"""Batch geocoding для listings с NULL lat/lon. Запускается: - Manual через POST /admin/scrape/geocode-missing-listings - Scheduled: nightly via scrape_schedules (source='geocode_missing_listings', migration 110) — wired into in-app scheduler, window 06:00-09:00 UTC. Pattern: dedup по address (1 unique address → 1 geocode call → UPDATE all listings). Rate limit: Nominatim 1 req/sec. Yandex 25K/day если YANDEX_GEOCODER_API_KEY set. Отличие от /admin/geocode-missing (per-ID): - Этот модуль группирует по address → меньше API calls (dedup). - Поддерживает all sources включая Avito (после PR #487 убрали jitter). - Возвращает GeocodeBackfillResult с детальными counters. - Loop-safe: SELECT фильтрует geocode_tried_at IS NULL OR tried_at < 7 days; при geocode failure помечает tried_at=NOW() → адрес не переотбирается в этом же run. """ from __future__ import annotations import logging import time from dataclasses import dataclass, field from sqlalchemy import text from sqlalchemy.orm import Session from app.services import scrape_runs as runs_mod from app.services.estimator import _geocode_is_coarse from app.services.geocoder import geocode logger = logging.getLogger(__name__) @dataclass class GeocodeBackfillResult: addresses_total: int = 0 # unique addresses pending geocode в этом batch addresses_processed: int = 0 # фактически обработано addresses_geocoded: int = 0 # успешно получили coords addresses_failed: int = 0 # geocoder вернул None listings_updated: int = 0 # total listings затронуто (1 address → N listings) cache_hits: int = 0 # из geocode_cache (instant) cache_misses: int = 0 # реальные geocoder calls duration_sec: float = field(default=0.0) async def geocode_missing_listings( db: Session, *, batch_size: int = 200, dry_run: bool = False, ) -> GeocodeBackfillResult: """Geocode listings с NULL coords (любой source). Steps: 1. SELECT DISTINCT address FROM listings WHERE lat IS NULL AND address IS NOT NULL GROUP BY address ORDER BY COUNT(*) DESC LIMIT batch_size (приоритет адресам с большим числом listings — больший ROI per geocode call) 2. Для каждого address: - geocode(address, db) — auto-cache (hit или miss) - Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND lat IS NULL - PostGIS trigger (listings_set_geom_trg) автоматически обновит geom 3. Log progress каждые 50 addresses. Args: batch_size: max addresses to process per call (default 200 ≈ 3.5 min Nominatim) dry_run: только показать что бы сделалось, без UPDATE Returns: GeocodeBackfillResult с counters. """ start = time.monotonic() result = GeocodeBackfillResult() # 1. Найти top-N адресов с NULL coords (DESC by occurrence count). # Фильтруем адреса, по которым геокодер уже пробовал и не нашёл — они помечены # geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен # переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали). # Это делает функцию loop-safe: при вызове несколько раз в одном прогоне # failed-адреса не переотбираются бесконечно. rows = ( db.execute( text( """ SELECT address, COUNT(*) AS listings_count FROM listings WHERE lat IS NULL AND address IS NOT NULL AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR geocode_tried_at < NOW() - INTERVAL '7 days') GROUP BY address ORDER BY listings_count DESC, address ASC LIMIT :limit """ ), {"limit": batch_size}, ) .mappings() .all() ) result.addresses_total = len(rows) if not rows: logger.info("geocode_missing: 0 pending addresses — nothing to do") result.duration_sec = time.monotonic() - start return result logger.info( "geocode_missing: starting batch=%d total_pending_addresses=%d (top by listings count)", batch_size, result.addresses_total, ) for idx, row in enumerate(rows): address: str = row["address"] listings_count: int = row["listings_count"] result.addresses_processed += 1 try: geo = await geocode(address, db) except Exception as exc: logger.warning("geocode_missing: geocode raised for '%s': %s", address[:60], exc) result.addresses_failed += 1 if not dry_run: # Пометить tried_at чтобы адрес не переотбирался в следующих batch'ах # этого же прогона (loop-safe backoff 7 дней). db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" " WHERE address = :addr AND lat IS NULL" ), {"addr": address}, ) db.commit() continue if geo is None: result.addresses_failed += 1 logger.info( "geocode_missing: NOT FOUND '%s' (used in %d listings)", address[:60], listings_count, ) if not dry_run: # Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" " WHERE address = :addr AND lat IS NULL" ), {"addr": address}, ) db.commit() continue if geo.provider == "cache": result.cache_hits += 1 else: result.cache_misses += 1 result.addresses_geocoded += 1 if dry_run: logger.info( "geocode_missing[dry]: '%s' → (%.5f, %.5f) provider=%s would update %d listings", address[:60], geo.lat, geo.lon, geo.provider, listings_count, ) continue # Определяем точность геокода: city-centroid (нет номера дома) → 'city'. # _geocode_is_coarse() проверяет confidence='locality' ИЛИ отсутствие # house-number токена (1-3 цифры) в full_address — оба случая означают # что геокодер не дошёл до дома и вернул центр НП/города (#769 Part E). precision: str | None = "city" if _geocode_is_coarse(geo) else None # UPDATE listings — PostGIS trigger (listings_set_geom_trg) обновит geom автоматически. # geo_precision и geocode_tried_at проставляются одновременно с координатами. update_result = db.execute( text( """ UPDATE listings SET lat = :lat, lon = :lon, geo_precision = :precision, geocode_tried_at = NOW() WHERE address = :addr AND lat IS NULL """ ), {"lat": geo.lat, "lon": geo.lon, "precision": precision, "addr": address}, ) db.commit() result.listings_updated += update_result.rowcount if (idx + 1) % 50 == 0: elapsed = time.monotonic() - start rate = (idx + 1) / elapsed if elapsed > 0 else 0 logger.info( "geocode_missing: progress %d/%d " "(geocoded=%d failed=%d cache_hits=%d listings_updated=%d) rate=%.1f addr/s", idx + 1, len(rows), result.addresses_geocoded, result.addresses_failed, result.cache_hits, result.listings_updated, rate, ) result.duration_sec = time.monotonic() - start logger.info( "geocode_missing: DONE batch=%d processed=%d geocoded=%d failed=%d " "cache=(hit=%d miss=%d) listings_updated=%d duration=%.1fs", batch_size, result.addresses_processed, result.addresses_geocoded, result.addresses_failed, result.cache_hits, result.cache_misses, result.listings_updated, result.duration_sec, ) return result async def run_geocode_missing_listings( db: Session, *, run_id: int, params: dict, ) -> GeocodeBackfillResult: """Run-lifecycle wrapper: batch loop с wall-clock budget. Запускается планировщиком (source='geocode_missing_listings') или вручную. Гоняет geocode_missing_listings() в цикле до тех пор пока: - res.addresses_total == 0 (нет pending адресов) - res.addresses_total < batch_size (дренаж — последний batch меньше полного) - истёк budget_sec Params (из default_params jsonb в scrape_schedules): batch_size: int — адресов за один вызов geocode_missing_listings (default 200). budget_sec: float — максимальное время прогона в секундах (default 1800 = 30 мин). Lifecycle: update_heartbeat перед loop → аккумуляция counters → mark_done / mark_failed. """ batch_size = int(params.get("batch_size", 200)) budget_sec = float(params.get("budget_sec", 1800)) total = GeocodeBackfillResult() counters: dict[str, int] = { "checked": 0, "saved": 0, "skipped": 0, } try: runs_mod.update_heartbeat(db, run_id, counters) start = time.monotonic() while True: res = await geocode_missing_listings(db, batch_size=batch_size, dry_run=False) # Аккумулируем counters total.addresses_total += res.addresses_total total.addresses_processed += res.addresses_processed total.addresses_geocoded += res.addresses_geocoded total.addresses_failed += res.addresses_failed total.listings_updated += res.listings_updated total.cache_hits += res.cache_hits total.cache_misses += res.cache_misses counters = { "checked": total.addresses_processed, "saved": total.listings_updated, "skipped": total.addresses_failed, } runs_mod.update_heartbeat(db, run_id, counters) elapsed = time.monotonic() - start if res.addresses_total == 0: logger.info( "run_geocode_missing_listings: run_id=%d — нет pending адресов, завершаем", run_id, ) break if res.addresses_total < batch_size: logger.info( "run_geocode_missing_listings: run_id=%d — дренаж " "(addresses_total=%d < batch_size=%d), завершаем", run_id, res.addresses_total, batch_size, ) break if elapsed > budget_sec: logger.info( "run_geocode_missing_listings: run_id=%d — бюджет %.0fs исчерпан " "(elapsed=%.1fs), завершаем", run_id, budget_sec, elapsed, ) break total.duration_sec = time.monotonic() - start runs_mod.mark_done(db, run_id, counters) logger.info( "run_geocode_missing_listings: run_id=%d DONE — " "processed=%d geocoded=%d failed=%d listings_updated=%d duration=%.1fs", run_id, total.addresses_processed, total.addresses_geocoded, total.addresses_failed, total.listings_updated, total.duration_sec, ) return total except Exception as exc: total.duration_sec = time.monotonic() - start logger.exception( "run_geocode_missing_listings: run_id=%d FAILED after %.1fs", run_id, total.duration_sec, ) runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) raise