Добавляет app/services/dadata.py — async client для DaData /clean/address, обогащающий target адрес canonical-формой, kadastr_num, ФИАС, координатами и ближайшим метро. Migration 069 расширяет trade_in_estimates 6 nullable колонками; estimator.estimate_quality вызывает service после geocode и сохраняет результат в основной INSERT. AggregatedEstimate отдаёт canonical_address / house_cadnum / house_fias_id / metro_nearest наружу. Service graceful: пустые credentials / quota 429 / 5xx / network fail → None, estimator продолжает работу. Demo tier 100 req/день — хватит для low-traffic prod и тестов. ENV: DADATA_API_TOKEN, DADATA_API_SECRET. Tests: 20 unit-тестов через httpx.MockTransport, zero real DaData calls. Покрыто: happy path, отсутствие creds, короткий адрес, timeout/network, HTTP 429/401/403/500/502/503, пустой массив, malformed shape, partial payload, coerce строковых qc-кодов в int, проверка request body+headers. Закладывает базу для PR Q2 (frontend DaData Suggest, 10k/день free) и PR Q3 (backfill 339 NULL-coords houses) — обе используют тот же сервис.
214 lines
7.7 KiB
Python
214 lines
7.7 KiB
Python
"""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 <T>, X-Secret: <S>, Content-Type: application/json
|
||
Body: ["<address text>"]
|
||
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
|