195 lines
8.3 KiB
Python
195 lines
8.3 KiB
Python
"""Минимальный синхронный 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)
|