"""DaData /clean/address client — enrich address с canonical form, kadastr_num, ФИАС, координаты, метро. Используется в estimator flow для on-demand обогащения **целевого адреса** (payload.address) — заполняет недостающий kadastr_num, который open dataset Росреестра не даёт (см. PR Q1 / vault `meta/00_credentials.md`). ENV: DADATA_API_TOKEN, DADATA_API_SECRET. Если не задан хотя бы один — service возвращает None gracefully (не break flow). Rate limit: 100/день demo tier. Для production надо upgrade'нуть. Endpoint: POST https://cleaner.dadata.ru/api/v1/clean/address Headers: Authorization: Token , X-Secret: , Content-Type: application/json Body: ["
"] Response: list[dict] — обычно один элемент с полями result/house_cadnum/geo_lat/... Docs: https://dadata.ru/api/clean/address/ """ from __future__ import annotations import logging from dataclasses import dataclass 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_TIMEOUT_S = 8.0 @dataclass(frozen=True, slots=True) class DadataAddressResult: """Parsed DaData enrichment payload — slots для compact representation. canonical_address — поле `result` из ответа («г Екатеринбург, ул Малышева, д 125»). house_cadnum — кадастровый номер ДОМА (не участка). Формат «66:41:0704045:350». house_fias_id — UUID ФИАС дома. lat / lon — координаты от DaData (precision до дома). qc_geo — quality code геокодинга: 0=exact, 1=street, 2=settlement… qc_house — quality code дома: 2=в ФИАС, 10=на картах… kladr_id — старый ОКРС-классификатор (legacy, может быть полезен). okato / oktmo — admin codes для отчётности. metro — список ближайших станций метро (если есть): [{name,line,distance}…]. raw — полный ответ для debugging / future fields. """ canonical_address: str | None house_cadnum: str | None house_fias_id: str | None lat: float | None lon: float | None qc_geo: int | None qc_house: int | None kladr_id: str | None okato: str | None oktmo: str | None metro: list[dict[str, Any]] raw: dict[str, Any] def _credentials() -> tuple[str, str] | None: """Returns (token, secret) если оба заданы, иначе None.""" 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_int(value: Any) -> int | None: """DaData отдаёт qc-коды строками иногда. Coerce → int безопасно.""" if value is None: return None try: return int(value) except (TypeError, ValueError): return None def _coerce_float(value: Any) -> float | None: if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _parse_response(item: dict[str, Any]) -> DadataAddressResult: """Парсит single DaData payload item в DadataAddressResult. Не raise'ит — пропускает невалидные поля как None. """ metro_raw = item.get("metro") or [] if not isinstance(metro_raw, list): metro_raw = [] return DadataAddressResult( canonical_address=item.get("result"), house_cadnum=item.get("house_cadnum"), house_fias_id=item.get("house_fias_id"), lat=_coerce_float(item.get("geo_lat")), lon=_coerce_float(item.get("geo_lon")), qc_geo=_coerce_int(item.get("qc_geo")), qc_house=_coerce_int(item.get("qc_house")), kladr_id=item.get("kladr_id"), okato=item.get("okato"), oktmo=item.get("oktmo"), metro=metro_raw, raw=item, ) async def clean_address(address: str) -> DadataAddressResult | None: """Обогащает один адрес через DaData /clean/address. Args: address: свободный текст адреса (как пришёл от пользователя или из geocoder). Returns: DadataAddressResult или None если: - credentials не заданы в ENV (graceful disable) - адрес пустой / слишком короткий - API вернул пустой массив / ошибку shape - сетевая / HTTP ошибка (5xx, 429 quota, timeout) - адрес не распознан (canonical_address пустой) """ if not address or len(address.strip()) < 3: return None creds = _credentials() if creds is None: logger.debug("dadata: credentials не заданы — skip enrichment") return None token, secret = creds headers = { "Authorization": f"Token {token}", "X-Secret": secret, "Content-Type": "application/json", "Accept": "application/json", } body = [address.strip()] try: async with httpx.AsyncClient(timeout=_DADATA_TIMEOUT_S) as client: response = await client.post(DADATA_CLEAN_URL, headers=headers, json=body) except (httpx.TimeoutException, httpx.NetworkError) as exc: logger.warning("dadata: network error для %r: %s", address[:60], exc) return None except Exception as exc: # pragma: no cover — defensive logger.warning("dadata: unexpected client error для %r: %s", address[:60], exc) return None status = response.status_code if status == 429: logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)") return None if status in (401, 403): logger.error( "dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.", status, ) return None if status >= 500: logger.warning("dadata: HTTP %d — transient server error", status) return None if status >= 400: body_preview = (response.text or "")[:200] logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview) return None try: payload = response.json() except ValueError as exc: logger.warning("dadata: невалидный JSON в ответе: %s", exc) return None if not isinstance(payload, list) or not payload: logger.info("dadata: пустой массив для %r", address[:60]) return None item = payload[0] if not isinstance(item, dict): logger.warning("dadata: первый элемент не dict (%s)", type(item).__name__) return None result = _parse_response(item) if not result.canonical_address: # DaData вернул shape, но не распознал адрес — quality codes обычно qc_geo=5. logger.info( "dadata: адрес не распознан (qc_geo=%s qc_house=%s) для %r", result.qc_geo, result.qc_house, address[:60], ) return None logger.info( "dadata: enriched %r → canonical=%r cadnum=%s qc_geo=%s qc_house=%s", address[:60], (result.canonical_address or "")[:60], result.house_cadnum, result.qc_geo, result.qc_house, ) return result