Merge 7f087496d24e5272e2bf32bf449381a95a3e53b3 into 90068660196d898896219d1df7a088348c5d3d14
This commit is contained in:
commit
bfba2279bf
@ -11,10 +11,10 @@ end_of_line = lf
|
||||
charset = utf-8
|
||||
|
||||
[*.py]
|
||||
max_line_length = 119
|
||||
max_line_length = 79
|
||||
|
||||
[*.html]
|
||||
indent_size = 4
|
||||
indent_size = 2
|
||||
|
||||
[*.css]
|
||||
indent_size = 2
|
||||
|
@ -19,6 +19,7 @@
|
||||
@import "new_issue.less";
|
||||
@import "stats.less";
|
||||
@import "result_templates.less";
|
||||
@import "weather.less";
|
||||
|
||||
// for index.html template
|
||||
@import "index.less";
|
||||
|
20
client/simple/src/less/weather.less
Normal file
20
client/simple/src/less/weather.less
Normal file
@ -0,0 +1,20 @@
|
||||
#answers .weather {
|
||||
img.thumbnail {
|
||||
padding: 0;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
table {
|
||||
background-color: var(--color-result-keyvalue-table);
|
||||
}
|
||||
|
||||
tr.odd {
|
||||
background-color: var(--color-result-keyvalue-odd);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
tr.even {
|
||||
background-color: var(--color-result-keyvalue-even);
|
||||
padding: 0;
|
||||
}
|
||||
}
|
8
docs/src/searx.weather.rst
Normal file
8
docs/src/searx.weather.rst
Normal file
@ -0,0 +1,8 @@
|
||||
.. _weather:
|
||||
|
||||
=======
|
||||
Weather
|
||||
=======
|
||||
|
||||
.. automodule:: searx.weather
|
||||
:members:
|
@ -45,6 +45,14 @@ def extract(
|
||||
namespace = {}
|
||||
exec(fileobj.read(), {}, namespace) # pylint: disable=exec-used
|
||||
|
||||
for name in namespace['__all__']:
|
||||
for k, v in namespace[name].items():
|
||||
yield 0, '_', v, ["%s['%s']" % (name, k)]
|
||||
for obj_name in namespace['__all__']:
|
||||
obj = namespace[obj_name]
|
||||
if isinstance(obj, list):
|
||||
for msg in obj:
|
||||
# (lineno, funcname, message, comments)
|
||||
yield 0, '_', msg, [f"{obj_name}"]
|
||||
elif isinstance(obj, dict):
|
||||
for k, msg in obj.items():
|
||||
yield 0, '_', msg, [f"{obj_name}['{k}']"]
|
||||
else:
|
||||
raise ValueError(f"{obj_name} should be list or dict")
|
||||
|
@ -226,7 +226,7 @@ class ExpireCacheSQLite(sqlitedb.SQLiteAppl, ExpireCache):
|
||||
# The key/value tables will be created on demand by self.create_table
|
||||
DDL_CREATE_TABLES = {}
|
||||
|
||||
CACHE_TABLE_PREFIX = "CACHE-TABLE-"
|
||||
CACHE_TABLE_PREFIX = "CACHE-TABLE"
|
||||
|
||||
def __init__(self, cfg: ExpireCacheCfg):
|
||||
"""An instance of the SQLite expire cache is build up from a
|
||||
|
@ -3,15 +3,17 @@
|
||||
|
||||
from urllib.parse import urlencode, quote_plus
|
||||
from datetime import datetime
|
||||
from flask_babel import gettext
|
||||
|
||||
from searx.network import get
|
||||
from searx.exceptions import SearxEngineAPIException
|
||||
from searx.result_types import EngineResults, WeatherAnswer
|
||||
from searx import weather
|
||||
|
||||
|
||||
about = {
|
||||
"website": 'https://open-meteo.com',
|
||||
"website": "https://open-meteo.com",
|
||||
"wikidata_id": None,
|
||||
"official_api_documentation": 'https://open-meteo.com/en/docs',
|
||||
"official_api_documentation": "https://open-meteo.com/en/docs",
|
||||
"use_official_api": True,
|
||||
"require_api_key": False,
|
||||
"results": "JSON",
|
||||
@ -22,7 +24,17 @@ categories = ["weather"]
|
||||
geo_url = "https://geocoding-api.open-meteo.com"
|
||||
api_url = "https://api.open-meteo.com"
|
||||
|
||||
data_of_interest = "temperature_2m,relative_humidity_2m,apparent_temperature,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m" # pylint: disable=line-too-long
|
||||
data_of_interest = (
|
||||
"temperature_2m",
|
||||
"apparent_temperature",
|
||||
"relative_humidity_2m",
|
||||
"apparent_temperature",
|
||||
"cloud_cover",
|
||||
"pressure_msl",
|
||||
"wind_speed_10m",
|
||||
"wind_direction_10m",
|
||||
"weather_code",
|
||||
)
|
||||
|
||||
|
||||
def request(query, params):
|
||||
@ -38,81 +50,113 @@ def request(query, params):
|
||||
|
||||
location = json_locations[0]
|
||||
args = {
|
||||
'latitude': location['latitude'],
|
||||
'longitude': location['longitude'],
|
||||
'timeformat': 'unixtime',
|
||||
'format': 'json',
|
||||
'current': data_of_interest,
|
||||
'forecast_days': 7,
|
||||
'hourly': data_of_interest,
|
||||
"latitude": location["latitude"],
|
||||
"longitude": location["longitude"],
|
||||
"timeformat": "unixtime",
|
||||
"format": "json",
|
||||
"current": ",".join(data_of_interest),
|
||||
"forecast_days": 3,
|
||||
"hourly": ",".join(data_of_interest),
|
||||
}
|
||||
|
||||
params['url'] = f"{api_url}/v1/forecast?{urlencode(args)}"
|
||||
params["url"] = f"{api_url}/v1/forecast?{urlencode(args)}"
|
||||
params["location"] = location["name"]
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def c_to_f(temperature):
|
||||
return "%.2f" % ((temperature * 1.8) + 32)
|
||||
# https://open-meteo.com/en/docs#weather_variable_documentation
|
||||
# https://nrkno.github.io/yr-weather-symbols/
|
||||
#
|
||||
# F I X M E:
|
||||
#
|
||||
# Based on the weather icons, we should check again whether this mapping
|
||||
# table needs to be corrected ..
|
||||
#
|
||||
WMO_TO_CONDITION: dict[int, weather.WeatherConditionType] = {
|
||||
# 0 Clear sky
|
||||
0: "clear sky",
|
||||
# 1, 2, 3 Mainly clear, partly cloudy, and overcast
|
||||
1: "fair",
|
||||
2: "partly cloudy",
|
||||
3: "cloudy",
|
||||
# 45, 48 Fog and depositing rime fog
|
||||
45: "fog",
|
||||
48: "fog",
|
||||
# 51, 53, 55 Drizzle: Light, moderate, and dense intensity
|
||||
51: "light rain",
|
||||
53: "light rain",
|
||||
55: "light rain",
|
||||
# 56, 57 Freezing Drizzle: Light and dense intensity
|
||||
56: "light sleet showers",
|
||||
57: "light sleet",
|
||||
# 61, 63, 65 Rain: Slight, moderate and heavy intensity
|
||||
61: "light rain",
|
||||
63: "rain",
|
||||
65: "heavy rain",
|
||||
# 66, 67 Freezing Rain: Light and heavy intensity
|
||||
66: "light sleet showers",
|
||||
67: "light sleet",
|
||||
# 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity
|
||||
71: "light sleet",
|
||||
73: "sleet",
|
||||
75: "heavy sleet",
|
||||
# 77 Snow grains
|
||||
77: "snow",
|
||||
# 80, 81, 82 Rain showers: Slight, moderate, and violent
|
||||
80: "light rain showers",
|
||||
81: "rain showers",
|
||||
82: "heavy rain showers",
|
||||
# 85, 86 Snow showers slight and heavy
|
||||
85: "snow showers",
|
||||
86: "heavy snow showers",
|
||||
# 95 Thunderstorm: Slight or moderate
|
||||
95: "rain and thunder",
|
||||
# 96, 99 Thunderstorm with slight and heavy hail
|
||||
96: "light snow and thunder",
|
||||
99: "heavy snow and thunder",
|
||||
}
|
||||
|
||||
|
||||
def get_direction(degrees):
|
||||
if degrees < 45 or degrees >= 315:
|
||||
return "N"
|
||||
def _weather_data(location, data):
|
||||
|
||||
if 45 <= degrees < 135:
|
||||
return "O"
|
||||
|
||||
if 135 <= degrees < 225:
|
||||
return "S"
|
||||
|
||||
return "W"
|
||||
|
||||
|
||||
def generate_condition_table(condition):
|
||||
res = ""
|
||||
|
||||
res += (
|
||||
f"<tr><td><b>{gettext('Temperature')}</b></td>"
|
||||
f"<td><b>{condition['temperature_2m']}°C / {c_to_f(condition['temperature_2m'])}°F</b></td></tr>"
|
||||
return WeatherAnswer.Item(
|
||||
location=location,
|
||||
temperature=weather.Temperature(unit="°C", value=data["temperature_2m"]),
|
||||
condition=WMO_TO_CONDITION[data["weather_code"]],
|
||||
feels_like=weather.Temperature(unit="°C", value=data["apparent_temperature"]),
|
||||
wind_from=weather.Compass(data["wind_direction_10m"]),
|
||||
wind_speed=weather.WindSpeed(data["wind_speed_10m"], unit="km/h"),
|
||||
pressure=weather.Pressure(data["pressure_msl"], unit="hPa"),
|
||||
humidity=weather.RelativeHumidity(data["relative_humidity_2m"]),
|
||||
cloud_cover=data["cloud_cover"],
|
||||
)
|
||||
|
||||
res += (
|
||||
f"<tr><td>{gettext('Feels like')}</td><td>{condition['apparent_temperature']}°C / "
|
||||
f"{c_to_f(condition['apparent_temperature'])}°F</td></tr>"
|
||||
)
|
||||
|
||||
res += (
|
||||
f"<tr><td>{gettext('Wind')}</td><td>{get_direction(condition['wind_direction_10m'])}, "
|
||||
f"{condition['wind_direction_10m']}° — "
|
||||
f"{condition['wind_speed_10m']} km/h</td></tr>"
|
||||
)
|
||||
|
||||
res += f"<tr><td>{gettext('Cloud cover')}</td><td>{condition['cloud_cover']}%</td>"
|
||||
|
||||
res += f"<tr><td>{gettext('Humidity')}</td><td>{condition['relative_humidity_2m']}%</td></tr>"
|
||||
|
||||
res += f"<tr><td>{gettext('Pressure')}</td><td>{condition['pressure_msl']}hPa</td></tr>"
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def response(resp):
|
||||
data = resp.json()
|
||||
res = EngineResults()
|
||||
json_data = resp.json()
|
||||
|
||||
table_content = generate_condition_table(data['current'])
|
||||
location = resp.search_params["location"]
|
||||
weather_answer = WeatherAnswer(
|
||||
current=_weather_data(location, json_data["current"]),
|
||||
service="Open-meteo",
|
||||
url="https://open-meteo.com/en/docs",
|
||||
)
|
||||
|
||||
infobox = f"<table><tbody>{table_content}</tbody></table>"
|
||||
for index, time in enumerate(json_data["hourly"]["time"]):
|
||||
|
||||
if time < json_data["current"]["time"]:
|
||||
# Cut off the hours that are already in the past
|
||||
continue
|
||||
|
||||
for index, time in enumerate(data['hourly']['time']):
|
||||
hourly_data = {}
|
||||
for key in data_of_interest:
|
||||
hourly_data[key] = json_data["hourly"][key][index]
|
||||
|
||||
for key in data_of_interest.split(","):
|
||||
hourly_data[key] = data['hourly'][key][index]
|
||||
forecast_data = _weather_data(location, hourly_data)
|
||||
forecast_data.time = datetime.fromtimestamp(time).strftime("%Y-%m-%d %H:%M")
|
||||
weather_answer.forecasts.append(forecast_data)
|
||||
|
||||
table_content = generate_condition_table(hourly_data)
|
||||
|
||||
infobox += f"<h3>{datetime.fromtimestamp(time).strftime('%Y-%m-%d %H:%M')}</h3>"
|
||||
infobox += f"<table><tbody>{table_content}</tbody></table>"
|
||||
|
||||
return [{'infobox': 'Open Meteo', 'content': infobox}]
|
||||
res.add(weather_answer)
|
||||
return res
|
||||
|
@ -15,7 +15,7 @@ import babel.numbers
|
||||
|
||||
from flask_babel import gettext, get_locale
|
||||
|
||||
from searx import data
|
||||
from searx.wikidata_units import symbol_to_si
|
||||
from searx.plugins import Plugin, PluginInfo
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
@ -86,132 +86,6 @@ RE_MEASURE = r'''
|
||||
'''
|
||||
|
||||
|
||||
ADDITIONAL_UNITS = [
|
||||
{
|
||||
"si_name": "Q11579",
|
||||
"symbol": "°C",
|
||||
"to_si": lambda val: val + 273.15,
|
||||
"from_si": lambda val: val - 273.15,
|
||||
},
|
||||
{
|
||||
"si_name": "Q11579",
|
||||
"symbol": "°F",
|
||||
"to_si": lambda val: (val + 459.67) * 5 / 9,
|
||||
"from_si": lambda val: (val * 9 / 5) - 459.67,
|
||||
},
|
||||
]
|
||||
"""Additional items to convert from a measure unit to a SI unit (vice versa).
|
||||
|
||||
.. code:: python
|
||||
|
||||
{
|
||||
"si_name": "Q11579", # Wikidata item ID of the SI unit (Kelvin)
|
||||
"symbol": "°C", # symbol of the measure unit
|
||||
"to_si": lambda val: val + 273.15, # convert measure value (val) to SI unit
|
||||
"from_si": lambda val: val - 273.15, # convert SI value (val) measure unit
|
||||
},
|
||||
{
|
||||
"si_name": "Q11573",
|
||||
"symbol": "mi",
|
||||
"to_si": 1609.344, # convert measure value (val) to SI unit
|
||||
"from_si": 1 / 1609.344 # convert SI value (val) measure unit
|
||||
},
|
||||
|
||||
The values of ``to_si`` and ``from_si`` can be of :py:obj:`float` (a multiplier)
|
||||
or a callable_ (val in / converted value returned).
|
||||
|
||||
.. _callable: https://docs.python.org/3/glossary.html#term-callable
|
||||
"""
|
||||
|
||||
|
||||
ALIAS_SYMBOLS = {
|
||||
'°C': ('C',),
|
||||
'°F': ('F',),
|
||||
'mi': ('L',),
|
||||
}
|
||||
"""Alias symbols for known unit of measure symbols / by example::
|
||||
|
||||
'°C': ('C', ...), # list of alias symbols for °C (Q69362731)
|
||||
'°F': ('F', ...), # list of alias symbols for °F (Q99490479)
|
||||
'mi': ('L',), # list of alias symbols for mi (Q253276)
|
||||
"""
|
||||
|
||||
|
||||
SYMBOL_TO_SI = []
|
||||
|
||||
|
||||
def symbol_to_si():
|
||||
"""Generates a list of tuples, each tuple is a measure unit and the fields
|
||||
in the tuple are:
|
||||
|
||||
0. Symbol of the measure unit (e.g. 'mi' for measure unit 'miles' Q253276)
|
||||
|
||||
1. SI name of the measure unit (e.g. Q11573 for SI unit 'metre')
|
||||
|
||||
2. Factor to get SI value from measure unit (e.g. 1mi is equal to SI 1m
|
||||
multiplied by 1609.344)
|
||||
|
||||
3. Factor to get measure value from from SI value (e.g. SI 100m is equal to
|
||||
100mi divided by 1609.344)
|
||||
|
||||
The returned list is sorted, the first items are created from
|
||||
``WIKIDATA_UNITS``, the second group of items is build from
|
||||
:py:obj:`ADDITIONAL_UNITS` and items created from :py:obj:`ALIAS_SYMBOLS`.
|
||||
|
||||
If you search this list for a symbol, then a match with a symbol from
|
||||
Wikidata has the highest weighting (first hit in the list), followed by the
|
||||
symbols from the :py:obj:`ADDITIONAL_UNITS` and the lowest weighting is
|
||||
given to the symbols resulting from the aliases :py:obj:`ALIAS_SYMBOLS`.
|
||||
|
||||
"""
|
||||
|
||||
global SYMBOL_TO_SI # pylint: disable=global-statement
|
||||
if SYMBOL_TO_SI:
|
||||
return SYMBOL_TO_SI
|
||||
|
||||
# filter out units which can't be normalized to a SI unit and filter out
|
||||
# units without a symbol / arcsecond does not have a symbol
|
||||
# https://www.wikidata.org/wiki/Q829073
|
||||
|
||||
for item in data.WIKIDATA_UNITS.values():
|
||||
if item['to_si_factor'] and item['symbol']:
|
||||
SYMBOL_TO_SI.append(
|
||||
(
|
||||
item['symbol'],
|
||||
item['si_name'],
|
||||
1 / item['to_si_factor'], # from_si
|
||||
item['to_si_factor'], # to_si
|
||||
item['symbol'],
|
||||
)
|
||||
)
|
||||
|
||||
for item in ADDITIONAL_UNITS:
|
||||
SYMBOL_TO_SI.append(
|
||||
(
|
||||
item['symbol'],
|
||||
item['si_name'],
|
||||
item['from_si'],
|
||||
item['to_si'],
|
||||
item['symbol'],
|
||||
)
|
||||
)
|
||||
|
||||
alias_items = []
|
||||
for item in SYMBOL_TO_SI:
|
||||
for alias in ALIAS_SYMBOLS.get(item[0], ()):
|
||||
alias_items.append(
|
||||
(
|
||||
alias,
|
||||
item[1],
|
||||
item[2], # from_si
|
||||
item[3], # to_si
|
||||
item[0], # origin unit
|
||||
)
|
||||
)
|
||||
SYMBOL_TO_SI = SYMBOL_TO_SI + alias_items
|
||||
return SYMBOL_TO_SI
|
||||
|
||||
|
||||
def _parse_text_and_convert(from_query, to_query) -> str | None:
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-locals
|
||||
|
@ -13,14 +13,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["Result", "MainResult", "KeyValue", "EngineResults", "AnswerSet", "Answer", "Translations"]
|
||||
__all__ = ["Result", "MainResult", "KeyValue", "EngineResults", "AnswerSet", "Answer", "Translations", "WeatherAnswer"]
|
||||
|
||||
import abc
|
||||
|
||||
from searx import enginelib
|
||||
|
||||
from ._base import Result, MainResult, LegacyResult
|
||||
from .answer import AnswerSet, Answer, Translations
|
||||
from .answer import AnswerSet, Answer, Translations, WeatherAnswer
|
||||
from .keyvalue import KeyValue
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ class ResultList(list, abc.ABC):
|
||||
MainResult = MainResult
|
||||
Result = Result
|
||||
Translations = Translations
|
||||
WeatherAnswer = WeatherAnswer
|
||||
|
||||
# for backward compatibility
|
||||
LegacyResult = LegacyResult
|
||||
|
@ -18,6 +18,10 @@ template.
|
||||
:members:
|
||||
:show-inheritance:
|
||||
|
||||
.. autoclass:: WeatherAnswer
|
||||
:members:
|
||||
:show-inheritance:
|
||||
|
||||
.. autoclass:: AnswerSet
|
||||
:members:
|
||||
:show-inheritance:
|
||||
@ -26,10 +30,12 @@ template.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["AnswerSet", "Answer", "Translations"]
|
||||
__all__ = ["AnswerSet", "Answer", "Translations", "WeatherAnswer"]
|
||||
|
||||
from flask_babel import gettext
|
||||
import msgspec
|
||||
|
||||
from searx import weather
|
||||
from ._base import Result
|
||||
|
||||
|
||||
@ -143,3 +149,90 @@ class Translations(BaseAnswer, kw_only=True):
|
||||
|
||||
synonyms: list[str] = []
|
||||
"""List of synonyms for the requested translation."""
|
||||
|
||||
|
||||
class WeatherAnswer(BaseAnswer, kw_only=True):
|
||||
"""Answer type for weather data."""
|
||||
|
||||
template: str = "answer/weather.html"
|
||||
"""The template is located at :origin:`answer/weather.html
|
||||
<searx/templates/simple/answer/weather.html>`"""
|
||||
|
||||
current: WeatherAnswer.Item
|
||||
"""Current weather at ``location``."""
|
||||
|
||||
forecasts: list[WeatherAnswer.Item] = []
|
||||
"""Weather forecasts for ``location``."""
|
||||
|
||||
service: str = ""
|
||||
"""Weather service from which this information was provided."""
|
||||
|
||||
class Item(msgspec.Struct, kw_only=True):
|
||||
"""Weather parameters valid for a specific point in time."""
|
||||
|
||||
location: str
|
||||
"""The geo-location the weather data is from (e.g. `Berlin, Germany`)."""
|
||||
|
||||
temperature: weather.Temperature
|
||||
"""Air temperature at 2m above the ground."""
|
||||
|
||||
condition: weather.WeatherConditionType
|
||||
"""Standardized designations that summarize the weather situation
|
||||
(e.g. ``light sleet showers and thunder``)."""
|
||||
|
||||
# optional fields
|
||||
|
||||
time: str | None = None
|
||||
"""Time of the forecast - not needed for the current weather."""
|
||||
|
||||
summary: str | None = None
|
||||
"""One-liner about the weather forecast / current weather conditions.
|
||||
If unset, a summary is build up from temperature and current weather
|
||||
conditions.
|
||||
"""
|
||||
|
||||
feels_like: weather.Temperature | None = None
|
||||
"""Apparent temperature, the temperature equivalent perceived by
|
||||
humans, caused by the combined effects of air temperature, relative
|
||||
humidity and wind speed. The measure is most commonly applied to the
|
||||
perceived outdoor temperature.
|
||||
"""
|
||||
|
||||
pressure: weather.Pressure | None = None
|
||||
"""Air pressure at sea level (e.g. 1030 hPa) """
|
||||
|
||||
humidity: weather.RelativeHumidity | None = None
|
||||
"""Amount of relative humidity in the air at 2m above the ground. The
|
||||
unit is ``%``, e.g. 60%)
|
||||
"""
|
||||
|
||||
wind_from: weather.Compass
|
||||
"""The directon which moves towards / direction the wind is coming from."""
|
||||
|
||||
wind_speed: weather.WindSpeed | None = None
|
||||
"""Speed of wind / wind speed at 10m above the ground (10 min average)."""
|
||||
|
||||
cloud_cover: int | None = None
|
||||
"""Amount of sky covered by clouds / total cloud cover for all heights
|
||||
(cloudiness, unit: %)"""
|
||||
|
||||
# attributes: dict[str, str | int] = {}
|
||||
# """Key-Value dict of additional typeless weather attributes."""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.summary:
|
||||
self.summary = gettext("{location}: {temperature}, {condition}").format(
|
||||
location=self.location,
|
||||
temperature=self.temperature,
|
||||
condition=gettext(self.condition.capitalize()),
|
||||
)
|
||||
|
||||
@property
|
||||
def url(self) -> str | None:
|
||||
"""Determines a `data URL`_ with a symbol for the weather
|
||||
conditions. If no symbol can be assigned, ``None`` is returned.
|
||||
|
||||
.. _data URL:
|
||||
https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data
|
||||
"""
|
||||
return weather.symbol_url(self.condition)
|
||||
|
@ -3,8 +3,12 @@
|
||||
"""A SearXNG message file, see :py:obj:`searx.babel`
|
||||
"""
|
||||
|
||||
import typing
|
||||
|
||||
from searx import webutils
|
||||
from searx import engines
|
||||
from searx.weather import WeatherConditionType
|
||||
|
||||
|
||||
__all__ = [
|
||||
'CONSTANT_NAMES',
|
||||
@ -13,6 +17,7 @@ __all__ = [
|
||||
'STYLE_NAMES',
|
||||
'BRAND_CUSTOM_LINKS',
|
||||
'WEATHER_TERMS',
|
||||
'WEATHER_CONDITIONS',
|
||||
'SOCIAL_MEDIA_TERMS',
|
||||
]
|
||||
|
||||
@ -85,6 +90,13 @@ WEATHER_TERMS = {
|
||||
'WIND': 'Wind',
|
||||
}
|
||||
|
||||
|
||||
WEATHER_CONDITIONS = [
|
||||
# The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
|
||||
msg.capitalize()
|
||||
for msg in typing.get_args(WeatherConditionType)
|
||||
]
|
||||
|
||||
SOCIAL_MEDIA_TERMS = {
|
||||
'SUBSCRIBERS': 'subscribers',
|
||||
'POSTS': 'posts',
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
55
searx/templates/simple/answer/weather.html
Normal file
55
searx/templates/simple/answer/weather.html
Normal file
@ -0,0 +1,55 @@
|
||||
{% macro show_weather_data(answer, data) %}
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="5">
|
||||
{%- if data.url %}<img src="{{ data.url }}" class="thumbnail" title="{{ data.summary }}">{% endif -%}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>{{ _("Condition") }}:</td>
|
||||
<td>{{ _(data.condition.capitalize()) }}</td>
|
||||
<td>{{ _("Feels Like") }}:</td>
|
||||
<td>{{ data.feels_like }}</td>
|
||||
</tr>
|
||||
<tr class="even">
|
||||
<td>{{ _("Temperature") }}:</td>
|
||||
<td>{{ data.temperature }}</td>
|
||||
<td>{{ _("Wind") }}:</td>
|
||||
<td>{{ data.wind_from }}: {{ data.wind_speed }}</td>
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<td>{{_("Humidity")}}:</td>
|
||||
<td>{{ data.humidity }}</td>
|
||||
<td>{{ _("Pressure") }}:</td>
|
||||
<td>{{ data.pressure }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
|
||||
<details class="weather">
|
||||
<summary>
|
||||
{%- if answer.url -%}
|
||||
<a href="{{ answer.url }}" class="answer-url"
|
||||
{%- if results_on_new_tab %}target="_blank" rel="noopener noreferrer"{%- else -%}rel="noreferrer"{%- endif -%}>
|
||||
{{ answer.service }}
|
||||
</a>
|
||||
{%- else -%}
|
||||
{{ answer.service }}
|
||||
{% endif -%}
|
||||
{{ answer.current.summary }}
|
||||
|
||||
{{ show_weather_data(answer, answer.current) }}
|
||||
</summary>
|
||||
<div class="weather-forecast">
|
||||
{%- if answer.forecasts -%}
|
||||
<div class="answer-weather-forecasts">
|
||||
{%- for forecast in answer.forecasts -%}
|
||||
<h3>{{ forecast.time }}</h3>
|
||||
{{ show_weather_data(answer, forecast) }}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
</details>
|
461
searx/weather.py
Normal file
461
searx/weather.py
Normal file
@ -0,0 +1,461 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Implementations used for weather conditions and forecast."""
|
||||
# pylint: disable=too-few-public-methods
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"symbol_url",
|
||||
"Temperature",
|
||||
"Pressure",
|
||||
"WindSpeed",
|
||||
"RelativeHumidity",
|
||||
"Compass",
|
||||
"WeatherConditionType",
|
||||
]
|
||||
|
||||
import typing
|
||||
|
||||
import base64
|
||||
import babel
|
||||
import babel.numbers
|
||||
|
||||
from searx import network
|
||||
from searx.cache import ExpireCache, ExpireCacheCfg
|
||||
from searx.extended_types import sxng_request
|
||||
from searx.wikidata_units import convert_to_si, convert_from_si
|
||||
|
||||
WEATHER_SYMBOL_CACHE: ExpireCache = None # type: ignore
|
||||
"""A simple cache for weather condition icons."""
|
||||
|
||||
YR_WEATHER_SYMBOL_URL = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols/outline"
|
||||
|
||||
|
||||
def _get_locale_tag() -> str:
|
||||
# pylint: disable=import-outside-toplevel,disable=cyclic-import
|
||||
from searx import query
|
||||
from searx.preferences import ClientPref
|
||||
|
||||
query = query.RawTextQuery(sxng_request.form.get("q", ""), [])
|
||||
if query.languages and query.languages[0] not in ["all", "auto"]:
|
||||
return query.languages[0]
|
||||
|
||||
search_lang = sxng_request.form.get("language")
|
||||
if search_lang and search_lang not in ["all", "auto"]:
|
||||
return search_lang
|
||||
|
||||
client_pref = ClientPref.from_http_request(sxng_request)
|
||||
search_lang = client_pref.locale_tag
|
||||
if search_lang and search_lang not in ["all", "auto"]:
|
||||
return search_lang
|
||||
return "en"
|
||||
|
||||
|
||||
def symbol_url(condition: WeatherConditionType) -> str | None:
|
||||
"""Returns ``data:`` URL for the weather condition symbol or ``None`` if
|
||||
the condition is not of type :py:obj:`WeatherConditionType`.
|
||||
|
||||
If symbol (SVG) is not already in the :py:obj:`WEATHER_SYMBOL_CACHE` its
|
||||
fetched from https://github.com/nrkno/yr-weather-symbols
|
||||
|
||||
.. todo::
|
||||
|
||||
Symbols for darkmode/lightmode .. and day/night symnbols (for latter we
|
||||
need a geopint / critical)
|
||||
"""
|
||||
global WEATHER_SYMBOL_CACHE # pylint: disable=global-statement
|
||||
|
||||
fname = YR_WEATHER_SYMBOL_MAP.get(condition)
|
||||
if fname is None:
|
||||
return None
|
||||
|
||||
if WEATHER_SYMBOL_CACHE is None:
|
||||
WEATHER_SYMBOL_CACHE = ExpireCache.build_cache(
|
||||
ExpireCacheCfg(
|
||||
name="WEATHER_SYMBOL_CACHE",
|
||||
MAX_VALUE_LEN=1024 * 200, # max. 200kB per icon (icons have most often 10-20kB)
|
||||
)
|
||||
)
|
||||
|
||||
origin_url = f"{YR_WEATHER_SYMBOL_URL}/{fname}.svg"
|
||||
data_url = WEATHER_SYMBOL_CACHE.get(origin_url)
|
||||
if data_url is not None:
|
||||
return data_url
|
||||
|
||||
response = network.get(origin_url, timeout=3)
|
||||
if response and response.status_code == 200:
|
||||
mimetype = response.headers['Content-Type']
|
||||
data_url = f"data:{mimetype};base64,{str(base64.b64encode(response.content), 'utf-8')}"
|
||||
WEATHER_SYMBOL_CACHE.set(key=origin_url, value=data_url, expire=None)
|
||||
return data_url
|
||||
|
||||
|
||||
class Temperature:
|
||||
"""Class for converting temperature units and for string representation of
|
||||
measured values."""
|
||||
|
||||
si_name = "Q11579"
|
||||
|
||||
Units = typing.Literal["°C", "°F", "K"]
|
||||
"""Supported temperature units."""
|
||||
|
||||
units = list(typing.get_args(Units))
|
||||
|
||||
def __init__(self, value: float, unit: Units):
|
||||
if unit not in self.units:
|
||||
raise ValueError(f"invalid unit: {unit}")
|
||||
self.si: float = convert_to_si( # pylint: disable=invalid-name
|
||||
si_name=self.si_name,
|
||||
symbol=unit,
|
||||
value=value,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.l10n()
|
||||
|
||||
def value(self, unit: Units) -> float:
|
||||
return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
|
||||
|
||||
def l10n(
|
||||
self,
|
||||
unit: Units | None = None,
|
||||
locale: babel.Locale | None = None,
|
||||
template: str = "{value} {unit}",
|
||||
num_pattern: str = "#,##0",
|
||||
) -> str:
|
||||
"""Localized representation of a measured value.
|
||||
|
||||
If the ``unit`` is not set, an attempt is made to determine a ``unit``
|
||||
matching the territory of the ``locale``. If the locale is not set, an
|
||||
attempt is made to determine it from the HTTP request.
|
||||
|
||||
The value is converted into the respective unit before formatting.
|
||||
|
||||
The argument ``num_pattern`` is used to determine the string formatting
|
||||
of the numerical value:
|
||||
|
||||
- https://babel.pocoo.org/en/latest/numbers.html#pattern-syntax
|
||||
- https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns
|
||||
|
||||
The argument ``template`` specifies how the **string formatted** value
|
||||
and unit are to be arranged.
|
||||
|
||||
- `Format Specification Mini-Language
|
||||
<https://docs.python.org/3/library/string.html#format-specification-mini-language>`.
|
||||
"""
|
||||
if locale is None:
|
||||
locale = babel.Locale.parse(_get_locale_tag(), sep='-')
|
||||
|
||||
if unit is None: # unit by territory
|
||||
unit = "°C"
|
||||
if locale.territory in ["US"]:
|
||||
unit = "°F"
|
||||
val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
|
||||
return template.format(value=val_str, unit=unit)
|
||||
|
||||
|
||||
class Pressure:
|
||||
"""Class for converting pressure units and for string representation of
|
||||
measured values."""
|
||||
|
||||
si_name = "Q44395"
|
||||
|
||||
Units = typing.Literal["Pa", "hPa", "cm Hg", "bar"]
|
||||
"""Supported units."""
|
||||
|
||||
units = list(typing.get_args(Units))
|
||||
|
||||
def __init__(self, value: float, unit: Units):
|
||||
if unit not in self.units:
|
||||
raise ValueError(f"invalid unit: {unit}")
|
||||
# pylint: disable=invalid-name
|
||||
self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
|
||||
|
||||
def __str__(self):
|
||||
return self.l10n()
|
||||
|
||||
def value(self, unit: Units) -> float:
|
||||
return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
|
||||
|
||||
def l10n(
|
||||
self,
|
||||
unit: Units | None = None,
|
||||
locale: babel.Locale | None = None,
|
||||
template: str = "{value} {unit}",
|
||||
num_pattern: str = "#,##0",
|
||||
) -> str:
|
||||
if locale is None:
|
||||
locale = babel.Locale.parse(_get_locale_tag(), sep='-')
|
||||
|
||||
if unit is None: # unit by territory?
|
||||
unit = "hPa"
|
||||
|
||||
val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
|
||||
return template.format(value=val_str, unit=unit)
|
||||
|
||||
|
||||
class WindSpeed:
|
||||
"""Class for converting speed or velocity units and for string
|
||||
representation of measured values.
|
||||
|
||||
.. hint::
|
||||
|
||||
Working with unit ``Bft`` (:py:obj:`searx.wikidata_units.Beaufort`) will
|
||||
throw a :py:obj:`ValueError` for egative values or values greater 16 Bft
|
||||
(55.6 m/s)
|
||||
"""
|
||||
|
||||
si_name = "Q182429"
|
||||
|
||||
Units = typing.Literal["m/s", "km/h", "kn", "mph", "mi/h", "Bft"]
|
||||
"""Supported units."""
|
||||
|
||||
units = list(typing.get_args(Units))
|
||||
|
||||
def __init__(self, value: float, unit: Units):
|
||||
if unit not in self.units:
|
||||
raise ValueError(f"invalid unit: {unit}")
|
||||
# pylint: disable=invalid-name
|
||||
self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
|
||||
|
||||
def __str__(self):
|
||||
return self.l10n()
|
||||
|
||||
def value(self, unit: Units) -> float:
|
||||
return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
|
||||
|
||||
def l10n(
|
||||
self,
|
||||
unit: Units | None = None,
|
||||
locale: babel.Locale | None = None,
|
||||
template: str = "{value} {unit}",
|
||||
num_pattern: str = "#,##0",
|
||||
) -> str:
|
||||
if locale is None:
|
||||
locale = babel.Locale.parse(_get_locale_tag(), sep='-')
|
||||
|
||||
if unit is None: # unit by territory?
|
||||
unit = "m/s"
|
||||
|
||||
val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
|
||||
return template.format(value=val_str, unit=unit)
|
||||
|
||||
|
||||
class RelativeHumidity:
|
||||
"""Amount of relative humidity in the air. The unit is ``%``"""
|
||||
|
||||
Units = typing.Literal["%"]
|
||||
"""Supported unit."""
|
||||
|
||||
units = list(typing.get_args(Units))
|
||||
|
||||
def __init__(self, humidity: float):
|
||||
self.humidity = humidity
|
||||
|
||||
def __str__(self):
|
||||
return self.l10n()
|
||||
|
||||
def value(self) -> float:
|
||||
return self.humidity
|
||||
|
||||
def l10n(
|
||||
self,
|
||||
locale: babel.Locale | None = None,
|
||||
template: str = "{value}{unit}",
|
||||
num_pattern: str = "#,##0",
|
||||
) -> str:
|
||||
if locale is None:
|
||||
locale = babel.Locale.parse(_get_locale_tag(), sep='-')
|
||||
|
||||
unit = "%"
|
||||
val_str = babel.numbers.format_decimal(self.value(), locale=locale, format=num_pattern)
|
||||
return template.format(value=val_str, unit=unit)
|
||||
|
||||
|
||||
class Compass:
|
||||
"""Class for converting compass points and azimuth values (360°)"""
|
||||
|
||||
Units = typing.Literal["°", "Point"]
|
||||
|
||||
Point = typing.Literal[
|
||||
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
|
||||
]
|
||||
"""Compass point type definition"""
|
||||
|
||||
TURN = 360.0
|
||||
"""Full turn (360°)"""
|
||||
|
||||
POINTS = list(typing.get_args(Point))
|
||||
"""Compass points."""
|
||||
|
||||
RANGE = TURN / len(POINTS)
|
||||
"""Angle sector of a compass point"""
|
||||
|
||||
def __init__(self, azimuth: float | int | Point):
|
||||
if isinstance(azimuth, str):
|
||||
if azimuth not in self.POINTS:
|
||||
raise ValueError(f"Invalid compass point: {azimuth}")
|
||||
azimuth = self.POINTS.index(azimuth) * self.RANGE
|
||||
self.azimuth = azimuth % self.TURN
|
||||
|
||||
def __str__(self):
|
||||
return self.l10n()
|
||||
|
||||
def value(self, unit: Units):
|
||||
if unit == "Point":
|
||||
return self.point(self.azimuth)
|
||||
if unit == "°":
|
||||
return self.azimuth
|
||||
raise ValueError(f"unknown unit: {unit}")
|
||||
|
||||
@classmethod
|
||||
def point(cls, azimuth: float | int) -> Point:
|
||||
"""Returns the compass point to an azimuth value."""
|
||||
azimuth = azimuth % cls.TURN
|
||||
# The angle sector of a compass point starts 1/2 sector range before
|
||||
# and after compass point (example: "N" goes from -11.25° to +11.25°)
|
||||
azimuth = azimuth - cls.RANGE / 2
|
||||
idx = int(azimuth // cls.RANGE)
|
||||
return cls.POINTS[idx]
|
||||
|
||||
def l10n(
|
||||
self,
|
||||
unit: Units = "Point",
|
||||
locale: babel.Locale | None = None,
|
||||
template: str = "{value}{unit}",
|
||||
num_pattern: str = "#,##0",
|
||||
) -> str:
|
||||
if locale is None:
|
||||
locale = babel.Locale.parse(_get_locale_tag(), sep='-')
|
||||
|
||||
if unit == "Point":
|
||||
val_str = self.value(unit)
|
||||
return template.format(value=val_str, unit="")
|
||||
|
||||
val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern)
|
||||
return template.format(value=val_str, unit=unit)
|
||||
|
||||
|
||||
WeatherConditionType = typing.Literal[
|
||||
# The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
|
||||
"clear sky",
|
||||
"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",
|
||||
"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",
|
||||
"sleet and thunder",
|
||||
"sleet showers and thunder",
|
||||
"sleet showers",
|
||||
"sleet",
|
||||
"snow and thunder",
|
||||
"snow showers and thunder",
|
||||
"snow showers",
|
||||
"snow",
|
||||
]
|
||||
"""Standardized designations for weather conditions. The designators were
|
||||
taken from a collaboration between NRK and Norwegian Meteorological Institute
|
||||
(yr.no_). `Weather symbols`_ can be assigned to the identifiers
|
||||
(weathericons_) and they are included in the translation (i18n/l10n
|
||||
:origin:`searx/searxng.msg`).
|
||||
|
||||
.. _yr.no: https://www.yr.no/en
|
||||
.. _Weather symbols: https://github.com/nrkno/yr-weather-symbols
|
||||
.. _weathericons: https://github.com/metno/weathericons
|
||||
"""
|
||||
|
||||
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
|
||||
"fog": "15", # 15 fog
|
||||
}
|
||||
"""Map a :py:obj:`WeatherConditionType` to a `YR weather symbol`_
|
||||
|
||||
.. code::
|
||||
|
||||
base_url = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols"
|
||||
icon_url = f"{base_url}/outline/{YR_WEATHER_SYMBOL_MAP['sleet showers']}.svg"
|
||||
|
||||
.. _YR weather symbol: https://github.com/nrkno/yr-weather-symbols/blob/master/locales/en.json
|
||||
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
# test: fetch all symbols of the type catalog ..
|
||||
for c in typing.get_args(WeatherConditionType):
|
||||
symbol_url(condition=c)
|
||||
|
||||
title = "cached weather condition symbols"
|
||||
print(title)
|
||||
print("=" * len(title))
|
||||
print(WEATHER_SYMBOL_CACHE.state().report())
|
||||
print()
|
||||
title = f"properties of {WEATHER_SYMBOL_CACHE.cfg.name}"
|
||||
print(title)
|
||||
print("=" * len(title))
|
||||
print(str(WEATHER_SYMBOL_CACHE.properties)) # type: ignore
|
291
searx/wikidata_units.py
Normal file
291
searx/wikidata_units.py
Normal file
@ -0,0 +1,291 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Unit conversion on the basis of `SPARQL/WIKIDATA Precision, Units and
|
||||
Coordinates`_
|
||||
|
||||
.. _SPARQL/WIKIDATA Precision, Units and Coordinates:
|
||||
https://en.wikibooks.org/wiki/SPARQL/WIKIDATA_Precision,_Units_and_Coordinates#Quantities
|
||||
"""
|
||||
|
||||
__all__ = ["convert_from_si", "convert_to_si", "symbol_to_si"]
|
||||
|
||||
import collections
|
||||
|
||||
from searx import data
|
||||
from searx.engines import wikidata
|
||||
|
||||
|
||||
class Beaufort:
|
||||
"""The mapping of the Beaufort_ contains values from 0 to 16 (55.6 m/s),
|
||||
wind speeds greater than 200km/h (55.6 m/s) are given as 17 Bft. Thats why
|
||||
a value of 17 Bft cannot be converted to SI.
|
||||
|
||||
.. hint::
|
||||
|
||||
Negative values or values greater 16 Bft (55.6 m/s) will throw a
|
||||
:py:obj:`ValueError`.
|
||||
|
||||
_Beaufort: https://en.wikipedia.org/wiki/Beaufort_scale
|
||||
"""
|
||||
|
||||
# fmt: off
|
||||
scale: list[float] = [
|
||||
0.2, 1.5, 3.3, 5.4, 7.9,
|
||||
10.7, 13.8, 17.1, 20.7, 24.4,
|
||||
28.4, 32.6, 32.7, 41.1, 45.8,
|
||||
50.8, 55.6
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
@classmethod
|
||||
def from_si(cls, value) -> float:
|
||||
if value < 0 or value > 55.6:
|
||||
raise ValueError(f"invalid value {value} / the Beaufort scales from 0 to 16 (55.6 m/s)")
|
||||
bft = 0
|
||||
for bft, mps in enumerate(cls.scale):
|
||||
if mps >= value:
|
||||
break
|
||||
return bft
|
||||
|
||||
@classmethod
|
||||
def to_si(cls, value) -> float:
|
||||
idx = round(value)
|
||||
if idx < 0 or idx > 16:
|
||||
raise ValueError(f"invalid value {value} / the Beaufort scales from 0 to 16 (55.6 m/s)")
|
||||
return cls.scale[idx]
|
||||
|
||||
|
||||
ADDITIONAL_UNITS = [
|
||||
{
|
||||
"si_name": "Q11579",
|
||||
"symbol": "°C",
|
||||
"to_si": lambda val: val + 273.15,
|
||||
"from_si": lambda val: val - 273.15,
|
||||
},
|
||||
{
|
||||
"si_name": "Q11579",
|
||||
"symbol": "°F",
|
||||
"to_si": lambda val: (val + 459.67) * 5 / 9,
|
||||
"from_si": lambda val: (val * 9 / 5) - 459.67,
|
||||
},
|
||||
{
|
||||
"si_name": "Q182429",
|
||||
"symbol": "Bft",
|
||||
"to_si": Beaufort.to_si,
|
||||
"from_si": Beaufort.from_si,
|
||||
},
|
||||
]
|
||||
"""Additional items to convert from a measure unit to a SI unit (vice versa).
|
||||
|
||||
.. code:: python
|
||||
|
||||
{
|
||||
"si_name": "Q11579", # Wikidata item ID of the SI unit (Kelvin)
|
||||
"symbol": "°C", # symbol of the measure unit
|
||||
"to_si": lambda val: val + 273.15, # convert measure value (val) to SI unit
|
||||
"from_si": lambda val: val - 273.15, # convert SI value (val) measure unit
|
||||
},
|
||||
{
|
||||
"si_name": "Q11573",
|
||||
"symbol": "mi",
|
||||
"to_si": 1609.344, # convert measure value (val) to SI unit
|
||||
"from_si": 1 / 1609.344 # convert SI value (val) measure unit
|
||||
},
|
||||
|
||||
The values of ``to_si`` and ``from_si`` can be of :py:obj:`float` (a multiplier)
|
||||
or a callable_ (val in / converted value returned).
|
||||
|
||||
.. _callable: https://docs.python.org/3/glossary.html#term-callable
|
||||
"""
|
||||
|
||||
|
||||
ALIAS_SYMBOLS = {
|
||||
'°C': ('C',),
|
||||
'°F': ('F',),
|
||||
'mi': ('L',),
|
||||
'Bft': ('bft',),
|
||||
}
|
||||
"""Alias symbols for known unit of measure symbols / by example::
|
||||
|
||||
'°C': ('C', ...), # list of alias symbols for °C (Q69362731)
|
||||
'°F': ('F', ...), # list of alias symbols for °F (Q99490479)
|
||||
'mi': ('L',), # list of alias symbols for mi (Q253276)
|
||||
"""
|
||||
|
||||
|
||||
SYMBOL_TO_SI = []
|
||||
UNITS_BY_SI_NAME: dict = {}
|
||||
|
||||
|
||||
def convert_from_si(si_name: str, symbol: str, value: float | int) -> float:
|
||||
from_si = units_by_si_name(si_name)[symbol][pos_from_si]
|
||||
if isinstance(from_si, (float, int)):
|
||||
value = float(value) * from_si
|
||||
else:
|
||||
value = from_si(float(value))
|
||||
return value
|
||||
|
||||
|
||||
def convert_to_si(si_name: str, symbol: str, value: float | int) -> float:
|
||||
to_si = units_by_si_name(si_name)[symbol][pos_to_si]
|
||||
if isinstance(to_si, (float, int)):
|
||||
value = float(value) * to_si
|
||||
else:
|
||||
value = to_si(float(value))
|
||||
return value
|
||||
|
||||
|
||||
def units_by_si_name(si_name):
|
||||
|
||||
global UNITS_BY_SI_NAME # pylint: disable=global-statement,global-variable-not-assigned
|
||||
if UNITS_BY_SI_NAME:
|
||||
return UNITS_BY_SI_NAME[si_name]
|
||||
|
||||
# build the catalog ..
|
||||
for item in symbol_to_si():
|
||||
|
||||
item_si_name = item[pos_si_name]
|
||||
item_symbol = item[pos_symbol]
|
||||
|
||||
by_symbol = UNITS_BY_SI_NAME.get(item_si_name)
|
||||
if by_symbol is None:
|
||||
by_symbol = {}
|
||||
UNITS_BY_SI_NAME[item_si_name] = by_symbol
|
||||
by_symbol[item_symbol] = item
|
||||
|
||||
return UNITS_BY_SI_NAME[si_name]
|
||||
|
||||
|
||||
pos_symbol = 0 # (alias) symbol
|
||||
pos_si_name = 1 # si_name
|
||||
pos_from_si = 2 # from_si
|
||||
pos_to_si = 3 # to_si
|
||||
pos_symbol = 4 # standardized symbol
|
||||
|
||||
|
||||
def symbol_to_si():
|
||||
"""Generates a list of tuples, each tuple is a measure unit and the fields
|
||||
in the tuple are:
|
||||
|
||||
0. Symbol of the measure unit (e.g. 'mi' for measure unit 'miles' Q253276)
|
||||
|
||||
1. SI name of the measure unit (e.g. Q11573 for SI unit 'metre')
|
||||
|
||||
2. Factor to get SI value from measure unit (e.g. 1mi is equal to SI 1m
|
||||
multiplied by 1609.344)
|
||||
|
||||
3. Factor to get measure value from from SI value (e.g. SI 100m is equal to
|
||||
100mi divided by 1609.344)
|
||||
|
||||
The returned list is sorted, the first items are created from
|
||||
``WIKIDATA_UNITS``, the second group of items is build from
|
||||
:py:obj:`ADDITIONAL_UNITS` and items created from :py:obj:`ALIAS_SYMBOLS`.
|
||||
|
||||
If you search this list for a symbol, then a match with a symbol from
|
||||
Wikidata has the highest weighting (first hit in the list), followed by the
|
||||
symbols from the :py:obj:`ADDITIONAL_UNITS` and the lowest weighting is
|
||||
given to the symbols resulting from the aliases :py:obj:`ALIAS_SYMBOLS`.
|
||||
|
||||
"""
|
||||
|
||||
global SYMBOL_TO_SI # pylint: disable=global-statement
|
||||
if SYMBOL_TO_SI:
|
||||
return SYMBOL_TO_SI
|
||||
|
||||
# filter out units which can't be normalized to a SI unit and filter out
|
||||
# units without a symbol / arcsecond does not have a symbol
|
||||
# https://www.wikidata.org/wiki/Q829073
|
||||
|
||||
for item in data.WIKIDATA_UNITS.values():
|
||||
if item['to_si_factor'] and item['symbol']:
|
||||
SYMBOL_TO_SI.append(
|
||||
(
|
||||
item['symbol'],
|
||||
item['si_name'],
|
||||
1 / item['to_si_factor'], # from_si
|
||||
item['to_si_factor'], # to_si
|
||||
item['symbol'],
|
||||
)
|
||||
)
|
||||
|
||||
for item in ADDITIONAL_UNITS:
|
||||
SYMBOL_TO_SI.append(
|
||||
(
|
||||
item['symbol'],
|
||||
item['si_name'],
|
||||
item['from_si'],
|
||||
item['to_si'],
|
||||
item['symbol'],
|
||||
)
|
||||
)
|
||||
|
||||
alias_items = []
|
||||
for item in SYMBOL_TO_SI:
|
||||
for alias in ALIAS_SYMBOLS.get(item[0], ()):
|
||||
alias_items.append(
|
||||
(
|
||||
alias,
|
||||
item[1],
|
||||
item[2], # from_si
|
||||
item[3], # to_si
|
||||
item[0], # origin unit
|
||||
)
|
||||
)
|
||||
SYMBOL_TO_SI = SYMBOL_TO_SI + alias_items
|
||||
return SYMBOL_TO_SI
|
||||
|
||||
|
||||
# the response contains duplicate ?item with the different ?symbol
|
||||
# "ORDER BY ?item DESC(?rank) ?symbol" provides a deterministic result
|
||||
# even if a ?item has different ?symbol of the same rank.
|
||||
# A deterministic result
|
||||
# see:
|
||||
# * https://www.wikidata.org/wiki/Help:Ranking
|
||||
# * https://www.mediawiki.org/wiki/Wikibase/Indexing/RDF_Dump_Format ("Statement representation" section)
|
||||
# * https://w.wiki/32BT
|
||||
# * https://en.wikibooks.org/wiki/SPARQL/WIKIDATA_Precision,_Units_and_Coordinates#Quantities
|
||||
# see the result for https://www.wikidata.org/wiki/Q11582
|
||||
# there are multiple symbols the same rank
|
||||
|
||||
SARQL_REQUEST = """
|
||||
SELECT DISTINCT ?item ?symbol ?tosi ?tosiUnit
|
||||
WHERE
|
||||
{
|
||||
?item wdt:P31/wdt:P279 wd:Q47574 .
|
||||
?item p:P5061 ?symbolP .
|
||||
?symbolP ps:P5061 ?symbol ;
|
||||
wikibase:rank ?rank .
|
||||
OPTIONAL {
|
||||
?item p:P2370 ?tosistmt .
|
||||
?tosistmt psv:P2370 ?tosinode .
|
||||
?tosinode wikibase:quantityAmount ?tosi .
|
||||
?tosinode wikibase:quantityUnit ?tosiUnit .
|
||||
}
|
||||
FILTER(LANG(?symbol) = "en").
|
||||
}
|
||||
ORDER BY ?item DESC(?rank) ?symbol
|
||||
"""
|
||||
|
||||
|
||||
def fetch_units():
|
||||
"""Fetch units from Wikidata. Function is used to update persistence of
|
||||
:py:obj:`searx.data.WIKIDATA_UNITS`."""
|
||||
|
||||
results = collections.OrderedDict()
|
||||
response = wikidata.send_wikidata_query(SARQL_REQUEST)
|
||||
for unit in response['results']['bindings']:
|
||||
|
||||
symbol = unit['symbol']['value']
|
||||
name = unit['item']['value'].rsplit('/', 1)[1]
|
||||
si_name = unit.get('tosiUnit', {}).get('value', '')
|
||||
if si_name:
|
||||
si_name = si_name.rsplit('/', 1)[1]
|
||||
|
||||
to_si_factor = unit.get('tosi', {}).get('value', '')
|
||||
if name not in results:
|
||||
# ignore duplicate: always use the first one
|
||||
results[name] = {
|
||||
'symbol': symbol,
|
||||
'si_name': si_name if si_name else None,
|
||||
'to_si_factor': float(to_si_factor) if to_si_factor else None,
|
||||
}
|
||||
return results
|
@ -8,76 +8,15 @@ Output file: :origin:`searx/data/wikidata_units.json` (:origin:`CI Update data
|
||||
"""
|
||||
|
||||
import json
|
||||
import collections
|
||||
|
||||
# set path
|
||||
from os.path import join
|
||||
|
||||
from searx import searx_dir
|
||||
from searx.engines import wikidata, set_loggers
|
||||
from searx.data import data_dir
|
||||
from searx.wikidata_units import fetch_units
|
||||
|
||||
DATA_FILE = data_dir / 'wikidata_units.json'
|
||||
|
||||
set_loggers(wikidata, 'wikidata')
|
||||
|
||||
# the response contains duplicate ?item with the different ?symbol
|
||||
# "ORDER BY ?item DESC(?rank) ?symbol" provides a deterministic result
|
||||
# even if a ?item has different ?symbol of the same rank.
|
||||
# A deterministic result
|
||||
# see:
|
||||
# * https://www.wikidata.org/wiki/Help:Ranking
|
||||
# * https://www.mediawiki.org/wiki/Wikibase/Indexing/RDF_Dump_Format ("Statement representation" section)
|
||||
# * https://w.wiki/32BT
|
||||
# * https://en.wikibooks.org/wiki/SPARQL/WIKIDATA_Precision,_Units_and_Coordinates#Quantities
|
||||
# see the result for https://www.wikidata.org/wiki/Q11582
|
||||
# there are multiple symbols the same rank
|
||||
SARQL_REQUEST = """
|
||||
SELECT DISTINCT ?item ?symbol ?tosi ?tosiUnit
|
||||
WHERE
|
||||
{
|
||||
?item wdt:P31/wdt:P279 wd:Q47574 .
|
||||
?item p:P5061 ?symbolP .
|
||||
?symbolP ps:P5061 ?symbol ;
|
||||
wikibase:rank ?rank .
|
||||
OPTIONAL {
|
||||
?item p:P2370 ?tosistmt .
|
||||
?tosistmt psv:P2370 ?tosinode .
|
||||
?tosinode wikibase:quantityAmount ?tosi .
|
||||
?tosinode wikibase:quantityUnit ?tosiUnit .
|
||||
}
|
||||
FILTER(LANG(?symbol) = "en").
|
||||
}
|
||||
ORDER BY ?item DESC(?rank) ?symbol
|
||||
"""
|
||||
|
||||
|
||||
def get_data():
|
||||
results = collections.OrderedDict()
|
||||
response = wikidata.send_wikidata_query(SARQL_REQUEST)
|
||||
for unit in response['results']['bindings']:
|
||||
|
||||
symbol = unit['symbol']['value']
|
||||
name = unit['item']['value'].rsplit('/', 1)[1]
|
||||
si_name = unit.get('tosiUnit', {}).get('value', '')
|
||||
if si_name:
|
||||
si_name = si_name.rsplit('/', 1)[1]
|
||||
|
||||
to_si_factor = unit.get('tosi', {}).get('value', '')
|
||||
if name not in results:
|
||||
# ignore duplicate: always use the first one
|
||||
results[name] = {
|
||||
'symbol': symbol,
|
||||
'si_name': si_name if si_name else None,
|
||||
'to_si_factor': float(to_si_factor) if to_si_factor else None,
|
||||
}
|
||||
return results
|
||||
|
||||
|
||||
def get_wikidata_units_filename():
|
||||
return join(join(searx_dir, "data"), "")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
with DATA_FILE.open('w', encoding="utf8") as f:
|
||||
json.dump(get_data(), f, indent=4, sort_keys=True, ensure_ascii=False)
|
||||
json.dump(fetch_units(), f, indent=4, sort_keys=True, ensure_ascii=False)
|
||||
|
Loading…
x
Reference in New Issue
Block a user