ORDER BY has_active_listings DESC, id во всех priority-бакетах + новый --priority listings (focused sweep: NULL cadnum + есть active listing). Дневной 100/day budget сначала закрывает дома, которые реально появляются в оценках → estimator #6 cadastr_exact same-building матчинг загорается на real estimate targets за дни, а не недели. Correlated EXISTS бьёт по partial-индексу listings_house_id_fk_idx. Write-path / EKB-guard / SAVEPOINT / qc_geo rules не тронуты. code-reviewer: ✅ APPROVE no critical. Tests: 40 passed. Ruff clean.
602 lines
25 KiB
Python
602 lines
25 KiB
Python
"""Backfill houses table via DaData /clean/address.
|
||
|
||
PR Q3 follow-up to PR Q1 (#604) — теперь, когда сервис `dadata.clean_address()`
|
||
живёт на проде, проходим по таблице `houses` daily 100/day (demo quota) и
|
||
дополняем недостающие координаты + кадастровые номера + ФИАС.
|
||
|
||
DB state on prod (verified 2026-05-27):
|
||
- houses: 12437 rows total
|
||
- lat IS NULL OR lon IS NULL: 3801 (нужны координаты)
|
||
- cadastral_number IS NULL: 12437 (100% — все нужно дополнить)
|
||
- house_fias_id IS NULL: 12437 (колонка добавлена в migration 070)
|
||
|
||
Walltime: 12437 / 100 = ~124 дня. Acceptable — это batch backfill, не realtime.
|
||
|
||
Priority order (--priority both, default):
|
||
1. NULL lat OR NULL lon (3801 rows) — нужны coords для spatial match
|
||
2. NULL cadastral_number AND lat IS NOT NULL (~8636 rows) — есть coords,
|
||
дополняем только cadnum / fias / qc-codes
|
||
|
||
`--priority coords` — только #1, `--priority cadnum` — только #2.
|
||
|
||
Estimate-relevance ordering (accuracy-roadmap #2):
|
||
Внутри каждого priority bucket'а дома с active listings
|
||
(`EXISTS listings WHERE house_id_fk = houses.id AND is_active`) сортируются
|
||
ПЕРВЫМИ (`ORDER BY has_active_listings DESC, id`). Так дневной 100/day budget
|
||
сначала закрывает дома, которые реально появляются в оценках → estimator #6
|
||
`cadastr_exact` tier «загорается» на real estimate targets за дни, а не недели.
|
||
`--priority listings` — focused sweep: только NULL cadnum + есть active listing.
|
||
|
||
Per-row outcome rules (после `clean_address` ответа):
|
||
- result.qc_geo IN (0, 1) AND result.lat IS NOT NULL → точное / уличное совпадение,
|
||
UPDATE houses SET lat=, lon=, cadastral_number=, house_fias_id=,
|
||
dadata_qc_geo=, dadata_qc_house=, dadata_enriched_at=NOW()
|
||
- result.qc_geo IN (2, 3, 4) OR result IS None → не overwrite координаты,
|
||
только UPDATE houses SET dadata_qc_geo=, dadata_qc_house=, dadata_enriched_at=NOW()
|
||
(фиксируем факт попытки → resume пропустит row при следующем запуске)
|
||
|
||
Resume-safe: `WHERE dadata_enriched_at IS NULL` фильтрует уже обработанные.
|
||
|
||
Design choices:
|
||
- **Per-row SAVEPOINT** (`db.begin_nested()`) — одна DaData / DB ошибка не должна
|
||
завалить весь batch (тот же паттерн что и в backfill_house_coords.py).
|
||
- **Rate limit**: 0.5s между requests (DaData demo allows ~5 req/sec, мы при limit=100
|
||
делаем 50 секунд непрерывной работы — далеко от quota burst-detection).
|
||
- **Daily budget**: `--limit 100` default matches demo tier (100 req/day).
|
||
|
||
Usage:
|
||
DATABASE_URL=postgresql+psycopg://... \\
|
||
DADATA_API_TOKEN=xxx DADATA_API_SECRET=xxx \\
|
||
python -m scripts.backfill_houses_dadata --limit 100
|
||
|
||
Flags:
|
||
--limit N max rows to process (default 100)
|
||
--batch LABEL log label (default `dadata_YYYY-MM-DD`)
|
||
--dry-run log what would happen, no DB writes
|
||
--priority X coords | cadnum | both (default both) | listings
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
from dataclasses import dataclass, field
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
# Allow running both as `python -m scripts.backfill_houses_dadata` (preferred,
|
||
# матчит pattern из backfill_house_coords.py) и как stand-alone file.
|
||
try:
|
||
from app.core.db import SessionLocal # type: ignore[import-not-found]
|
||
except ImportError: # pragma: no cover — fallback for adhoc invocation
|
||
import sys
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
from app.core.db import SessionLocal
|
||
|
||
try:
|
||
from app.services.dadata import ( # type: ignore[import-not-found]
|
||
DadataAddressResult,
|
||
clean_address,
|
||
)
|
||
except ImportError: # pragma: no cover
|
||
import sys
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
from app.services.dadata import DadataAddressResult, clean_address
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||
)
|
||
logger = logging.getLogger("backfill_houses_dadata")
|
||
|
||
# DaData demo tier: 100 requests/day, no burst limit documented but
|
||
# best practice — sleep между requests, чтобы не триггерить anti-flood.
|
||
# 0.5s = 2 req/sec → 50s walltime для full 100/day batch.
|
||
_REQUEST_DELAY_S = 0.5
|
||
|
||
# qc_geo: 0=exact, 1=street, 2=settlement, 3=city, 4=region, 5=unknown.
|
||
# Только 0/1 точны достаточно для overwrite coords дома.
|
||
_QC_GEO_ACCEPT = frozenset({0, 1})
|
||
|
||
# Логировать каждые N rows
|
||
_LOG_EVERY = 10
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Domain types
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class HouseRow:
|
||
"""Минимальные поля из houses для enrichment loop'а.
|
||
|
||
`address` — сырой адрес (приоритет houses.address, fallback на full_address).
|
||
"""
|
||
|
||
id: int
|
||
address: str
|
||
lat: float | None
|
||
lon: float | None
|
||
cadastral_number: str | None
|
||
|
||
|
||
@dataclass
|
||
class Stats:
|
||
"""Счётчики для финального summary log'а."""
|
||
|
||
processed: int = 0
|
||
enriched: int = 0 # qc_geo IN (0,1) — coords/cadnum/fias записаны
|
||
no_change: int = 0 # qc_geo IN (2,3,4) или canonical_address None — только tracking
|
||
failed: int = 0 # exception в clean_address или DB write
|
||
skipped: int = 0 # пустой address (нечего geocode'ить)
|
||
by_priority: dict[str, int] = field(default_factory=dict)
|
||
|
||
def bump(self, priority: str, outcome: str) -> None:
|
||
"""Внутренний учёт по priority bucket'у."""
|
||
bucket = self.by_priority.setdefault(
|
||
priority, {"enriched": 0, "no_change": 0, "failed": 0, "skipped": 0}
|
||
)
|
||
bucket[outcome] = bucket.get(outcome, 0) + 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Source-row query — приоритет: NULL coords → NULL cadnum
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# accuracy-roadmap #2: дом «участвует в оценках» iff у него есть active listing
|
||
# (`listings.house_id_fk → houses.id`, `listings.is_active`). Такие дома —
|
||
# реальные target'ы estimator'а (#6 `cadastr_exact` tier), поэтому их кадастры
|
||
# нужны раньше всего. Используем как ORDER BY-ключ (DESC → впереди) + как фильтр
|
||
# для `--priority listings`. Correlated EXISTS бьёт по partial-индексу
|
||
# `listings_house_id_fk_idx (house_id_fk) WHERE house_id_fk IS NOT NULL`.
|
||
_HAS_ACTIVE_LISTINGS_EXPR = (
|
||
"EXISTS (SELECT 1 FROM listings l "
|
||
"WHERE l.house_id_fk = houses.id AND l.is_active)"
|
||
)
|
||
|
||
|
||
def _select_candidates(
|
||
db: Session, *, priority: str, limit: int
|
||
) -> list[tuple[HouseRow, str]]:
|
||
"""Возвращает список (HouseRow, priority_bucket) для enrichment.
|
||
|
||
priority:
|
||
- 'coords' → только rows с NULL coords (max бизнес-value)
|
||
- 'cadnum' → только rows с NOT NULL coords + NULL cadnum
|
||
- 'both' → coords first, потом cadnum (FIFO в пределах buckets)
|
||
- 'listings' → только NULL cadnum + есть active listing (focused sweep
|
||
для #6 same-building матчинга — дома, которые реально в оценках)
|
||
|
||
Внутри каждого bucket'а строки с active listings (`has_active_listings`)
|
||
идут ПЕРВЫМИ (`ORDER BY has_active_listings DESC, id`), чтобы дневной
|
||
100/day budget сначала закрывал estimate-relevant дома, а уже потом
|
||
длинный хвост. Это и есть accuracy-roadmap #2.
|
||
|
||
Фильтр `dadata_enriched_at IS NULL` — resume-safe (не повторяем уже
|
||
обработанные rows). После full enrichment колонка станет NOT NULL у всех
|
||
rows которые мы успели тронуть, и партишен-индекс
|
||
`houses_dadata_not_enriched_idx` схлопнется.
|
||
"""
|
||
# Skip rows без какого-либо адреса — geocode'ить нечего.
|
||
address_filter = (
|
||
"(COALESCE(NULLIF(trim(address), ''), NULLIF(trim(full_address), '')) IS NOT NULL)"
|
||
)
|
||
|
||
out: list[tuple[HouseRow, str]] = []
|
||
remaining = limit
|
||
|
||
def _fetch(where: str, bucket: str, lim: int) -> list[tuple[HouseRow, str]]:
|
||
sql = text(
|
||
"SELECT id, "
|
||
" COALESCE(NULLIF(trim(address), ''), NULLIF(trim(full_address), '')) "
|
||
" AS address, "
|
||
" lat, lon, cadastral_number, "
|
||
f" {_HAS_ACTIVE_LISTINGS_EXPR} AS has_active_listings "
|
||
"FROM houses "
|
||
f"WHERE {where} "
|
||
" AND dadata_enriched_at IS NULL "
|
||
f" AND {address_filter} "
|
||
"ORDER BY has_active_listings DESC, id "
|
||
"LIMIT CAST(:lim AS int)"
|
||
)
|
||
rows = db.execute(sql, {"lim": lim}).mappings().all()
|
||
return [
|
||
(
|
||
HouseRow(
|
||
id=r["id"],
|
||
address=r["address"],
|
||
lat=r["lat"],
|
||
lon=r["lon"],
|
||
cadastral_number=r["cadastral_number"],
|
||
),
|
||
bucket,
|
||
)
|
||
for r in rows
|
||
]
|
||
|
||
if priority == "listings":
|
||
# Focused sweep: только дома с active listings + NULL cadnum.
|
||
# has_active_listings гарантированно TRUE здесь, но ORDER BY оставляем
|
||
# консистентным (DESC, id) — деградирует до ORDER BY id.
|
||
out.extend(
|
||
_fetch(
|
||
f"(cadastral_number IS NULL AND {_HAS_ACTIVE_LISTINGS_EXPR})",
|
||
"listings",
|
||
remaining,
|
||
)
|
||
)
|
||
return out[:limit]
|
||
|
||
if priority in ("coords", "both"):
|
||
out.extend(_fetch("(lat IS NULL OR lon IS NULL)", "coords", remaining))
|
||
remaining = limit - len(out)
|
||
if priority in ("cadnum", "both") and remaining > 0:
|
||
out.extend(
|
||
_fetch(
|
||
"(lat IS NOT NULL AND lon IS NOT NULL AND cadastral_number IS NULL)",
|
||
"cadnum",
|
||
remaining,
|
||
)
|
||
)
|
||
|
||
return out[:limit]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB writers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _update_house_enriched(
|
||
db: Session,
|
||
*,
|
||
house_id: int,
|
||
result: DadataAddressResult,
|
||
) -> None:
|
||
"""UPDATE houses с полным набором полей (qc_geo IN (0,1) → точное матч).
|
||
|
||
Не overwrite'ит lat/lon если result.lat/lon None (DaData может вернуть
|
||
qc_geo=0 для города но без точки — edge case). COALESCE'им через
|
||
`CASE WHEN :lat IS NOT NULL THEN :lat ELSE lat END` чтобы не ломать
|
||
существующие значения.
|
||
|
||
cadastral_number / house_fias_id — overwrite только если DaData вернул
|
||
непустое значение (COALESCE с existing).
|
||
"""
|
||
db.execute(
|
||
text(
|
||
"UPDATE houses "
|
||
" SET lat = CASE WHEN CAST(:lat AS double precision) IS NOT NULL "
|
||
" THEN CAST(:lat AS double precision) "
|
||
" ELSE lat END, "
|
||
" lon = CASE WHEN CAST(:lon AS double precision) IS NOT NULL "
|
||
" THEN CAST(:lon AS double precision) "
|
||
" ELSE lon END, "
|
||
" cadastral_number = COALESCE(CAST(:cadnum AS text), cadastral_number), "
|
||
" house_fias_id = COALESCE(CAST(:fias AS text), house_fias_id), "
|
||
" dadata_qc_geo = CAST(:qc_geo AS integer), "
|
||
" dadata_qc_house = CAST(:qc_house AS integer), "
|
||
" dadata_enriched_at = NOW() "
|
||
" WHERE id = CAST(:id AS bigint)"
|
||
),
|
||
{
|
||
"id": house_id,
|
||
"lat": result.lat,
|
||
"lon": result.lon,
|
||
"cadnum": result.house_cadnum,
|
||
"fias": result.house_fias_id,
|
||
"qc_geo": result.qc_geo,
|
||
"qc_house": result.qc_house,
|
||
},
|
||
)
|
||
|
||
|
||
def _update_house_attempt(
|
||
db: Session,
|
||
*,
|
||
house_id: int,
|
||
qc_geo: int | None,
|
||
qc_house: int | None,
|
||
) -> None:
|
||
"""UPDATE только tracking-поля (qc_geo вне ACCEPT set, либо None result).
|
||
|
||
НЕ трогаем lat/lon/cadnum/fias — DaData либо не нашёл точно (qc=2,3,4),
|
||
либо вообще не распознал адрес. Фиксируем `dadata_enriched_at` чтобы
|
||
resume не повторял эту row при следующем запуске.
|
||
"""
|
||
db.execute(
|
||
text(
|
||
"UPDATE houses "
|
||
" SET dadata_qc_geo = CAST(:qc_geo AS integer), "
|
||
" dadata_qc_house = CAST(:qc_house AS integer), "
|
||
" dadata_enriched_at = NOW() "
|
||
" WHERE id = CAST(:id AS bigint)"
|
||
),
|
||
{"id": house_id, "qc_geo": qc_geo, "qc_house": qc_house},
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main loop
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
# «Екатеринбург» в любом регистре — короткие адреса типа «улица Малышева, 125»
|
||
# без явного города DaData по умолчанию маппит в Москву → подсовываем префикс.
|
||
_EKB_CITY_PREFIX = "Екатеринбург, "
|
||
_EKB_CITY_MARKER = "екатеринбург"
|
||
|
||
|
||
def _with_city_context(address: str) -> str:
|
||
"""Pre-pend «Екатеринбург, » если в адресе ещё нет указания города.
|
||
|
||
DaData /clean без city-контекста выбирает Москву по умолчанию для
|
||
тёзок (улица Просторная → ул Просторная в Москве). Этот wrapper гарантирует,
|
||
что для коротких street-адресов из houses.address DaData получает явный город.
|
||
|
||
Идемпотентен: «Екатеринбург, улица Малышева, 125» не префиксируется повторно.
|
||
"""
|
||
if not address:
|
||
return address
|
||
if _EKB_CITY_MARKER in address.lower():
|
||
return address
|
||
return _EKB_CITY_PREFIX + address.strip()
|
||
|
||
|
||
def _result_in_ekb(result: DadataAddressResult | None) -> bool:
|
||
"""Safety: True если DaData вернул действительно Екатеринбургский адрес.
|
||
|
||
Защищает от false-positives когда DaData мапит улицу-тёзку (даже после
|
||
префикса) в другой город. Использует `result.raw.get('city')` (структурное
|
||
поле) c fallback на substring search в `canonical_address`.
|
||
"""
|
||
if result is None:
|
||
return False
|
||
city = (result.raw.get("city") or "") if isinstance(result.raw, dict) else ""
|
||
if _EKB_CITY_MARKER in city.lower():
|
||
return True
|
||
canonical = (result.canonical_address or "").lower()
|
||
return _EKB_CITY_MARKER in canonical
|
||
|
||
|
||
def _is_enriched_result(result: DadataAddressResult | None) -> bool:
|
||
"""Решает, можно ли overwrite'ить координаты + cadnum по этому result'у.
|
||
|
||
Условие: qc_geo IN (0, 1) AND lat / lon заданы AND city == Екатеринбург.
|
||
Возврат False означает «записать только tracking, не overwrite payload».
|
||
"""
|
||
if result is None:
|
||
return False
|
||
if result.qc_geo not in _QC_GEO_ACCEPT:
|
||
return False
|
||
if result.lat is None or result.lon is None:
|
||
return False
|
||
if not _result_in_ekb(result):
|
||
return False
|
||
return True
|
||
|
||
|
||
async def _run_backfill(
|
||
db: Session,
|
||
candidates: list[tuple[HouseRow, str]],
|
||
*,
|
||
batch: str,
|
||
dry_run: bool,
|
||
) -> Stats:
|
||
"""Главный цикл — для каждого candidate зовём DaData + UPDATE houses.
|
||
|
||
Per-row SAVEPOINT защищает batch от падений на одной row. Per-row commit
|
||
после успешного nested block → resume picks up exactly где упали.
|
||
"""
|
||
stats = Stats()
|
||
|
||
for i, (row, priority) in enumerate(candidates, start=1):
|
||
result: DadataAddressResult | None = None
|
||
outcome = "no_change"
|
||
|
||
try:
|
||
# `clean_address` сам обрабатывает HTTP errors / quota / timeout
|
||
# и возвращает None — exception будет только при unexpected error.
|
||
# `_with_city_context` префиксирует «Екатеринбург, » если в адресе
|
||
# ещё нет указания города (защита от Москва-by-default mapping).
|
||
result = await clean_address(_with_city_context(row.address))
|
||
except Exception as exc: # defensive — одна ошибка не должна валить batch
|
||
stats.failed += 1
|
||
stats.bump(priority, "failed")
|
||
logger.warning(
|
||
"dadata_call failed for house_id=%s addr=%r: %s",
|
||
row.id,
|
||
row.address[:60],
|
||
exc,
|
||
)
|
||
# Не пишем в DB при exception — оставляем row для следующей попытки.
|
||
continue
|
||
|
||
if dry_run:
|
||
if _is_enriched_result(result):
|
||
outcome = "enriched"
|
||
logger.info(
|
||
"DRY-RUN house_id=%s [%s] WOULD enrich → "
|
||
"qc_geo=%s qc_house=%s lat=%s lon=%s cadnum=%s fias=%s",
|
||
row.id,
|
||
priority,
|
||
result.qc_geo if result else None,
|
||
result.qc_house if result else None,
|
||
result.lat if result else None,
|
||
result.lon if result else None,
|
||
result.house_cadnum if result else None,
|
||
result.house_fias_id if result else None,
|
||
)
|
||
else:
|
||
outcome = "no_change"
|
||
logger.info(
|
||
"DRY-RUN house_id=%s [%s] WOULD record attempt only → "
|
||
"qc_geo=%s qc_house=%s (result_present=%s)",
|
||
row.id,
|
||
priority,
|
||
result.qc_geo if result else None,
|
||
result.qc_house if result else None,
|
||
result is not None,
|
||
)
|
||
stats.processed += 1
|
||
if outcome == "enriched":
|
||
stats.enriched += 1
|
||
else:
|
||
stats.no_change += 1
|
||
stats.bump(priority, outcome)
|
||
else:
|
||
try:
|
||
with db.begin_nested():
|
||
if _is_enriched_result(result):
|
||
assert result is not None # narrowed by _is_enriched_result
|
||
_update_house_enriched(db, house_id=row.id, result=result)
|
||
outcome = "enriched"
|
||
stats.enriched += 1
|
||
else:
|
||
_update_house_attempt(
|
||
db,
|
||
house_id=row.id,
|
||
qc_geo=result.qc_geo if result else None,
|
||
qc_house=result.qc_house if result else None,
|
||
)
|
||
outcome = "no_change"
|
||
stats.no_change += 1
|
||
# Per-row commit чтобы resume picks up exactly where we crashed.
|
||
db.commit()
|
||
stats.processed += 1
|
||
stats.bump(priority, outcome)
|
||
except Exception as exc: # defensive — DB write failure isolation
|
||
db.rollback()
|
||
stats.failed += 1
|
||
stats.bump(priority, "failed")
|
||
logger.warning(
|
||
"db_write failed for house_id=%s: %s", row.id, exc
|
||
)
|
||
|
||
if i % _LOG_EVERY == 0:
|
||
logger.info(
|
||
"batch=%s progress %d/%d enriched=%d no_change=%d failed=%d",
|
||
batch,
|
||
i,
|
||
len(candidates),
|
||
stats.enriched,
|
||
stats.no_change,
|
||
stats.failed,
|
||
)
|
||
|
||
# Rate-limit: пауза только если ещё есть rows впереди.
|
||
if i < len(candidates):
|
||
await asyncio.sleep(_REQUEST_DELAY_S)
|
||
|
||
return stats
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||
"""argparse setup, выделено отдельно для testability."""
|
||
p = argparse.ArgumentParser(
|
||
description=(
|
||
"PR Q3 — backfill houses.lat/lon/cadastral_number/house_fias_id "
|
||
"через DaData /clean/address (demo tier, 100/day)."
|
||
),
|
||
)
|
||
p.add_argument(
|
||
"--limit",
|
||
type=int,
|
||
default=100,
|
||
help="Max rows to process (default 100 — matches DaData demo daily quota).",
|
||
)
|
||
p.add_argument(
|
||
"--batch",
|
||
default=f"dadata_{date.today().isoformat()}",
|
||
help="Log batch label. Не влияет на DB фильтры — только для логов.",
|
||
)
|
||
p.add_argument(
|
||
"--dry-run",
|
||
action="store_true",
|
||
help="Логирует что бы произошло, но не пишет в DB.",
|
||
)
|
||
p.add_argument(
|
||
"--priority",
|
||
choices=("coords", "cadnum", "both", "listings"),
|
||
default="both",
|
||
help=(
|
||
"Какой набор кандидатов обрабатывать. coords = только NULL coords, "
|
||
"cadnum = NOT NULL coords + NULL cadnum, both (default) = первый "
|
||
"приоритет coords, listings = только NULL cadnum + есть active "
|
||
"listing (focused sweep для #6 same-building матчинга). Во всех "
|
||
"режимах дома с active listings сортируются вперёд."
|
||
),
|
||
)
|
||
return p.parse_args(argv)
|
||
|
||
|
||
async def main(argv: list[str] | None = None) -> int:
|
||
"""CLI entry point. Возвращает кол-во processed rows."""
|
||
args = _parse_args(argv)
|
||
|
||
# Сервис graceful'но disable'ится без creds, но script без них бессмысленен —
|
||
# лучше fail fast, чтобы cron-runs не съели DB-locks впустую.
|
||
token = os.environ.get("DADATA_API_TOKEN", "").strip()
|
||
secret = os.environ.get("DADATA_API_SECRET", "").strip()
|
||
if not token or not secret:
|
||
raise SystemExit(
|
||
"DADATA_API_TOKEN + DADATA_API_SECRET required — "
|
||
"DaData /clean/address без secret не работает."
|
||
)
|
||
|
||
logger.info(
|
||
"starting batch=%s limit=%s priority=%s dry_run=%s",
|
||
args.batch,
|
||
args.limit,
|
||
args.priority,
|
||
args.dry_run,
|
||
)
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
candidates = _select_candidates(db, priority=args.priority, limit=args.limit)
|
||
logger.info(
|
||
"loaded candidates: %d (priority=%s)", len(candidates), args.priority
|
||
)
|
||
if not candidates:
|
||
logger.info(
|
||
"nothing to do — нет rows с dadata_enriched_at IS NULL для priority=%s",
|
||
args.priority,
|
||
)
|
||
return 0
|
||
|
||
stats = await _run_backfill(
|
||
db, candidates, batch=args.batch, dry_run=args.dry_run
|
||
)
|
||
|
||
logger.info(
|
||
"done: batch=%s processed=%d enriched=%d no_change=%d "
|
||
"failed=%d skipped=%d by_priority=%s",
|
||
args.batch,
|
||
stats.processed,
|
||
stats.enriched,
|
||
stats.no_change,
|
||
stats.failed,
|
||
stats.skipped,
|
||
stats.by_priority,
|
||
)
|
||
return stats.processed
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
if __name__ == "__main__": # pragma: no cover
|
||
asyncio.run(main())
|