"""Audit driver — compares houses.address vs Yandex reverse geocode. Phase 1 of Forgejo issue #582. Pulls a stratified sample of EKB houses (25 per admin district = 200 total), reverse-geocodes each via Yandex, computes the distance between the stored coordinates and the snapped Yandex point, and writes the result into `address_mismatch_audit`. Design choices: - **Resumable**: the audit table has UNIQUE (house_id, audit_batch). Re-run with the same `--batch` skips rows already inserted, so a partial run can be picked up after CAPTCHA / network blip. - **Mode auto**: prefer API when `YANDEX_GEOCODER_API_KEY` is set, fall back to Playwright otherwise. Explicit override via `--mode {api,playwright}`. - **No prod side effects**: the script only writes to one new audit table; it never touches `houses`, `house_sources`, or any matching/listing row. - **Per-row SAVEPOINT**: a single Yandex error must not nuke the entire batch — wrap each INSERT in `db.begin_nested()` per backend.md. How to run: DATABASE_URL=postgresql+psycopg://... \ YANDEX_GEOCODER_API_KEY=... \ python -m scripts.audit_address_mismatch --batch 2026-05-25_run1 Outputs (post-run): - New rows in `address_mismatch_audit` with batch label. - `scripts/address_audit_report.sql :batch=` for summary. """ from __future__ import annotations import argparse import asyncio import json import logging import os import random from dataclasses import dataclass from datetime import date from pathlib import Path from typing import Any import httpx from sqlalchemy import text from sqlalchemy.orm import Session # Allow running both as `python -m scripts.audit_address_mismatch` (preferred) # and as a stand-alone file (`python scripts/audit_address_mismatch.py`) # without requiring package install. try: from app.core.db import SessionLocal # type: ignore[import-not-found] from app.services.matching.normalize import normalize_address # 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 from app.services.matching.normalize import normalize_address # `from .` works when run via -m; the absolute import works under pytest. try: from scripts._yandex_reverse import ( # type: ignore[import-not-found] YandexBlockedError, YandexReverseResult, reverse_via_api, reverse_via_playwright, ) except ImportError: from _yandex_reverse import ( # type: ignore[no-redef] YandexBlockedError, YandexReverseResult, reverse_via_api, reverse_via_playwright, ) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) logger = logging.getLogger("audit_address_mismatch") # Playwright persistent context location — keeps cookies/local storage between # runs so we look like a returning user, reducing CAPTCHA frequency. _PLAYWRIGHT_USER_DATA = Path.home() / ".cache" / "tradein-audit-playwright" _PLAYWRIGHT_UA = ( "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" ) _SAMPLE_SQL_PATH = Path(__file__).parent / "audit_address_sample.sql" # --------------------------------------------------------------------------- # Domain helpers # --------------------------------------------------------------------------- @dataclass class SampleRow: """One house from the stratified sampling query.""" id: int address: str lat: float lon: float district: str | None # Words that introduce a street rather than identify it. We skip these so the # comparison lands on the actual street name ('малышева' / 'ленина'). Mirrors # the canonical forms produced by `normalize_address` (which expands all known # abbreviations to these full words). _STREET_TYPE_WORDS = frozenset( { "улица", "проспект", "переулок", "бульвар", "проезд", "шоссе", "площадь", "набережная", "тупик", "строение", "корпус", "дом", } ) # Geographic prefix words that addresses sometimes carry before the street # (e.g. 'россия екатеринбург улица малышева 51'). We skip them too so we # converge on the same identifying token regardless of how verbose the # source representation is. _GEO_PREFIX_WORDS = frozenset( { "россия", "свердловская", "область", "екатеринбург", "город", "г", } ) def _first_street_token(address: str | None) -> str | None: """Extract the first street-name token of a normalized address. Phase-1 heuristic for "do the streets agree": skip numeric tokens (house numbers), street-type words ('улица', 'проспект', …), and geographic prefixes ('россия', 'екатеринбург', …) — the next token is the street name itself, which is the identifying part we want to compare. Returns None for an empty address or when no candidate token remains. """ norm = normalize_address(address or "") if not norm: return None for tok in norm.split(): if not tok: continue # Skip purely numeric tokens (e.g. '5', '17а' if it starts with digit). if tok[0].isdigit(): continue # Skip street type words and geographic prefixes. if tok in _STREET_TYPE_WORDS or tok in _GEO_PREFIX_WORDS: continue return tok return None def _street_differs(original: str | None, snapped: str | None) -> bool | None: """True iff first non-numeric token differs between the two addresses. Returns None when either side is empty — we cannot compute a meaningful diff (caller writes NULL into the audit row). """ a = _first_street_token(original) b = _first_street_token(snapped) if a is None or b is None: return None return a != b def _distance_meters( db: Session, olat: float, olon: float, slat: float, slon: float, ) -> float | None: """Compute great-circle distance via PostGIS geography type. We could do this in Python with a haversine formula, but the audit table uses ST_Distance results elsewhere so we use the same authority to avoid drift. ST_MakePoint(lon, lat) — PostGIS convention is lon first. """ row = db.execute( text( "SELECT ST_Distance(" " ST_SetSRID(ST_MakePoint(CAST(:olon AS double precision), " " CAST(:olat AS double precision)), 4326)::geography, " " ST_SetSRID(ST_MakePoint(CAST(:slon AS double precision), " " CAST(:slat AS double precision)), 4326)::geography" ") AS m" ), {"olat": olat, "olon": olon, "slat": slat, "slon": slon}, ).first() if row is None or row[0] is None: return None return float(row[0]) # --------------------------------------------------------------------------- # Sampling + resumption queries # --------------------------------------------------------------------------- def _load_sample(db: Session, limit_per_district: int) -> list[SampleRow]: """Run the stratified sampling SQL → list of SampleRow.""" sql = _SAMPLE_SQL_PATH.read_text(encoding="utf-8") rows = db.execute(text(sql), {"limit_per_district": limit_per_district}).mappings().all() return [ SampleRow( id=r["id"], address=r["address"], lat=float(r["lat"]), lon=float(r["lon"]), district=r["district"], ) for r in rows ] def _already_processed_ids(db: Session, batch: str) -> set[int]: """Return the set of house_id already in the audit table for this batch. Drives resumability: drop these from the sample before geocoding. """ rows = db.execute( text("SELECT house_id FROM address_mismatch_audit WHERE audit_batch = CAST(:b AS text)"), {"b": batch}, ).all() return {r[0] for r in rows} # --------------------------------------------------------------------------- # Insert helper # --------------------------------------------------------------------------- def _insert_audit_row( db: Session, *, house_id: int, batch: str, district: str | None, original_address: str | None, original_lat: float | None, original_lon: float | None, snapped_address: str | None, snapped_lat: float | None, snapped_lon: float | None, distance_m: float | None, street_differs: bool | None, audit_status: str, error_message: str | None, raw_payload: dict[str, Any] | None, ) -> None: """INSERT … ON CONFLICT DO NOTHING into address_mismatch_audit. Wrapped in begin_nested by the caller per backend.md SAVEPOINT pattern. """ db.execute( text( "INSERT INTO address_mismatch_audit (" " house_id, audit_batch, district," " original_address, original_lat, original_lon," " snapped_address, snapped_lat, snapped_lon," " distance_m, street_differs," " audit_status, error_message, raw_payload" ") VALUES (" " CAST(:house_id AS bigint), CAST(:batch AS text), :district," " :original_address, :original_lat, :original_lon," " :snapped_address, :snapped_lat, :snapped_lon," " :distance_m, :street_differs," " CAST(:audit_status AS text), :error_message," " CAST(:raw_payload AS jsonb)" ") ON CONFLICT (house_id, audit_batch) DO NOTHING" ), { "house_id": house_id, "batch": batch, "district": district, "original_address": original_address, "original_lat": original_lat, "original_lon": original_lon, "snapped_address": snapped_address, "snapped_lat": snapped_lat, "snapped_lon": snapped_lon, "distance_m": distance_m, "street_differs": street_differs, "audit_status": audit_status, "error_message": error_message, "raw_payload": json.dumps(raw_payload) if raw_payload is not None else None, }, ) # --------------------------------------------------------------------------- # Mode dispatcher # --------------------------------------------------------------------------- def _resolve_mode(mode: str, api_key: str | None) -> str: """Translate `--mode auto` → concrete 'api' / 'playwright' choice. Explicit modes are passed through unchanged; auto chooses api iff a key is configured (fail-fast: we don't want a "should have used the API but silently fell back to slow scraping" surprise). """ if mode == "auto": return "api" if api_key else "playwright" return mode # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- async def _run_api_mode( db: Session, sample: list[SampleRow], batch: str, api_key: str, ) -> int: """Geocode the sample using the HTTP Geocoder API.""" processed = 0 last_distance: float | None = None async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: for i, row in enumerate(sample, start=1): status = "ok" err: str | None = None res: YandexReverseResult | None = None try: res = await reverse_via_api(row.lat, row.lon, api_key, client=client) except httpx.HTTPError as e: status = "error" err = f"http_error: {e!s}" except Exception as e: # pragma: no cover — defensive status = "error" err = f"unhandled: {e!s}" distance = None street_diff: bool | None = None if res is not None and status == "ok": if res.address is None: status = "no_match" else: if res.snapped_lat is not None and res.snapped_lon is not None: distance = _distance_meters( db, row.lat, row.lon, res.snapped_lat, res.snapped_lon ) last_distance = distance street_diff = _street_differs(row.address, res.address) try: with db.begin_nested(): _insert_audit_row( db, house_id=row.id, batch=batch, district=row.district, original_address=row.address, original_lat=row.lat, original_lon=row.lon, snapped_address=res.address if res else None, snapped_lat=res.snapped_lat if res else None, snapped_lon=res.snapped_lon if res else None, distance_m=distance, street_differs=street_diff, audit_status=status, error_message=err, raw_payload=res.raw if res else None, ) # Per-row commit: each row is durable on disk before the next # Yandex call; --batch resume picks up exactly where we crashed. db.commit() processed += 1 except Exception as e: db.rollback() logger.warning("insert failed for house_id=%s: %s", row.id, e) if i % 10 == 0: logger.info( "progress %d/%d, mode=api, last_distance=%s", i, len(sample), f"{last_distance:.1f}m" if last_distance is not None else "n/a", ) return processed async def _run_playwright_mode( db: Session, sample: list[SampleRow], batch: str, ) -> int: """Geocode via a persistent Playwright context (CAPTCHA-aware).""" try: from playwright.async_api import async_playwright # type: ignore[import-not-found] except ImportError as e: raise RuntimeError( "Playwright is required for --mode playwright. " "Install with `uv sync --group dev` and `playwright install chromium`." ) from e _PLAYWRIGHT_USER_DATA.mkdir(parents=True, exist_ok=True) processed = 0 last_distance: float | None = None async with async_playwright() as p: context = await p.chromium.launch_persistent_context( user_data_dir=str(_PLAYWRIGHT_USER_DATA), headless=False, user_agent=_PLAYWRIGHT_UA, locale="ru-RU", timezone_id="Asia/Yekaterinburg", ) page = await context.new_page() try: for i, row in enumerate(sample, start=1): status = "ok" err: str | None = None res: YandexReverseResult | None = None stop_batch = False try: res = await reverse_via_playwright(row.lat, row.lon, page) except YandexBlockedError as e: status = "blocked" err = str(e) stop_batch = True except Exception as e: status = "error" err = f"playwright: {e!s}" distance = None street_diff: bool | None = None if res is not None and status == "ok": if res.address is None: status = "no_match" else: if res.snapped_lat is not None and res.snapped_lon is not None: distance = _distance_meters( db, row.lat, row.lon, res.snapped_lat, res.snapped_lon ) last_distance = distance street_diff = _street_differs(row.address, res.address) try: with db.begin_nested(): _insert_audit_row( db, house_id=row.id, batch=batch, district=row.district, original_address=row.address, original_lat=row.lat, original_lon=row.lon, snapped_address=res.address if res else None, snapped_lat=res.snapped_lat if res else None, snapped_lon=res.snapped_lon if res else None, distance_m=distance, street_differs=street_diff, audit_status=status, error_message=err, raw_payload=res.raw if res else None, ) db.commit() processed += 1 except Exception as e: db.rollback() logger.warning("insert failed for house_id=%s: %s", row.id, e) if stop_batch: logger.error( "Yandex CAPTCHA detected at position %d/%d (house_id=%s). " "Stopping batch — re-run with same --batch to resume.", i, len(sample), row.id, ) break if i % 10 == 0: logger.info( "progress %d/%d, mode=playwright, last_distance=%s", i, len(sample), f"{last_distance:.1f}m" if last_distance is not None else "n/a", ) # Random delay 4-7s between requests — keeps us under Yandex's # heuristic rate limit while still finishing 200 rows in <30min. # Skip the wait on the last iteration (no next request to space). if i < len(sample): await asyncio.sleep(random.uniform(4.0, 7.0)) finally: await context.close() return processed # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: """argparse setup, factored out for testability.""" p = argparse.ArgumentParser( description="Phase 1 audit — houses.address vs Yandex reverse geocode.", ) p.add_argument( "--batch", default=f"{date.today().isoformat()}_run1", help="Audit batch label. Same batch re-run skips already-processed houses.", ) p.add_argument( "--limit-per-district", type=int, default=25, help="Houses to sample per district (default 25 → ~200 total for EKB).", ) p.add_argument( "--mode", choices=("auto", "api", "playwright"), default="auto", help="auto = API if YANDEX_GEOCODER_API_KEY set, else playwright.", ) return p.parse_args(argv) async def main(argv: list[str] | None = None) -> int: """CLI entry point. Returns the number of rows processed this run.""" args = _parse_args(argv) api_key = os.environ.get("YANDEX_GEOCODER_API_KEY") mode = _resolve_mode(args.mode, api_key) if mode == "api" and not api_key: raise SystemExit("mode=api requested but YANDEX_GEOCODER_API_KEY is not set") logger.info( "starting audit batch=%s mode=%s limit_per_district=%d", args.batch, mode, args.limit_per_district, ) db = SessionLocal() try: sample = _load_sample(db, args.limit_per_district) logger.info("loaded sample: %d houses", len(sample)) # Resume support — drop already-processed house_ids. done = _already_processed_ids(db, args.batch) if done: logger.info( "resuming batch %s: %d rows already processed, %d remaining", args.batch, len(done), len(sample) - sum(1 for s in sample if s.id in done), ) remaining = [s for s in sample if s.id not in done] if not remaining: logger.info("nothing to do — batch %s is complete", args.batch) return 0 if mode == "api": n = await _run_api_mode(db, remaining, args.batch, api_key or "") else: n = await _run_playwright_mode(db, remaining, args.batch) logger.info("done: processed=%d batch=%s mode=%s", n, args.batch, mode) return n finally: db.close() if __name__ == "__main__": # pragma: no cover asyncio.run(main())