"""Минимальный синхронный DaData-клиент — /clean/address → (lat, lon). Нужен ТОЛЬКО для geo-pass в objective_backfill (#2177): адрес objective-проекта → координаты для haversine-сверки с domrf-точкой. Не тащим tradein-модуль (`tradein-mvp/.../dadata.py`) импортом — это другое приложение с другим settings. Клиент СИНХРОННЫЙ (httpx.Client): geo-pass гоняется в CLI / sync-Session, без event-loop. Авторизация /clean/address требует ОБА: token + secret (в отличие от /suggest, которому хватает токена). Оба берутся из settings (env DADATA_API_TOKEN / DADATA_API_SECRET). Возвращает None (не raise) на любой сбой — нет credentials, пустой/короткий адрес, сетевая/HTTP-ошибка, 429 quota, невалидный JSON, адрес не распознан. Вызывающая сторона (geo-pass) трактует None как «нет геокода» → reject-кандидат, проход не падает. Прод-инцидент 2026-07-03: у токена может быть ОТКЛЮЧЕНА фича CLEAN («Feature 'CLEAN' disabled for token» — стандартизация платная, баланс исчерпан), при этом БЕСПЛАТНЫЙ suggestions API (10k/день, только Token без X-Secret) работает и тоже отдаёт geo_lat/geo_lon. Поэтому: cleaner → при 401/403 однократный фолбэк на suggest/address (первая саджеста). Точность саджесты чуть ниже клинера — для наших целей (точка на карте + bbox-гейт) достаточно. Docs: https://dadata.ru/api/clean/address/ · https://dadata.ru/api/suggest/address/ """ from __future__ import annotations import logging from typing import Any import httpx from app.core.config import settings logger = logging.getLogger(__name__) DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address" DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" def _credentials() -> tuple[str, str] | None: """(token, secret) если ОБА заданы непусто, иначе None (graceful disable).""" token = (settings.dadata_api_token or "").strip() secret = (settings.dadata_api_secret or "").strip() if not token or not secret: return None return token, secret def _coerce_float(value: Any) -> float | None: """DaData отдаёт geo_lat/geo_lon строкой — coerce в float безопасно.""" if value is None: return None try: return float(value) except (TypeError, ValueError): return None def clean_address(address: str) -> tuple[float, float] | None: """Геокодировать один адрес через DaData /clean/address → (lat, lon). Args: address: свободный текст адреса (objective_lots.address). Returns: (geo_lat, geo_lon) как float-пара, либо None если: - credentials не заданы в ENV (graceful disable); - адрес пустой / короче 3 символов; - сетевая / HTTP-ошибка (timeout, 5xx, 429 quota, 401/403 auth, 4xx); - невалидный JSON / пустой массив / не dict; - DaData не вернул geo_lat/geo_lon (адрес не геокодирован). """ if not address or len(address.strip()) < 3: return None creds = _credentials() if creds is None: logger.debug("dadata_client: credentials не заданы — skip геокод") return None token, secret = creds headers = { "Authorization": f"Token {token}", "X-Secret": secret, "Content-Type": "application/json", "Accept": "application/json", } body = [address.strip()] try: with httpx.Client(timeout=settings.dadata_timeout_s) as client: response = client.post(DADATA_CLEAN_URL, headers=headers, json=body) except (httpx.TimeoutException, httpx.NetworkError) as exc: logger.warning("dadata_client: network error для %r: %s", address[:60], exc) return None except Exception as exc: # defensive: любой прочий httpx/клиент-сбой → None, geo-pass reject'ит, # НЕ роняем весь проход. Логируем на warning (не глотаем молча). logger.warning("dadata_client: unexpected client error для %r: %s", address[:60], exc) return None status = response.status_code if status == 429: logger.warning("dadata_client: HTTP 429 — quota exceeded") return None if status in (401, 403): # Фича CLEAN у токена может быть отключена (баланс стандартизации) — # фолбэк на бесплатный suggestions (тот же token, X-Secret не нужен). logger.warning("dadata_client: cleaner HTTP %d — фолбэк на suggest/address", status) return _suggest_geocode(address.strip(), token) if status >= 400: logger.warning("dadata_client: HTTP %d для %r", status, address[:60]) return None try: payload = response.json() except ValueError as exc: logger.warning("dadata_client: невалидный JSON: %s", exc) return None if not isinstance(payload, list) or not payload: logger.info("dadata_client: пустой ответ для %r", address[:60]) return None item = payload[0] if not isinstance(item, dict): logger.warning("dadata_client: первый элемент не dict (%s)", type(item).__name__) return None lat = _coerce_float(item.get("geo_lat")) lon = _coerce_float(item.get("geo_lon")) if lat is None or lon is None: logger.info( "dadata_client: нет координат (qc_geo=%s) для %r", item.get("qc_geo"), address[:60], ) return None return (lat, lon) def _suggest_geocode(address: str, token: str) -> tuple[float, float] | None: """Фолбэк-геокод через БЕСПЛАТНЫЙ suggest/address (первая саджеста). Возвращает (lat, lon) из suggestions[0].data.geo_lat/geo_lon либо None. Та же graceful-семантика, что у clean_address. """ headers = { "Authorization": f"Token {token}", "Content-Type": "application/json", "Accept": "application/json", } try: with httpx.Client(timeout=settings.dadata_timeout_s) as client: response = client.post( DADATA_SUGGEST_URL, headers=headers, json={"query": address, "count": 1}, ) except (httpx.TimeoutException, httpx.NetworkError) as exc: logger.warning("dadata_client: suggest network error для %r: %s", address[:60], exc) return None except Exception as exc: logger.warning("dadata_client: suggest unexpected error для %r: %s", address[:60], exc) return None if response.status_code != 200: logger.warning("dadata_client: suggest HTTP %d для %r", response.status_code, address[:60]) return None try: payload = response.json() except ValueError as exc: logger.warning("dadata_client: suggest невалидный JSON: %s", exc) return None suggestions = payload.get("suggestions") if isinstance(payload, dict) else None if not suggestions or not isinstance(suggestions[0], dict): logger.info("dadata_client: suggest пусто для %r", address[:60]) return None data = suggestions[0].get("data") or {} lat = _coerce_float(data.get("geo_lat")) lon = _coerce_float(data.get("geo_lon")) if lat is None or lon is None: logger.info( "dadata_client: suggest без координат (qc_geo=%s) для %r", data.get("qc_geo"), address[:60], ) return None return (lat, lon)