diff --git a/backend-py/app/geocoder.py b/backend-py/app/geocoder.py new file mode 100644 index 0000000..5b2da50 --- /dev/null +++ b/backend-py/app/geocoder.py @@ -0,0 +1,187 @@ +"""Hybrid Geocoder: Yandex (если есть API-ключ) → fallback OSM Nominatim. + +Порт из secretary/lib/geocoder.js — та же логика нормализации адреса +и стратегия fallback. +""" +from __future__ import annotations +import os +import re +import time +import logging +import urllib.parse +import httpx + +log = logging.getLogger("zov.geocoder") + +OSM_HOST = "https://nominatim.openstreetmap.org" +YANDEX_HOST = "https://geocode-maps.yandex.ru" +OSM_RATE_DELAY_SEC = 1.1 # ~1 req/sec по политике Nominatim + +_cache: dict[str, dict | None] = {} +_last_osm_at: float = 0.0 +_yandex_disabled: bool = False + + +def normalize_address(s: str) -> str: + """Готовим адрес для геокодера. Срезаем квартиру/этаж/подъезд/корпус, + раскрываем сокращения улиц. Совпадает с logic из secretary/lib/geocoder.js. + """ + if not s: + return "" + out = str(s) + + # Замена сокращений улиц на полные слова (лучше для OSM) + # \b плохо работает с кириллицей в Python — используем lookaround + L = r"[А-Яа-яЁёA-Za-z]" + NL = rf"(? dict | None: + global _yandex_disabled + path = f"/v1/?apikey={urllib.parse.quote(api_key)}&geocode={urllib.parse.quote(query)}&format=json&results=1&lang=ru_RU" + try: + with httpx.Client(timeout=15.0) as cli: + r = cli.get(YANDEX_HOST + path) + except Exception as e: + log.warning("Yandex geocoder error: %s", e) + return None + if r.status_code == 403: + log.warning("Yandex 403 — отключаю на эту сессию") + _yandex_disabled = True + return None + if r.status_code >= 400: + log.warning("Yandex %d", r.status_code) + return None + try: + data = r.json() + except Exception: + return None + features = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) + if not features: + return None + obj = features[0].get("GeoObject", {}) + pos = (obj.get("Point") or {}).get("pos", "") + try: + lon_s, lat_s = pos.split(" ") + lat = float(lat_s) + lon = float(lon_s) + except (ValueError, AttributeError): + return None + meta = (obj.get("metaDataProperty") or {}).get("GeocoderMetaData", {}) + return { + "lat": lat, + "lon": lon, + "formatted": meta.get("text") or obj.get("description") or query, + "precision": meta.get("precision", ""), + "kind": meta.get("kind", ""), + "source": "yandex", + } + + +def _osm_lookup(query: str, orig: str) -> dict | None: + global _last_osm_at + wait = max(0.0, _last_osm_at + OSM_RATE_DELAY_SEC - time.time()) + if wait > 0: + time.sleep(wait) + _last_osm_at = time.time() + + path = f"/search?q={urllib.parse.quote(query)}&format=json&limit=1&accept-language=ru" + headers = {"User-Agent": "zov-tech/1.0 (vasrusgen@gmail.com)"} + try: + with httpx.Client(timeout=15.0, headers=headers) as cli: + r = cli.get(OSM_HOST + path) + except Exception as e: + log.warning("OSM error: %s", e) + return None + if r.status_code >= 400: + log.warning("OSM %d", r.status_code) + return None + try: + data = r.json() + except Exception: + return None + if not data: + return None + it = data[0] + try: + lat = float(it.get("lat")) + lon = float(it.get("lon")) + except (TypeError, ValueError): + return None + return { + "lat": lat, + "lon": lon, + "formatted": it.get("display_name") or orig or query, + "precision": it.get("type", ""), + "kind": it.get("addresstype") or it.get("class", ""), + "source": "osm", + } + + +def geocode(address_text: str, city: str = "Санкт-Петербург") -> dict | None: + """Прямое геокодирование: текст адреса → {lat, lon, formatted, source, ...}""" + if not address_text: + return None + cleaned = normalize_address(address_text) + q = f"{city}, {cleaned}" if city else cleaned + if q in _cache: + return _cache[q] + + api_key = os.environ.get("YANDEX_GEOCODER_API_KEY", "").strip() + if api_key and not _yandex_disabled: + r = _yandex_lookup(q, api_key) + if r: + _cache[q] = r + return r + + r = _osm_lookup(q, address_text) + _cache[q] = r + return r + + +def build_yandex_maps_url(lat: float, lon: float, *, zoom: int = 17, text: str = "") -> str: + """Deeplink в Я.Карты — открывается в приложении на телефоне.""" + params = [f"pt={lon},{lat},pm2rdm", f"z={zoom}", f"ll={lon},{lat}"] + if text: + params.append(f"text={urllib.parse.quote(text)}") + return "https://yandex.ru/maps/?" + "&".join(params) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index d4bf1e1..554b45a 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -16,7 +16,7 @@ from fastapi.responses import FileResponse, JSONResponse from .config import get_config from .auth import verify_init_data -from . import sheets, ai, telegram as tg, proxy_pool, catalog +from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder from . import parsers from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl @@ -113,6 +113,7 @@ async def _dispatch_post(request: Request): "measurement_schedule": _handle_measurement_schedule, "measurement_next_no": _handle_measurement_next_no, "measurement_logistics": _handle_measurement_logistics, + "geocode": _handle_geocode, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -215,6 +216,12 @@ async def api_measurement_logistics(request: Request): return _handle_measurement_logistics(body) +@app.post("/api/geocode") +async def api_geocode(request: Request): + body = await _safe_json(request) + return _handle_geocode(body) + + @app.post("/api/grant_role") async def api_grant_role(request: Request): """Админ выдаёт роль другому пользователю. @@ -1366,6 +1373,41 @@ def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "id": measurement_id, "logistics": updates} +def _handle_geocode(body: dict[str, Any]) -> dict[str, Any]: + """Прямое геокодирование: текст адреса → lat/lon. + Использует Yandex (если есть YANDEX_GEOCODER_API_KEY в env) с fallback на OSM. + body: {initData, address, city?}""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if not (isinstance(unsafe, dict) and unsafe.get("user", {}).get("id")): + return {"error": "invalid_init_data"} + + address = (body.get("address") or "").strip() + if not address: + return {"error": "missing_address"} + city = (body.get("city") or "Санкт-Петербург").strip() + result = geocoder.geocode(address, city=city) + if not result: + return {"ok": False, "error": "not_found", "address": address} + return { + "ok": True, + "address": address, + "result": { + "lat": result.get("lat"), + "lng": result.get("lon"), # фронт использует lng + "formatted": result.get("formatted"), + "precision": result.get("precision"), + "kind": result.get("kind"), + "source": result.get("source"), + }, + "yandex_maps_url": geocoder.build_yandex_maps_url( + result["lat"], result["lon"], text=result.get("formatted") or address, + ), + } + + def _handle_measurement_next_no(body: dict[str, Any]) -> dict[str, Any]: """Возвращает следующий свободный номер замера (max существующих + 1). Если в Sheets ничего нет — стартуем с 1. Менеджер может скорректировать вручную diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index 0328502..000fcdf 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -766,11 +766,12 @@ function renderLogisticsBlock(m) {