fix: геокодинг через Nominatim — корректные города по кириллице

This commit is contained in:
Maksim Totmin
2026-09-23 19:29:59 +07:00
parent 534ff9c5b0
commit cbae93cee4
3 changed files with 62 additions and 29 deletions
+50 -14
View File
@@ -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,
)