4 Commits

Author SHA1 Message Date
Bnyro
0cbb4f74cc [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
2025-07-03 16:42:13 +02:00
Bnyro
99033f548e [feat] calculator: add support for math constants (e, pi) 2025-07-03 13:57:31 +02:00
Bnyro
27466faadb [feat] calculator: add support for comparation operators (<, <=, ==, ...) 2025-07-03 13:57:31 +02:00
Bnyro
a0fca8c21b [feat] calculator: add some operations (mod, shifts, bitwise and/or) 2025-07-03 13:57:31 +02:00
3 changed files with 233 additions and 176 deletions

View File

@@ -1,9 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""wttr.in (weather forecast service)"""
from json import loads
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 = {
"website": "https://wttr.in",
@@ -18,118 +20,105 @@ categories = ["weather"]
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):
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["raise_for_httperror"] = False
return params
def response(resp):
results = []
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
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>"
infobox += "<table><tbody>"
for time in forecast_indices.items():
infobox += f"<tr><td rowspan=\"7\"><b>{time[1]}</b></td></tr>"
infobox += generate_condition_table(day['hourly'][time[0]], resp.search_params['language'])
infobox += "</tbody></table>"
results.append(
{
"infobox": title,
"content": infobox,
}
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"],
)
return results
def response(resp):
res = EngineResults()
if resp.status_code == 404:
return res
json_data = resp.json()
geoloc = weather.GeoLocation.by_query(resp.search_params["query"])
weather_answer = WeatherAnswer(
current=_weather_data(geoloc, json_data["current_condition"][0]),
service="wttr.in",
)
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

View File

@@ -1,11 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Calculate mathematical expressions using :py:obj:`ast.parse` (mode="eval").
"""
"""Calculate mathematical expressions using :py:obj:`ast.parse` (mode="eval")."""
from __future__ import annotations
import typing
import ast
import math
import re
import operator
import multiprocessing
@@ -68,24 +68,55 @@ class SXNGPlugin(Plugin):
group = ui_locale.number_symbols["latn"]["group"]
query = re.sub(f"[0-9]+[{decimal}|{group}][0-9]+[{decimal}|{group}]?[0-9]?", _decimal, query)
# only numbers and math operators are accepted
if any(str.isalpha(c) for c in query):
return results
# in python, powers are calculated via **
query_py_formatted = query.replace("^", "**")
# Prevent the runtime from being longer than 50 ms
res = timeout_func(0.05, _eval_expr, query_py_formatted)
if res is None or res == "":
if res is None or res[0] == "":
return results
res = babel.numbers.format_decimal(res, locale=ui_locale)
res, is_boolean = res
if is_boolean:
res = "True" if res != 0 else "False"
else:
res = babel.numbers.format_decimal(res, locale=ui_locale)
results.add(results.types.Answer(answer=f"{search.search_query.query} = {res}"))
return results
def _compare(ops: list[ast.cmpop], values: list[int | float]) -> int:
"""
2 < 3 becomes ops=[ast.Lt] and values=[2,3]
2 < 3 <= 4 becomes ops=[ast.Lt, ast.LtE] and values=[2,3, 4]
"""
for op, a, b in zip(ops, values, values[1:]): # pylint: disable=invalid-name
if isinstance(op, ast.Eq) and a == b:
continue
if isinstance(op, ast.NotEq) and a != b:
continue
if isinstance(op, ast.Lt) and a < b:
continue
if isinstance(op, ast.LtE) and a <= b:
continue
if isinstance(op, ast.Gt) and a > b:
continue
if isinstance(op, ast.GtE) and a >= b:
continue
# Ignore impossible ops:
# * ast.Is
# * ast.IsNot
# * ast.In
# * ast.NotIn
# the result is False for a and b and operation op
return 0
# the results for all the ops are True
return 1
operators: dict[type, typing.Callable] = {
ast.Add: operator.add,
ast.Sub: operator.sub,
@@ -93,9 +124,22 @@ operators: dict[type, typing.Callable] = {
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.BitXor: operator.xor,
ast.BitOr: operator.or_,
ast.BitAnd: operator.and_,
ast.USub: operator.neg,
ast.RShift: operator.rshift,
ast.LShift: operator.lshift,
ast.Mod: operator.mod,
ast.Compare: _compare,
}
math_constants = {
'e': math.e,
'pi': math.pi,
}
# with multiprocessing.get_context("fork") we are ready for Py3.14 (by emulating
# the old behavior "fork") but it will not solve the core problem of fork, nor
# will it remove the deprecation warnings in py3.12 & py3.13. Issue is
@@ -105,18 +149,30 @@ mp_fork = multiprocessing.get_context("fork")
def _eval_expr(expr):
"""
Evaluates the given textual expression.
Returns a tuple of (numericResult, isBooleanResult).
>>> _eval_expr('2^6')
64
64, False
>>> _eval_expr('2**6')
64
64, False
>>> _eval_expr('1 + 2*3**(4^5) / (6 + -7)')
-5.0
-5.0, False
>>> _eval_expr('1 < 3')
1, True
>>> _eval_expr('5 < 3')
0, True
>>> _eval_expr('17 == 11+1+5 == 7+5+5')
1, True
"""
try:
return _eval(ast.parse(expr, mode='eval').body)
root_expr = ast.parse(expr, mode='eval').body
return _eval(root_expr), isinstance(root_expr, ast.Compare)
except ZeroDivisionError:
# This is undefined
return ""
return "", False
def _eval(node):
@@ -129,6 +185,12 @@ def _eval(node):
if isinstance(node, ast.UnaryOp):
return operators[type(node.op)](_eval(node.operand))
if isinstance(node, ast.Compare):
return _compare(node.ops, [_eval(node.left)] + [_eval(c) for c in node.comparators])
if isinstance(node, ast.Name) and node.id in math_constants:
return math_constants[node.id]
raise TypeError(node)

View File

@@ -481,46 +481,49 @@ class Compass:
WeatherConditionType = typing.Literal[
# The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
"clear sky",
"partly cloudy",
"cloudy",
"fair",
"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 showers and thunder",
"light rain showers",
"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 showers and thunder",
"rain showers",
"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 showers and thunder",
"sleet showers",
"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 showers and thunder",
"snow showers",
"snow",
"heavy snow and thunder",
"heavy snow showers and thunder",
"heavy snow showers",
"heavy snow",
]
"""Standardized designations for weather conditions. The designators were
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 = {
"clear sky": "01d", # 01d clearsky_day
"fair": "02d", # 02d fair_day
"partly cloudy": "03d", # 03d partlycloudy_day
"cloudy": "04", # 04 cloudy
"light rain showers": "40d", # 40d lightrainshowers_day
"rain showers": "05d", # 05d rainshowers_day
"heavy rain showers": "41d", # 41d heavyrainshowers_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": "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
"heavy rain and thunder": "11", # 11 heavyrainandthunder
"light sleet": "47", # 47 lightsleet
"sleet": "12", # 12 sleet
"heavy sleet": "48", # 48 heavysleet
"light sleet and thunder": "31", # 31 lightsleetandthunder
"sleet and thunder": "23", # 23 sleetandthunder
"heavy sleet and thunder": "32", # 32 heavysleetandthunder
"light snow": "49", # 49 lightsnow
"snow": "13", # 13 snow
"heavy snow": "50", # 50 heavysnow
"light snow and thunder": "33", # 33 lightsnowandthunder
"snow and thunder": "14", # 14 snowandthunder
"heavy snow and thunder": "34", # 34 heavysnowandthunder
"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": "40d", # 40d lightrainshowers_day
"light rain": "46", # 46 lightrain
"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 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 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 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 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 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 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 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`_