"""Geocoder service — address → lat/lon. Стратегия: - Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней - Cache miss → Yandex Geocoder (если есть key) → fallback Nominatim - Результат сохраняется в кэш для последующих вызовов Используется в: - /api/v1/trade-in/estimate (вход — адрес от пользователя) - /api/v1/geocode/lookup (debug endpoint) - scraper jobs (когда нужно по адресу определить координаты) """ from __future__ import annotations import asyncio import logging from dataclasses import dataclass from typing import Literal import httpx from sqlalchemy import text from sqlalchemy.orm import Session from tenacity import retry, stop_after_attempt, wait_exponential from app.core.config import settings from app.services import dadata logger = logging.getLogger(__name__) # ── Result type ────────────────────────────────────────────────────────────── @dataclass(frozen=True, slots=True) class GeocodeResult: lat: float lon: float full_address: str provider: Literal["nominatim", "yandex", "cache"] confidence: Literal["exact", "approximate", "locality"] = "approximate" # ── Address normalisation ─────────────────────────────────────────────────── def normalize_address(address: str) -> str: """Нормализация для cache lookup: lowercase + trim + collapse whitespace. « Ул. МАЛЫШЕВА, 30 » → «ул. малышева, 30» """ return " ".join(address.lower().strip().split()) # Согласные, которые часто пишут с одной буквой вместо двух (RU typos). _DOUBLE_CONSONANTS = "лнмссккттпп" def _typo_variants(query: str, limit: int = 6) -> list[str]: """Генерация вариантов с удвоением согласных — для случая когда пользователь написал «Цвилинга» вместо «Цвиллинга», «Толстова» вместо «Толстого» итд. Каждая позиция где есть одинокая согласная — кандидат на удвоение. Возвращаем до `limit` вариантов в порядке вероятности (ближе к началу слова → выше). """ if len(query) < 3: return [] variants: list[str] = [] chars = list(query) for i in range(1, len(chars) - 1): c = chars[i].lower() if c not in _DOUBLE_CONSONANTS: continue prev_c = chars[i - 1].lower() next_c = chars[i + 1].lower() # Удваиваем только если соседи — гласные/'ь'/'ъ' (характерно для русских типо) if prev_c in "аеёиоуыэюяьъ" and next_c in "аеёиоуыэюяьъ": variant = query[:i + 1] + chars[i] + query[i + 1:] if variant != query and variant not in variants: variants.append(variant) if len(variants) >= limit: break return variants # ── Cache ──────────────────────────────────────────────────────────────────── def _cache_get(db: Session, address_norm: str) -> GeocodeResult | None: row = db.execute( text( """ SELECT lat, lon, full_address, provider, confidence FROM geocode_cache WHERE address_normalized = :addr AND expires_at > NOW() """ ), {"addr": address_norm}, ).fetchone() if row is None: return None return GeocodeResult( lat=row.lat, lon=row.lon, full_address=row.full_address, provider="cache", confidence=row.confidence or "approximate", ) def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None: db.execute( text( """ INSERT INTO geocode_cache (address_normalized, lat, lon, full_address, provider, confidence) VALUES (:addr, :lat, :lon, :full, :provider, :conf) ON CONFLICT (address_normalized) DO UPDATE SET lat = EXCLUDED.lat, lon = EXCLUDED.lon, full_address = EXCLUDED.full_address, provider = EXCLUDED.provider, confidence = EXCLUDED.confidence, created_at = NOW(), expires_at = NOW() + interval '90 days' """ ), { "addr": address_norm, "lat": result.lat, "lon": result.lon, "full": result.full_address, "provider": result.provider, "conf": result.confidence, }, ) db.commit() # ── Provider: Nominatim (OSM, без ключа) ──────────────────────────────────── async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None: """Single Nominatim search. Возвращает первый item или None. ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках не возвращать Пермский край / Челябинск. """ response = await client.get( "https://nominatim.openstreetmap.org/search", params={ "q": address, "format": "json", "limit": "3", "countrycodes": "ru", "addressdetails": "1", "viewbox": EKB_BBOX["viewbox"], "bounded": "1", # строго в ЕКБ bbox }, ) response.raise_for_status() data = response.json() for item in data: try: lat_f = float(item["lat"]) lon_f = float(item["lon"]) if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95: return item except Exception: continue return None @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) async def _nominatim_lookup(address: str) -> GeocodeResult | None: """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy. Бан-policy: User-Agent с email обязателен. Tier 1: bounded ЕКБ на оригинальный адрес. Tier 2: bounded ЕКБ на typo-варианты (Цвилинга → Цвиллинга). """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "Accept": "application/json", "Accept-Language": "ru,en;q=0.8", "Referer": "https://tradein-mvp.local/", } async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: # Tier 1: оригинал item = await _nominatim_query(client, address) # Tier 2: typo-variants if item is None: for variant in _typo_variants(address, limit=4): await asyncio.sleep(1.0) # Nominatim 1 req/sec policy item = await _nominatim_query(client, variant) if item is not None: logger.info("nominatim typo-fixed: %s → %s", address, variant) break if item is None: return None confidence = "exact" if item.get("class") == "building" else "approximate" return GeocodeResult( lat=float(item["lat"]), lon=float(item["lon"]), full_address=item.get("display_name", address), provider="nominatim", confidence=confidence, ) # ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ───────────── @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None: """Yandex Geocoder — 25K req/day free для самопод, лучше РФ. Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов, но БЕЗ rspn — чтобы fuzzy matching работал при опечатках. """ # Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest) geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}" async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get( "https://geocode-maps.yandex.ru/1.x/", params={ "apikey": api_key, "geocode": geocode_query, "format": "json", "results": 5, # берем top-5, отфильтруем по ЕКБ bbox ниже "lang": "ru_RU", "ll": EKB_BBOX["ll"], "spn": EKB_BBOX["spn"], }, ) response.raise_for_status() data = response.json() members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) if not members: return None # Фильтруем top-5 по ЕКБ bbox — игнорируем Челябинск/Уфу/Москву при опечатке best = None for m in members: obj = m.get("GeoObject", {}) try: lon_str, lat_str = obj["Point"]["pos"].split() lat_f, lon_f = float(lat_str), float(lon_str) if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95: best = obj break except Exception: continue if best is None: # Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то) best = members[0]["GeoObject"] lon_str, lat_str = best["Point"]["pos"].split() precision_raw = ( best.get("metaDataProperty", {}) .get("GeocoderMetaData", {}) .get("precision", "other") ) confidence_map = { "exact": "exact", "number": "exact", "near": "approximate", "range": "approximate", "street": "approximate", } return GeocodeResult( lat=float(lat_str), lon=float(lon_str), full_address=best.get("metaDataProperty", {}) .get("GeocoderMetaData", {}) .get("text", address), provider="yandex", confidence=confidence_map.get(precision_raw, "approximate"), ) # ── Suggest (автокомплит) ─────────────────────────────────────────────────── # ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85 EKB_BBOX = { "viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE) "ll": "60.605,56.838", # Yandex center (lon,lat) "spn": "0.45,0.30", # Yandex span (lon,lat) } @dataclass(frozen=True, slots=True) class GeocodeSuggestion: label: str # формат "Малышева 30, Октябрьский район" full_address: str # полный из геокодера lat: float lon: float kind: str # 'house' / 'street' / 'locality' def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]: """Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора.""" out: list[GeocodeSuggestion] = [] for m in members: obj = m.get("GeoObject", {}) try: lon_str, lat_str = obj["Point"]["pos"].split() meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) kind = meta.get("kind", "other") full = meta.get("text", obj.get("name", "")) name = obj.get("name", full) desc = obj.get("description", "") desc_parts = [ p.strip() for p in desc.split(",") if p.strip() and p.strip() not in {"Россия", "Свердловская область"} ] label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}" out.append(GeocodeSuggestion( label=label, full_address=full, lat=float(lat_str), lon=float(lon_str), kind=kind, )) except Exception: continue return out # ── DaData suggest wrapper ────────────────────────────────────────────────── # Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется # 'locality' вместо 'city' — consistent с Yandex/Nominatim ветками). _DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"} async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]: """Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion. Дроп candidate'ов без координат (DaData возвращает их для широких categories типа город/район, для autocomplete с привязкой к карте они бесполезны). Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»). Без city suffix — query уже ограничен `locations=[{city: 'Екатеринбург'}]`. """ raw = await dadata.suggest_addresses(query, limit=limit) out: list[GeocodeSuggestion] = [] for s in raw: if s.lat is None or s.lon is None: continue out.append(GeocodeSuggestion( label=s.value, full_address=s.unrestricted_value, lat=s.lat, lon=s.lon, kind=_DADATA_KIND_MAP.get(s.kind, "locality"), )) return out async def _yandex_geocode_request( client: httpx.AsyncClient, api_key: str, query: str, limit: int, bounded: bool ) -> list[dict]: """Single Yandex Geocoder request — bounded=True → строго в ЕКБ через rspn=1.""" params: dict[str, str] = { "apikey": api_key, "geocode": query, "format": "json", "results": str(limit), "lang": "ru_RU", "ll": EKB_BBOX["ll"], "spn": EKB_BBOX["spn"], } if bounded: params["rspn"] = "1" response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params) response.raise_for_status() data = response.json() return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]: """Yandex Geocoder с авто-fallback на typo-tolerant режим. Tier 1: bounded ЕКБ (rspn=1) на оригинальный query. Tier 2: bounded ЕКБ на typo-variants (удвоение согласных). Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по ЕКБ bbox. """ async with httpx.AsyncClient(timeout=8.0) as client: # Tier 1: strict bounded на оригинал members = await _yandex_geocode_request( client, api_key, f"Екатеринбург, {query}", limit, bounded=True, ) results = _parse_yandex_members(members) if results: return results # Tier 2: bounded на typo-варианты for variant in _typo_variants(query, limit=4): members = await _yandex_geocode_request( client, api_key, f"Екатеринбург, {variant}", limit, bounded=True, ) results = _parse_yandex_members(members) if results: return results # Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox members = await _yandex_geocode_request( client, api_key, f"Екатеринбург, {query}", limit, bounded=False, ) results = _parse_yandex_members(members) in_ekb = [ r for r in results if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95 ] return in_ekb async def _nominatim_query_multi( client: httpx.AsyncClient, query: str, limit: int ) -> list[dict]: """Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items.""" response = await client.get( "https://nominatim.openstreetmap.org/search", params={ "q": query, "format": "json", "limit": str(limit), "countrycodes": "ru", "viewbox": EKB_BBOX["viewbox"], "bounded": "1", "addressdetails": "1", }, ) response.raise_for_status() data = response.json() return data if isinstance(data, list) else [] @retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]: """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).""" headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "Accept": "application/json", "Accept-Language": "ru,en;q=0.8", } async with httpx.AsyncClient(timeout=8.0, headers=headers) as client: # Tier 1: оригинальный query data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit) # Tier 2: typo-варианты если оригинал пустой if not data: for variant in _typo_variants(query, limit=3): await asyncio.sleep(1.0) # Nominatim 1 req/sec data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", limit) if data: logger.info("nominatim suggest typo-fixed: %s → %s", query, variant) break out: list[GeocodeSuggestion] = [] for item in data: display = item.get("display_name", "") addr = item.get("address", {}) or {} # Компактный лейбл: street + house_number / locality / district street = addr.get("road") or addr.get("street") or "" house = addr.get("house_number", "") district = addr.get("suburb") or addr.get("city_district") or addr.get("borough") or "" parts = [] if street: parts.append(f"{street}{f', {house}' if house else ''}") elif item.get("name"): parts.append(item["name"]) if district: parts.append(district) label = " · ".join(parts) if parts else display[:80] kind = "house" if house else ("street" if street else "locality") out.append(GeocodeSuggestion( label=label, full_address=display, lat=float(item["lat"]), lon=float(item["lon"]), kind=kind, )) return out def _cadastral_forward_sync( db: Session, query: str, limit: int = 5 ) -> list[GeocodeSuggestion]: """Forward search via gendesign_cad_buildings FDW. Uses ILIKE prefilter (FDW-pushable) + pg_trgm similarity ranking locally. Returns up to `limit` suggestions, or [] on error / no results. """ try: rows = db.execute( text(""" SELECT cad_num, readable_address, lat, lon, similarity(readable_address, CAST(:q AS text)) AS sim FROM gendesign_cad_buildings WHERE readable_address ILIKE CAST('%' || :q || '%' AS text) ORDER BY sim DESC, length(readable_address) ASC LIMIT CAST(:lim AS integer) """), {"q": query, "lim": limit * 4}, ).mappings().all() except Exception: logger.warning("cadastral forward sync failed for query=%r", query, exc_info=True) return [] out: list[GeocodeSuggestion] = [] for r in rows[:limit]: out.append(GeocodeSuggestion( label=str(r["readable_address"]), full_address=str(r["readable_address"]), lat=float(r["lat"]), lon=float(r["lon"]), kind="house", )) return out def _cadastral_reverse_sync( db: Session, lat: float, lon: float, radius_m: int = 200 ) -> str | None: """Reverse lookup via gendesign_cad_buildings FDW. bbox prefilter (±0.0025° lat, ±0.005° lon ≈ 280m) is FDW-pushable. Distance calculated locally on small result set. Filters гаражи/СНТ. Returns None on no result or error. """ try: row = db.execute( text(""" WITH candidates AS ( SELECT cad_num, readable_address, lat, lon, 111320.0 * sqrt( pow(CAST(:lat AS double precision) - lat, 2) + pow( cos(radians(CAST(:lat AS double precision))) * (CAST(:lon AS double precision) - lon), 2 ) ) AS dist_m FROM gendesign_cad_buildings WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025 AND CAST(:lat AS double precision) + 0.0025 AND lon BETWEEN CAST(:lon AS double precision) - 0.005 AND CAST(:lon AS double precision) + 0.005 AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)' ) SELECT readable_address, dist_m FROM candidates WHERE dist_m < CAST(:radius AS double precision) ORDER BY dist_m ASC LIMIT 1 """), {"lat": lat, "lon": lon, "radius": float(radius_m)}, ).first() except Exception: logger.warning( "cadastral reverse sync failed for (%.5f, %.5f)", lat, lon, exc_info=True ) return None if row is None: return None return str(row.readable_address) async def suggest( query: str, db: Session | None = None, limit: int = 8 ) -> list[GeocodeSuggestion]: """Автокомплит адресов в ЕКБ. Cadastral FDW → DaData → Yandex → Nominatim → []. db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier). DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который заблокирован (1k/день demo limit исчерпан). Без кэша (дешёво, провайдеры толерируют автокомплит-запросы). """ if not query or len(query.strip()) < 2: return [] # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов if db is not None: cad_results = await asyncio.to_thread(_cadastral_forward_sync, db, query.strip(), limit) if cad_results: return cad_results # Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free, # лучший fit для РФ адресов. Заменил Yandex как primary external provider # после того как demo-key Yandex (1k/день) был исчерпан. if settings.dadata_api_token: try: dadata_results = await _dadata_suggest(query, limit) if dadata_results: return dadata_results except Exception: logger.exception("dadata suggest failed, falling back to yandex") # Tier 3: Yandex (legacy — оставляем как fallback, если key есть) if settings.yandex_geocoder_api_key: try: results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit) if results: return results except Exception: logger.exception("yandex suggest failed, falling back to nominatim") # Tier 4: Nominatim (последний fallback — OSM, без ключа) try: return await _nominatim_suggest(query, limit) except Exception: logger.exception("nominatim suggest failed") return [] # ── Public API ─────────────────────────────────────────────────────────────── async def geocode(address: str, db: Session) -> GeocodeResult | None: """Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None. Args: address: пользовательский ввод (может быть грязным — нормализуем). db: сессия Postgres для cache lookup/write и cadastral FDW lookup. Returns: GeocodeResult или None если ни один провайдер не отвечает. """ if not address or len(address.strip()) < 3: return None addr_norm = normalize_address(address) # 1. Cache cached = _cache_get(db, addr_norm) if cached is not None: logger.info("geocode cache hit: %s", addr_norm) return cached # 2. Cadastral FDW (прямой запрос к gendesign_cad_buildings — без внешнего API) cad_suggestions = await asyncio.to_thread(_cadastral_forward_sync, db, address.strip(), limit=1) if cad_suggestions: s = cad_suggestions[0] result = GeocodeResult( lat=s.lat, lon=s.lon, full_address=s.full_address, provider="nominatim", # treat as "local" — same confidence as nominatim confidence="exact", ) _cache_put(db, addr_norm, result) logger.info( "geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon ) return result # 3. Yandex (если есть key) с typo-fallback if settings.yandex_geocoder_api_key: try: result = await _yandex_lookup(address, settings.yandex_geocoder_api_key) # Если результат вне ЕКБ — пробуем typo-варианты in_ekb = ( result is not None and 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95 ) if result is not None and in_ekb: _cache_put(db, addr_norm, result) logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon) return result # Tier 2: typo-variants for variant in _typo_variants(address, limit=4): try: result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key) except Exception: continue if result is None: continue if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95: _cache_put(db, addr_norm, result) logger.info( "geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)", addr_norm, variant, result.lat, result.lon, ) return result except Exception: logger.exception("yandex geocoder failed, falling back to nominatim") # 4. Nominatim fallback try: result = await _nominatim_lookup(address) if result is not None: _cache_put(db, addr_norm, result) logger.info( "geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon ) # Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса await asyncio.sleep(1.0) return result except Exception: logger.exception("nominatim geocoder failed") return None # ── Reverse: координаты → адрес (для map-picker'а) ────────────────────────── # Precision levels which we treat as "снап к зданию имеет смысл": # - exact — точный матч на здание (Yandex) # - number — найден дом с номером (то что нам надо для квартирного оценщика) # - cadastral — Cadastral FDW row (то же по точности что Yandex "number") # Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике. _SNAP_PRECISIONS = {"exact", "number", "cadastral"} @dataclass(frozen=True, slots=True) class ReverseGeocodeResult: """Reverse-геокодинг с snapped координатами matched здания. - `address` — текстовый адрес (улица + дом + город). - `snapped_lat` — координата центра здания если provider дал её, иначе echo `lat` входной точки (для precision=street/locality). - `snapped_lon` — то же. - `precision` — yandex-style: `exact`/`number`/`street`/`range`/`near`/ `locality`/`other`/`cadastral`. Используется фронтом чтобы решить — двигать marker (exact/number) или нет. - `provider` — кто дал результат (`yandex`/`nominatim`/`cadastral`). Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number` и snapped >5m от click point — пересаживает marker на snapped point (чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе). Для остальных precision marker остаётся где кликнули — не врём что нашли точное здание. """ address: str snapped_lat: float snapped_lon: float precision: str provider: Literal["yandex", "nominatim", "cadastral"] def _format_reverse_address(addr: dict) -> str | None: """Собирает чистый уличный адрес из Nominatim address-объекта. Nominatim `display_name` ведёт с названия ближайшего POI/организации («NataliOlympic, 2к1, Трамвайный переулок, …»). Для map-picker'а это ломает кейс: такой адрес нельзя forward-геокодировать обратно и оценка не находит аналогов. Берём именно улицу + дом + город. Возвращает None если в объекте нет улицы — тогда вызывающий код откатывается на display_name. """ road = ( addr.get("road") or addr.get("pedestrian") or addr.get("footway") ) if not road: return None house = addr.get("house_number") city = ( addr.get("city") or addr.get("town") or addr.get("village") or addr.get("municipality") ) parts: list[str] = [str(road)] if house: parts.append(str(house)) if city: parts.append(str(city)) return ", ".join(parts) @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) async def _yandex_reverse( lat: float, lon: float, api_key: str ) -> ReverseGeocodeResult | None: """Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision. Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html Параметр `geocode` принимает `lon,lat` (важно — обратный порядок!). """ async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get( "https://geocode-maps.yandex.ru/1.x/", params={ "apikey": api_key, "geocode": f"{lon},{lat}", "format": "json", "results": "1", "kind": "house", # просим именно здание (house), не улицу "lang": "ru_RU", }, ) response.raise_for_status() data = response.json() members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", []) if not members: return None obj = members[0].get("GeoObject", {}) try: lon_str, lat_str = obj["Point"]["pos"].split() snapped_lat = float(lat_str) snapped_lon = float(lon_str) except (KeyError, ValueError): return None meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {}) precision = str(meta.get("precision", "other")) address_text = str(meta.get("text") or obj.get("name") or "") # Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix, # оставляем «улица, дом, город» для consistency с Nominatim/cadastral. if address_text: # «Россия, Свердловская область, Екатеринбург, улица Малышева, 51» # → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street) parts = [p.strip() for p in address_text.split(",") if p.strip()] filtered = [ p for p in parts if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ") ] # Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец locality = None rest: list[str] = [] for p in filtered: if locality is None and p in {"Екатеринбург"}: locality = p else: rest.append(p) if locality and rest: address_text = ", ".join([*rest, locality]) else: address_text = ", ".join(filtered) if not address_text: return None return ReverseGeocodeResult( address=address_text, snapped_lat=snapped_lat, snapped_lon=snapped_lon, precision=precision, provider="yandex", ) @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None: """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon. Nominatim возвращает координаты центра matched feature (building/way/node). precision выводим из наличия `house_number` в addressdetails. """ headers = { "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})", "Accept": "application/json", "Accept-Language": "ru,en;q=0.8", } async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: response = await client.get( "https://nominatim.openstreetmap.org/reverse", params={ "lat": str(lat), "lon": str(lon), "format": "json", "addressdetails": "1", "zoom": "18", }, ) response.raise_for_status() data = response.json() if not isinstance(data, dict) or "error" in data: return None addr = data.get("address") address_text: str | None = None if isinstance(addr, dict): address_text = _format_reverse_address(addr) if not address_text: display = data.get("display_name") if not display: return None address_text = str(display) # snapped coords — то что вернул Nominatim (центр matched feature) try: snapped_lat = float(data["lat"]) snapped_lon = float(data["lon"]) except (KeyError, ValueError, TypeError): snapped_lat, snapped_lon = lat, lon has_house = isinstance(addr, dict) and bool(addr.get("house_number")) precision = "number" if has_house else "street" return ReverseGeocodeResult( address=address_text, snapped_lat=snapped_lat, snapped_lon=snapped_lon, precision=precision, provider="nominatim", ) def _cadastral_reverse_sync_full( db: Session, lat: float, lon: float, radius_m: int = 200 ) -> tuple[str, float, float] | None: """Полный вариант cadastral reverse — возвращает (address, snapped_lat, snapped_lon). Отдельная функция чтобы старый `_cadastral_reverse_sync` (только адрес) остался backward-compatible — его держит `tests/services/test_cadastral_reverse.py`. """ try: row = db.execute( text(""" WITH candidates AS ( SELECT cad_num, readable_address, lat, lon, 111320.0 * sqrt( pow(CAST(:lat AS double precision) - lat, 2) + pow( cos(radians(CAST(:lat AS double precision))) * (CAST(:lon AS double precision) - lon), 2 ) ) AS dist_m FROM gendesign_cad_buildings WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025 AND CAST(:lat AS double precision) + 0.0025 AND lon BETWEEN CAST(:lon AS double precision) - 0.005 AND CAST(:lon AS double precision) + 0.005 AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)' ) SELECT readable_address, lat, lon, dist_m FROM candidates WHERE dist_m < CAST(:radius AS double precision) ORDER BY dist_m ASC LIMIT 1 """), {"lat": lat, "lon": lon, "radius": float(radius_m)}, ).first() except Exception: logger.warning( "cadastral reverse full failed for (%.5f, %.5f)", lat, lon, exc_info=True ) return None if row is None: return None return (str(row.readable_address), float(row.lat), float(row.lon)) async def reverse_geocode( lat: float, lon: float, db: Session | None = None ) -> ReverseGeocodeResult | None: """Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords. Возвращает None если ни один источник не дал адрес. Endpoint api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError наверх — раньше Nominatim 403 → RetryError → FastAPI 500. Snapped lat/lon — это центр matched здания (от provider'а), не echo входных координат. Фронт по precision решает — двигать marker (exact/number) или оставить на клике (street/locality). db: если передан — cadastral lookup через gendesign_cad_buildings FDW (первый tier). """ # 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI) if db is not None: cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon) if cad is not None: address, snap_lat, snap_lon = cad return ReverseGeocodeResult( address=address, snapped_lat=snap_lat, snapped_lon=snap_lon, precision="number", # cadastral row = здание с house number provider="cadastral", ) # 2. Yandex — основной источник snap'а (его Point.pos = центр здания) if settings.yandex_geocoder_api_key: try: result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key) if result is not None: return result except Exception: logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon) # 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit) try: return await _nominatim_reverse(lat, lon) except Exception: logger.exception("nominatim reverse failed for (%.5f, %.5f)", lat, lon) return None def snap_precision_useful(precision: str) -> bool: """True если precision означает «нашли точное здание» — фронт двигает marker.""" return precision in _SNAP_PRECISIONS