From cbae93cee49bfb1602a94fa4dc28e3ffc3b039ee Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Wed, 23 Sep 2026 19:29:59 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B3=D0=B5=D0=BE=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Nominatim?= =?UTF-8?q?=20=E2=80=94=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- agents/custom/weather/weather.py | 64 +++++++++++++++++++++++++------- tests/test_weather.py | 23 +++++------- 3 files changed, 62 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index fb44525..ac5763d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/agents/custom/weather/weather.py b/agents/custom/weather/weather.py index 2755d7b..e58c3a0 100644 --- a/agents/custom/weather/weather.py +++ b/agents/custom/weather/weather.py @@ -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, ) diff --git a/tests/test_weather.py b/tests/test_weather.py index 5fcd72e..da76a2d 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -18,17 +18,14 @@ import weather # noqa: E402 LOCATION = weather.Location("Казань", "Татарстан", "Россия", 55.7887, 49.1221) -GEOCODE_PAYLOAD = { - "results": [ - { - "name": "Казань", - "admin1": "Татарстан", - "country": "Россия", - "latitude": 55.78874, - "longitude": 49.12214, - } - ] -} +GEOCODE_PAYLOAD = [ + { + "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)