fix: геокодинг через Nominatim — корректные города по кириллице
This commit is contained in:
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
| Этап | Сервис | Ключ |
|
| Этап | Сервис | Ключ |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Город → координаты | [Open-Meteo Geocoding](https://open-meteo.com/en/docs/geocoding-api) | не нужен |
|
| Город → координаты | [Nominatim (OpenStreetMap)](https://nominatim.openstreetmap.org/) | не нужен |
|
||||||
| Координаты → погода | [MET Norway Locationforecast](https://api.met.no/weatherapi/locationforecast/2.0/documentation) | не нужен |
|
| Координаты → погода | [MET Norway Locationforecast](https://api.met.no/weatherapi/locationforecast/2.0/documentation) | не нужен |
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
@@ -45,7 +45,7 @@ tests/
|
|||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- AMS 7.005+ (Python 3.12, `requests` и `pydantic-ai` уже в окружении сервиса).
|
- AMS 7.005+ (Python 3.12, `requests` и `pydantic-ai` уже в окружении сервиса).
|
||||||
- Доступ к `geocoding-api.open-meteo.com` и `api.met.no` с хоста AMS.
|
- Доступ к `nominatim.openstreetmap.org` и `api.met.no` с хоста AMS.
|
||||||
|
|
||||||
## Установка в AMS
|
## Установка в AMS
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Погодный сервис: геокодинг Open-Meteo + прогноз met.no.
|
"""Погодный сервис: геокодинг Nominatim (OpenStreetMap) + прогноз met.no.
|
||||||
|
|
||||||
Модуль намеренно ничего не знает про AMS и pydantic-ai — это чистый слой
|
Модуль намеренно ничего не знает про AMS и pydantic-ai — это чистый слой
|
||||||
получения данных. Его можно запустить и проверить отдельно:
|
получения данных. Его можно запустить и проверить отдельно:
|
||||||
@@ -11,6 +11,7 @@ API-ключи не нужны: оба сервиса бесплатные.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -21,20 +22,28 @@ import requests
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# --- Внешние сервисы ------------------------------------------------------
|
# --- Внешние сервисы ------------------------------------------------------
|
||||||
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
# Nominatim (OSM) корректно ищет по кириллице, в отличие от GeoNames-индекса.
|
||||||
|
GEOCODE_URL = "https://nominatim.openstreetmap.org/search"
|
||||||
FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/2.0/compact"
|
FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/2.0/compact"
|
||||||
|
|
||||||
# met.no просит осмысленный User-Agent с контактом, иначе может блокировать.
|
# Оба сервиса требуют осмысленный User-Agent с контактом.
|
||||||
USER_AGENT = "ams-weather-agent/1.0 (+https://git.totmin.ru/en2zmax/ams-weather)"
|
USER_AGENT = "ams-weather-agent/1.0 (+https://git.totmin.ru/en2zmax/ams-weather)"
|
||||||
|
|
||||||
# (connect, read) в секундах. Держим короткими, чтобы не подвешивать диалог.
|
# (connect, read) в секундах. Держим короткими, чтобы не подвешивать диалог.
|
||||||
TIMEOUT = (4, 7)
|
TIMEOUT = (4, 7)
|
||||||
RETRIES = 2 # общее число попыток на один HTTP-запрос
|
RETRIES = 2 # общее число попыток на один HTTP-запрос
|
||||||
|
|
||||||
|
# Политика использования Nominatim: не чаще одного запроса в секунду.
|
||||||
|
NOMINATIM_MIN_INTERVAL = 1.0
|
||||||
|
|
||||||
# Переиспользуем TCP-соединения между вызовами — заметно быстрее серии запросов.
|
# Переиспользуем TCP-соединения между вызовами — заметно быстрее серии запросов.
|
||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
_session.headers["User-Agent"] = USER_AGENT
|
_session.headers["User-Agent"] = USER_AGENT
|
||||||
|
|
||||||
|
# Ограничитель частоты запросов к Nominatim (общий на процесс).
|
||||||
|
_rate_lock = threading.Lock()
|
||||||
|
_last_request_at = 0.0
|
||||||
|
|
||||||
|
|
||||||
class WeatherError(Exception):
|
class WeatherError(Exception):
|
||||||
"""Ожидаемая ошибка получения погоды.
|
"""Ожидаемая ошибка получения погоды.
|
||||||
@@ -108,7 +117,17 @@ _SYMBOLS: dict[str, str] = {
|
|||||||
_UNKNOWN_DESCRIPTION = "нет данных"
|
_UNKNOWN_DESCRIPTION = "нет данных"
|
||||||
|
|
||||||
|
|
||||||
def _get(url: str, params: dict[str, Any]) -> dict[str, Any]:
|
def _throttle_nominatim() -> None:
|
||||||
|
"""Выдержать паузу между запросами к Nominatim (правила OSM)."""
|
||||||
|
global _last_request_at
|
||||||
|
with _rate_lock:
|
||||||
|
wait = NOMINATIM_MIN_INTERVAL - (time.monotonic() - _last_request_at)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
_last_request_at = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str, params: dict[str, Any]) -> Any:
|
||||||
"""GET с короткими ретраями и единообразной обработкой ошибок.
|
"""GET с короткими ретраями и единообразной обработкой ошибок.
|
||||||
|
|
||||||
Повторяем только временные сбои: сетевые ошибки, 5xx и 429.
|
Повторяем только временные сбои: сетевые ошибки, 5xx и 429.
|
||||||
@@ -124,7 +143,7 @@ def _get(url: str, params: dict[str, Any]) -> dict[str, Any]:
|
|||||||
else:
|
else:
|
||||||
status = response.status_code
|
status = response.status_code
|
||||||
if 400 <= status < 500 and status != 429:
|
if 400 <= status < 500 and status != 429:
|
||||||
raise WeatherError("внешний сервис погоды вернул ошибку", f"{url}: HTTP {status}")
|
raise WeatherError("внешний сервис вернул ошибку", f"{url}: HTTP {status}")
|
||||||
if status < 500 and status != 429:
|
if status < 500 and status != 429:
|
||||||
try:
|
try:
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -137,7 +156,7 @@ def _get(url: str, params: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if attempt < RETRIES:
|
if attempt < RETRIES:
|
||||||
time.sleep(0.4 * attempt) # короткий нарастающий backoff
|
time.sleep(0.4 * attempt) # короткий нарастающий backoff
|
||||||
|
|
||||||
raise WeatherError("внешний сервис погоды недоступен", f"{url}: {last_error}")
|
raise WeatherError("внешний сервис недоступен", f"{url}: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=256)
|
@lru_cache(maxsize=256)
|
||||||
@@ -150,19 +169,36 @@ def geocode(city: str) -> Location:
|
|||||||
if not city:
|
if not city:
|
||||||
raise WeatherError("не указан город")
|
raise WeatherError("не указан город")
|
||||||
|
|
||||||
payload = _get(GEOCODE_URL, {"name": city, "count": 1, "language": "ru", "format": "json"})
|
_throttle_nominatim()
|
||||||
results = payload.get("results") or []
|
payload = _get(
|
||||||
|
GEOCODE_URL,
|
||||||
|
{"q": city, "format": "jsonv2", "limit": 1, "addressdetails": 1, "accept-language": "ru"},
|
||||||
|
)
|
||||||
|
results = payload if isinstance(payload, list) else []
|
||||||
if not results:
|
if not results:
|
||||||
raise WeatherError(f"город «{city}» не найден")
|
raise WeatherError(f"город «{city}» не найден")
|
||||||
|
|
||||||
top = results[0]
|
top = results[0]
|
||||||
|
address = top.get("address") or {}
|
||||||
|
name = (
|
||||||
|
address.get("city")
|
||||||
|
or address.get("town")
|
||||||
|
or address.get("village")
|
||||||
|
or address.get("municipality")
|
||||||
|
or city
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
latitude = round(float(top["lat"]), 4) # met.no принимает до 4 знаков
|
||||||
|
longitude = round(float(top["lon"]), 4)
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise WeatherError("геокодер вернул неожиданный формат", repr(exc)) from exc
|
||||||
|
|
||||||
return Location(
|
return Location(
|
||||||
name=top.get("name", city),
|
name=name,
|
||||||
region=top.get("admin1"),
|
region=address.get("state") or address.get("region"),
|
||||||
country=top.get("country"),
|
country=address.get("country"),
|
||||||
# met.no принимает координаты не более чем с 4 знаками после запятой.
|
latitude=latitude,
|
||||||
latitude=round(float(top["latitude"]), 4),
|
longitude=longitude,
|
||||||
longitude=round(float(top["longitude"]), 4),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-13
@@ -18,17 +18,14 @@ import weather # noqa: E402
|
|||||||
|
|
||||||
LOCATION = weather.Location("Казань", "Татарстан", "Россия", 55.7887, 49.1221)
|
LOCATION = weather.Location("Казань", "Татарстан", "Россия", 55.7887, 49.1221)
|
||||||
|
|
||||||
GEOCODE_PAYLOAD = {
|
GEOCODE_PAYLOAD = [
|
||||||
"results": [
|
{
|
||||||
{
|
"lat": "55.78874",
|
||||||
"name": "Казань",
|
"lon": "49.12214",
|
||||||
"admin1": "Татарстан",
|
"display_name": "Казань, городской округ Казань, Татарстан, Россия",
|
||||||
"country": "Россия",
|
"address": {"city": "Казань", "state": "Татарстан", "country": "Россия"},
|
||||||
"latitude": 55.78874,
|
}
|
||||||
"longitude": 49.12214,
|
]
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
FORECAST_PAYLOAD = {
|
FORECAST_PAYLOAD = {
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -71,7 +68,7 @@ def test_geocode_empty_city_raises():
|
|||||||
|
|
||||||
|
|
||||||
def test_geocode_not_found(monkeypatch):
|
def test_geocode_not_found(monkeypatch):
|
||||||
monkeypatch.setattr(weather, "_get", lambda url, params: {"results": []})
|
monkeypatch.setattr(weather, "_get", lambda url, params: [])
|
||||||
with pytest.raises(weather.WeatherError) as exc:
|
with pytest.raises(weather.WeatherError) as exc:
|
||||||
weather.geocode("Абракадабрания")
|
weather.geocode("Абракадабрания")
|
||||||
assert "не найден" in exc.value.message
|
assert "не найден" in exc.value.message
|
||||||
@@ -91,7 +88,7 @@ def test_geocode_is_cached(monkeypatch):
|
|||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
|
|
||||||
def fake_get(url, params):
|
def fake_get(url, params):
|
||||||
calls.append(params["name"])
|
calls.append(params["q"])
|
||||||
return GEOCODE_PAYLOAD
|
return GEOCODE_PAYLOAD
|
||||||
|
|
||||||
monkeypatch.setattr(weather, "_get", fake_get)
|
monkeypatch.setattr(weather, "_get", fake_get)
|
||||||
|
|||||||
Reference in New Issue
Block a user