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) | не нужен |
|
||||
|
||||
## Структура
|
||||
@@ -45,7 +45,7 @@ tests/
|
||||
## Требования
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Погодный сервис: геокодинг Open-Meteo + прогноз met.no.
|
||||
"""Погодный сервис: геокодинг Nominatim (OpenStreetMap) + прогноз met.no.
|
||||
|
||||
Модуль намеренно ничего не знает про AMS и pydantic-ai — это чистый слой
|
||||
получения данных. Его можно запустить и проверить отдельно:
|
||||
@@ -11,6 +11,7 @@ API-ключи не нужны: оба сервиса бесплатные.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
@@ -21,20 +22,28 @@ import requests
|
||||
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"
|
||||
|
||||
# met.no просит осмысленный User-Agent с контактом, иначе может блокировать.
|
||||
# Оба сервиса требуют осмысленный User-Agent с контактом.
|
||||
USER_AGENT = "ams-weather-agent/1.0 (+https://git.totmin.ru/en2zmax/ams-weather)"
|
||||
|
||||
# (connect, read) в секундах. Держим короткими, чтобы не подвешивать диалог.
|
||||
TIMEOUT = (4, 7)
|
||||
RETRIES = 2 # общее число попыток на один HTTP-запрос
|
||||
|
||||
# Политика использования Nominatim: не чаще одного запроса в секунду.
|
||||
NOMINATIM_MIN_INTERVAL = 1.0
|
||||
|
||||
# Переиспользуем TCP-соединения между вызовами — заметно быстрее серии запросов.
|
||||
_session = requests.Session()
|
||||
_session.headers["User-Agent"] = USER_AGENT
|
||||
|
||||
# Ограничитель частоты запросов к Nominatim (общий на процесс).
|
||||
_rate_lock = threading.Lock()
|
||||
_last_request_at = 0.0
|
||||
|
||||
|
||||
class WeatherError(Exception):
|
||||
"""Ожидаемая ошибка получения погоды.
|
||||
@@ -108,7 +117,17 @@ _SYMBOLS: dict[str, str] = {
|
||||
_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 с короткими ретраями и единообразной обработкой ошибок.
|
||||
|
||||
Повторяем только временные сбои: сетевые ошибки, 5xx и 429.
|
||||
@@ -124,7 +143,7 @@ def _get(url: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
else:
|
||||
status = response.status_code
|
||||
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:
|
||||
try:
|
||||
return response.json()
|
||||
@@ -137,7 +156,7 @@ def _get(url: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
if attempt < RETRIES:
|
||||
time.sleep(0.4 * attempt) # короткий нарастающий backoff
|
||||
|
||||
raise WeatherError("внешний сервис погоды недоступен", f"{url}: {last_error}")
|
||||
raise WeatherError("внешний сервис недоступен", f"{url}: {last_error}")
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
@@ -150,19 +169,36 @@ def geocode(city: str) -> Location:
|
||||
if not city:
|
||||
raise WeatherError("не указан город")
|
||||
|
||||
payload = _get(GEOCODE_URL, {"name": city, "count": 1, "language": "ru", "format": "json"})
|
||||
results = payload.get("results") or []
|
||||
_throttle_nominatim()
|
||||
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:
|
||||
raise WeatherError(f"город «{city}» не найден")
|
||||
|
||||
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(
|
||||
name=top.get("name", city),
|
||||
region=top.get("admin1"),
|
||||
country=top.get("country"),
|
||||
# met.no принимает координаты не более чем с 4 знаками после запятой.
|
||||
latitude=round(float(top["latitude"]), 4),
|
||||
longitude=round(float(top["longitude"]), 4),
|
||||
name=name,
|
||||
region=address.get("state") or address.get("region"),
|
||||
country=address.get("country"),
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+7
-10
@@ -18,17 +18,14 @@ import weather # noqa: E402
|
||||
|
||||
LOCATION = weather.Location("Казань", "Татарстан", "Россия", 55.7887, 49.1221)
|
||||
|
||||
GEOCODE_PAYLOAD = {
|
||||
"results": [
|
||||
GEOCODE_PAYLOAD = [
|
||||
{
|
||||
"name": "Казань",
|
||||
"admin1": "Татарстан",
|
||||
"country": "Россия",
|
||||
"latitude": 55.78874,
|
||||
"longitude": 49.12214,
|
||||
"lat": "55.78874",
|
||||
"lon": "49.12214",
|
||||
"display_name": "Казань, городской округ Казань, Татарстан, Россия",
|
||||
"address": {"city": "Казань", "state": "Татарстан", "country": "Россия"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
FORECAST_PAYLOAD = {
|
||||
"properties": {
|
||||
@@ -71,7 +68,7 @@ def test_geocode_empty_city_raises():
|
||||
|
||||
|
||||
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:
|
||||
weather.geocode("Абракадабрания")
|
||||
assert "не найден" in exc.value.message
|
||||
@@ -91,7 +88,7 @@ def test_geocode_is_cached(monkeypatch):
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_get(url, params):
|
||||
calls.append(params["name"])
|
||||
calls.append(params["q"])
|
||||
return GEOCODE_PAYLOAD
|
||||
|
||||
monkeypatch.setattr(weather, "_get", fake_get)
|
||||
|
||||
Reference in New Issue
Block a user