Browse Source

[feat] wttr.in: migrate to new weather engine template (#4888)

Author Notes
- wttr.in provides 8 hourly time forecasts per day, I assumed that they're always describing the weather for 3 hours each, starting at 1 o'clock in the morning

related:
- https://github.com/searxng/searxng/pull/4663
- https://github.com/searxng/searxng/issues/4885
Bnyro 3 days ago
parent
commit
0cbb4f74cc
2 changed files with 145 additions and 150 deletions
  1. 90 101
      searx/engines/wttr.py
  2. 55 49
      searx/weather.py

+ 90 - 101
searx/engines/wttr.py

@@ -1,9 +1,11 @@
 # SPDX-License-Identifier: AGPL-3.0-or-later
 # SPDX-License-Identifier: AGPL-3.0-or-later
 """wttr.in (weather forecast service)"""
 """wttr.in (weather forecast service)"""
 
 
-from json import loads
 from urllib.parse import quote
 from urllib.parse import quote
-from flask_babel import gettext
+from datetime import datetime
+
+from searx.result_types import EngineResults, WeatherAnswer
+from searx import weather
 
 
 about = {
 about = {
     "website": "https://wttr.in",
     "website": "https://wttr.in",
@@ -18,118 +20,105 @@ categories = ["weather"]
 
 
 url = "https://wttr.in/{query}?format=j1&lang={lang}"
 url = "https://wttr.in/{query}?format=j1&lang={lang}"
 
 
-
-def get_weather_condition_key(lang):
-    if lang == "en":
-        return "weatherDesc"
-
-    return "lang_" + lang.lower()
-
-
-def generate_day_table(day):
-    res = ""
-
-    res += f"<tr><td>{gettext('Average temp.')}</td><td>{day['avgtempC']}°C / {day['avgtempF']}°F</td></tr>"
-    res += f"<tr><td>{gettext('Min temp.')}</td><td>{day['mintempC']}°C / {day['mintempF']}°F</td></tr>"
-    res += f"<tr><td>{gettext('Max temp.')}</td><td>{day['maxtempC']}°C / {day['maxtempF']}°F</td></tr>"
-    res += f"<tr><td>{gettext('UV index')}</td><td>{day['uvIndex']}</td></tr>"
-    res += f"<tr><td>{gettext('Sunrise')}</td><td>{day['astronomy'][0]['sunrise']}</td></tr>"
-    res += f"<tr><td>{gettext('Sunset')}</td><td>{day['astronomy'][0]['sunset']}</td></tr>"
-
-    return res
-
-
-def generate_condition_table(condition, lang, current=False):
-    res = ""
-
-    if current:
-        key = "temp_"
-    else:
-        key = "temp"
-
-    res += (
-        f"<tr><td><b>{gettext('Condition')}</b></td>"
-        f"<td><b>{condition[get_weather_condition_key(lang)][0]['value']}</b></td></tr>"
-    )
-    res += (
-        f"<tr><td><b>{gettext('Temperature')}</b></td>"
-        f"<td><b>{condition[key+'C']}°C / {condition[key+'F']}°F</b></td></tr>"
-    )
-    res += (
-        f"<tr><td>{gettext('Feels like')}</td><td>{condition['FeelsLikeC']}°C / {condition['FeelsLikeF']}°F</td></tr>"
-    )
-    res += (
-        f"<tr><td>{gettext('Wind')}</td><td>{condition['winddir16Point']} — "
-        f"{condition['windspeedKmph']} km/h / {condition['windspeedMiles']} mph</td></tr>"
-    )
-    res += (
-        f"<tr><td>{gettext('Visibility')}</td><td>{condition['visibility']} km / {condition['visibilityMiles']} mi</td>"
-    )
-    res += f"<tr><td>{gettext('Humidity')}</td><td>{condition['humidity']}%</td></tr>"
-
-    return res
+# adapted from https://github.com/chubin/wttr.in/blob/master/lib/constants.py
+WWO_TO_CONDITION: dict[str, weather.WeatherConditionType] = {
+    "113": "clear sky",
+    "116": "partly cloudy",
+    "119": "cloudy",
+    "122": "fair",
+    "143": "fair",
+    "176": "light rain showers",
+    "179": "light snow showers",
+    "182": "light sleet showers",
+    "185": "light sleet",
+    "200": "rain and thunder",
+    "227": "light snow",
+    "230": "heavy snow",
+    "248": "fog",
+    "260": "fog",
+    "263": "light rain showers",
+    "266": "light rain showers",
+    "281": "light sleet showers",
+    "284": "light snow showers",
+    "293": "light rain showers",
+    "296": "light rain",
+    "299": "rain showers",
+    "302": "rain",
+    "305": "heavy rain showers",
+    "308": "heavy rain",
+    "311": "light sleet",
+    "314": "sleet",
+    "317": "light sleet",
+    "320": "heavy sleet",
+    "323": "light snow showers",
+    "326": "light snow showers",
+    "329": "heavy snow showers",
+    "332": "heavy snow",
+    "335": "heavy snow showers",
+    "338": "heavy snow",
+    "350": "light sleet",
+    "353": "light rain showers",
+    "356": "heavy rain showers",
+    "359": "heavy rain",
+    "362": "light sleet showers",
+    "365": "sleet showers",
+    "368": "light snow showers",
+    "371": "heavy snow showers",
+    "374": "light sleet showers",
+    "377": "heavy sleet",
+    "386": "rain showers and thunder",
+    "389": "heavy rain showers and thunder",
+    "392": "snow showers and thunder",
+    "395": "heavy snow showers",
+}
 
 
 
 
 def request(query, params):
 def request(query, params):
-    if query.replace('/', '') in [":help", ":bash.function", ":translation"]:
-        return None
-
-    if params["language"] == "all":
-        params["language"] = "en"
-    else:
-        params["language"] = params["language"].split("-")[0]
-
     params["url"] = url.format(query=quote(query), lang=params["language"])
     params["url"] = url.format(query=quote(query), lang=params["language"])
-
     params["raise_for_httperror"] = False
     params["raise_for_httperror"] = False
 
 
     return params
     return params
 
 
 
 
-def response(resp):
-    results = []
-
-    if resp.status_code == 404:
-        return []
-
-    result = loads(resp.text)
-
-    current = result["current_condition"][0]
-    location = result['nearest_area'][0]
-
-    forecast_indices = {3: gettext('Morning'), 4: gettext('Noon'), 6: gettext('Evening'), 7: gettext('Night')}
-
-    title = f"{location['areaName'][0]['value']}, {location['region'][0]['value']}"
-
-    infobox = f"<h3>{gettext('Current condition')}</h3><table><tbody>"
-
-    infobox += generate_condition_table(current, resp.search_params['language'], True)
-
-    infobox += "</tbody></table>"
-
-    for day in result["weather"]:
-        infobox += f"<h3>{day['date']}</h3>"
-
-        infobox += "<table><tbody>"
-
-        infobox += generate_day_table(day)
-
-        infobox += "</tbody></table>"
+def _weather_data(location: weather.GeoLocation, data: dict):
+    # the naming between different data objects is inconsitent, thus temp_C and
+    # tempC are possible
+    tempC: float = data.get("temp_C") or data.get("tempC")  # type: ignore
+
+    return WeatherAnswer.Item(
+        location=location,
+        temperature=weather.Temperature(unit="°C", value=tempC),
+        condition=WWO_TO_CONDITION[data["weatherCode"]],
+        feels_like=weather.Temperature(unit="°C", value=data["FeelsLikeC"]),
+        wind_from=weather.Compass(int(data["winddirDegree"])),
+        wind_speed=weather.WindSpeed(data["windspeedKmph"], unit="km/h"),
+        pressure=weather.Pressure(data["pressure"], unit="hPa"),
+        humidity=weather.RelativeHumidity(data["humidity"]),
+        cloud_cover=data["cloudcover"],
+    )
 
 
-        infobox += "<table><tbody>"
 
 
-        for time in forecast_indices.items():
-            infobox += f"<tr><td rowspan=\"7\"><b>{time[1]}</b></td></tr>"
+def response(resp):
+    res = EngineResults()
 
 
-            infobox += generate_condition_table(day['hourly'][time[0]], resp.search_params['language'])
+    if resp.status_code == 404:
+        return res
 
 
-        infobox += "</tbody></table>"
+    json_data = resp.json()
+    geoloc = weather.GeoLocation.by_query(resp.search_params["query"])
 
 
-    results.append(
-        {
-            "infobox": title,
-            "content": infobox,
-        }
+    weather_answer = WeatherAnswer(
+        current=_weather_data(geoloc, json_data["current_condition"][0]),
+        service="wttr.in",
     )
     )
 
 
-    return results
+    for day in json_data["weather"]:
+        date = datetime.fromisoformat(day["date"])
+        time_slot_len = 24 // len(day["hourly"])
+        for index, forecast in enumerate(day["hourly"]):
+            forecast_data = _weather_data(geoloc, forecast)
+            forecast_data.datetime = weather.DateTime(date.replace(hour=index * time_slot_len + 1))
+            weather_answer.forecasts.append(forecast_data)
+
+    res.add(weather_answer)
+    return res

+ 55 - 49
searx/weather.py

@@ -481,46 +481,49 @@ class Compass:
 WeatherConditionType = typing.Literal[
 WeatherConditionType = typing.Literal[
     # The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
     # The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
     "clear sky",
     "clear sky",
+    "partly cloudy",
     "cloudy",
     "cloudy",
     "fair",
     "fair",
     "fog",
     "fog",
-    "heavy rain and thunder",
-    "heavy rain showers and thunder",
-    "heavy rain showers",
-    "heavy rain",
-    "heavy sleet and thunder",
-    "heavy sleet showers and thunder",
-    "heavy sleet showers",
-    "heavy sleet",
-    "heavy snow and thunder",
-    "heavy snow showers and thunder",
-    "heavy snow showers",
-    "heavy snow",
+    # rain
     "light rain and thunder",
     "light rain and thunder",
     "light rain showers and thunder",
     "light rain showers and thunder",
     "light rain showers",
     "light rain showers",
     "light rain",
     "light rain",
-    "light sleet and thunder",
-    "light sleet showers and thunder",
-    "light sleet showers",
-    "light sleet",
-    "light snow and thunder",
-    "light snow showers and thunder",
-    "light snow showers",
-    "light snow",
-    "partly cloudy",
     "rain and thunder",
     "rain and thunder",
     "rain showers and thunder",
     "rain showers and thunder",
     "rain showers",
     "rain showers",
     "rain",
     "rain",
+    "heavy rain and thunder",
+    "heavy rain showers and thunder",
+    "heavy rain showers",
+    "heavy rain",
+    # sleet
+    "light sleet and thunder",
+    "light sleet showers and thunder",
+    "light sleet showers",
+    "light sleet",
     "sleet and thunder",
     "sleet and thunder",
     "sleet showers and thunder",
     "sleet showers and thunder",
     "sleet showers",
     "sleet showers",
     "sleet",
     "sleet",
+    "heavy sleet and thunder",
+    "heavy sleet showers and thunder",
+    "heavy sleet showers",
+    "heavy sleet",
+    # snow
+    "light snow and thunder",
+    "light snow showers and thunder",
+    "light snow showers",
+    "light snow",
     "snow and thunder",
     "snow and thunder",
     "snow showers and thunder",
     "snow showers and thunder",
     "snow showers",
     "snow showers",
     "snow",
     "snow",
+    "heavy snow and thunder",
+    "heavy snow showers and thunder",
+    "heavy snow showers",
+    "heavy snow",
 ]
 ]
 """Standardized designations for weather conditions.  The designators were
 """Standardized designations for weather conditions.  The designators were
 taken from a collaboration between NRK and Norwegian Meteorological Institute
 taken from a collaboration between NRK and Norwegian Meteorological Institute
@@ -535,46 +538,49 @@ taken from a collaboration between NRK and Norwegian Meteorological Institute
 
 
 YR_WEATHER_SYMBOL_MAP = {
 YR_WEATHER_SYMBOL_MAP = {
     "clear sky": "01d",  # 01d clearsky_day
     "clear sky": "01d",  # 01d clearsky_day
-    "fair": "02d",  # 02d fair_day
     "partly cloudy": "03d",  # 03d partlycloudy_day
     "partly cloudy": "03d",  # 03d partlycloudy_day
     "cloudy": "04",  # 04 cloudy
     "cloudy": "04",  # 04 cloudy
-    "light rain showers": "40d",  # 40d lightrainshowers_day
-    "rain showers": "05d",  # 05d rainshowers_day
-    "heavy rain showers": "41d",  # 41d heavyrainshowers_day
+    "fair": "02d",  # 02d fair_day
+    "fog": "15",  # 15 fog
+    # rain
+    "light rain and thunder": "30",  # 30 lightrainandthunder
     "light rain showers and thunder": "24d",  # 24d lightrainshowersandthunder_day
     "light rain showers and thunder": "24d",  # 24d lightrainshowersandthunder_day
-    "rain showers and thunder": "06d",  # 06d rainshowersandthunder_day
-    "heavy rain showers and thunder": "25d",  # 25d heavyrainshowersandthunder_day
-    "light sleet showers": "42d",  # 42d lightsleetshowers_day
-    "sleet showers": "07d",  # 07d sleetshowers_day
-    "heavy sleet showers": "43d",  # 43d heavysleetshowers_day
-    "light sleet showers and thunder": "26d",  # 26d lightssleetshowersandthunder_day
-    "sleet showers and thunder": "20d",  # 20d sleetshowersandthunder_day
-    "heavy sleet showers and thunder": "27d",  # 27d heavysleetshowersandthunder_day
-    "light snow showers": "44d",  # 44d lightsnowshowers_day
-    "snow showers": "08d",  # 08d snowshowers_day
-    "heavy snow showers": "45d",  # 45d heavysnowshowers_day
-    "light snow showers and thunder": "28d",  # 28d lightssnowshowersandthunder_day
-    "snow showers and thunder": "21d",  # 21d snowshowersandthunder_day
-    "heavy snow showers and thunder": "29d",  # 29d heavysnowshowersandthunder_day
+    "light rain showers": "40d",  # 40d lightrainshowers_day
     "light rain": "46",  # 46 lightrain
     "light rain": "46",  # 46 lightrain
-    "rain": "09",  # 09 rain
-    "heavy rain": "10",  # 10 heavyrain
-    "light rain and thunder": "30",  # 30 lightrainandthunder
     "rain and thunder": "22",  # 22 rainandthunder
     "rain and thunder": "22",  # 22 rainandthunder
+    "rain showers and thunder": "06d",  # 06d rainshowersandthunder_day
+    "rain showers": "05d",  # 05d rainshowers_day
+    "rain": "09",  # 09 rain
     "heavy rain and thunder": "11",  # 11 heavyrainandthunder
     "heavy rain and thunder": "11",  # 11 heavyrainandthunder
-    "light sleet": "47",  # 47 lightsleet
-    "sleet": "12",  # 12 sleet
-    "heavy sleet": "48",  # 48 heavysleet
+    "heavy rain showers and thunder": "25d",  # 25d heavyrainshowersandthunder_day
+    "heavy rain showers": "41d",  # 41d heavyrainshowers_day
+    "heavy rain": "10",  # 10 heavyrain
+    # sleet
     "light sleet and thunder": "31",  # 31 lightsleetandthunder
     "light sleet and thunder": "31",  # 31 lightsleetandthunder
+    "light sleet showers and thunder": "26d",  # 26d lightssleetshowersandthunder_day
+    "light sleet showers": "42d",  # 42d lightsleetshowers_day
+    "light sleet": "47",  # 47 lightsleet
     "sleet and thunder": "23",  # 23 sleetandthunder
     "sleet and thunder": "23",  # 23 sleetandthunder
+    "sleet showers and thunder": "20d",  # 20d sleetshowersandthunder_day
+    "sleet showers": "07d",  # 07d sleetshowers_day
+    "sleet": "12",  # 12 sleet
     "heavy sleet and thunder": "32",  # 32 heavysleetandthunder
     "heavy sleet and thunder": "32",  # 32 heavysleetandthunder
-    "light snow": "49",  # 49 lightsnow
-    "snow": "13",  # 13 snow
-    "heavy snow": "50",  # 50 heavysnow
+    "heavy sleet showers and thunder": "27d",  # 27d heavysleetshowersandthunder_day
+    "heavy sleet showers": "43d",  # 43d heavysleetshowers_day
+    "heavy sleet": "48",  # 48 heavysleet
+    # snow
     "light snow and thunder": "33",  # 33 lightsnowandthunder
     "light snow and thunder": "33",  # 33 lightsnowandthunder
+    "light snow showers and thunder": "28d",  # 28d lightssnowshowersandthunder_day
+    "light snow showers": "44d",  # 44d lightsnowshowers_day
+    "light snow": "49",  # 49 lightsnow
     "snow and thunder": "14",  # 14 snowandthunder
     "snow and thunder": "14",  # 14 snowandthunder
+    "snow showers and thunder": "21d",  # 21d snowshowersandthunder_day
+    "snow showers": "08d",  # 08d snowshowers_day
+    "snow": "13",  # 13 snow
     "heavy snow and thunder": "34",  # 34 heavysnowandthunder
     "heavy snow and thunder": "34",  # 34 heavysnowandthunder
-    "fog": "15",  # 15 fog
+    "heavy snow showers and thunder": "29d",  # 29d heavysnowshowersandthunder_day
+    "heavy snow showers": "45d",  # 45d heavysnowshowers_day
+    "heavy snow": "50",  # 50 heavysnow
 }
 }
 """Map a :py:obj:`WeatherConditionType` to a `YR weather symbol`_
 """Map a :py:obj:`WeatherConditionType` to a `YR weather symbol`_