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
+2 -2
View File
@@ -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
+50 -14
View File
@@ -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),
) )
+7 -10
View File
@@ -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": [
{ {
"name": "Казань", "lat": "55.78874",
"admin1": "Татарстан", "lon": "49.12214",
"country": "Россия", "display_name": "Казань, городской округ Казань, Татарстан, Россия",
"latitude": 55.78874, "address": {"city": "Казань", "state": "Татарстан", "country": "Россия"},
"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)