"""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, city) — 1 уникальная пара → 1 geocode call → UPDATE всех listings с этим address+city (#2594 шаг 2/3: listings.city теперь заполняется скрапером из контекста развёртки — один и тот же текст адреса в разных городах («ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) должен получать РАЗНЫЕ координаты, а не схлопываться в один geocode-вызов и один UPDATE по тексту адреса). Rate limit: Nominatim 1 req/sec (#2593: Yandex Geocoder tier удалён из geocoder). SELECT фильтрует `is_active` (#2604 п.1): на проде очередь была на 98.5% забита мёртвыми объявлениями чужих регионов (Новосибирск/Казань/Челябинск/…) без is_active — `ORDER BY listings_count DESC` ставил их В НАЧАЛО (у мусорного адреса вида «Новосибирская обл.,Новосибирск» — сотни listings, у реального адреса — 1-2), поэтому весь batch-бюджет (Nominatim 1 req/sec) съедался мусором и до настоящих адресов дело не доходило (8 ночных прогонов подряд: saved=0). UPDATE после успешного/неуспешного geocode НЕ фильтрует is_active — см. комментарии у соответствующих UPDATE ниже. Отличие от /admin/geocode-missing (per-ID): - Этот модуль группирует по (address, city) → меньше 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() → пара (address, city) не переотбирается в этом же 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, known_city_hint 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) budget_exhausted: bool = False # батч оборван дедлайном, а не разобран до конца async def geocode_missing_listings( db: Session, *, batch_size: int = 200, dry_run: bool = False, deadline_monotonic: float | None = None, ) -> GeocodeBackfillResult: """Geocode listings с NULL coords (любой source). Steps: 1. SELECT address, city FROM listings WHERE lat IS NULL AND is_active AND address IS NOT NULL GROUP BY address, city ORDER BY COUNT(*) DESC LIMIT batch_size (приоритет парам address+city с большим числом listings — больший ROI per geocode call; группировка по паре, НЕ только по address — #2594 шаг 2/3: один и тот же текст адреса в разных городах — разные записи. `is_active` — #2604 п.1: не тратим Nominatim-бюджет на мёртвые объявления, которые никогда не попадут в выдачу пользователю) 2. Для каждой пары (address, city): - geocode(address, db, city_hint=known_city_hint(city)) — auto-cache (hit или miss); хинт гейтится словарём городов области (#2603) - Если есть результат: UPDATE listings SET lat, lon WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL (IS NOT DISTINCT FROM, а не `=` — стандартная SQL NULL-семантика: `city = NULL` никогда не true, поэтому обычным `=` группа с city IS NULL не обновилась бы вообще ни для одной строки; `IS NOT DISTINCT FROM` трактует NULL=NULL как совпадение, оставаясь строгим при непустом city — нужная нам симметрия) - 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 deadline_monotonic: значение `time.monotonic()`, после которого батч обрывается на границе адреса (#3151). None — без дедлайна. Returns: GeocodeBackfillResult с counters. """ start = time.monotonic() result = GeocodeBackfillResult() # 1. Найти top-N пар (address, city) с NULL coords (DESC by occurrence count). # Группировка по паре, а не только по address (#2594 шаг 2/3) — один и тот же # текст адреса в разных городах (напр. «ул. Победы, 30» в ЕКБ и в Нижнем Тагиле) # это разные записи с разными координатами, их нельзя схлопывать в один # geocode-вызов. GROUP BY address, city трактует NULL city как отдельную # группу (стандартная SQL-семантика группировки NULL как равных друг другу). # Фильтруем пары, по которым геокодер уже пробовал и не нашёл — они помечены # geocode_tried_at. Повторяем попытку только если tried_at старше 7 дней (возможен # переезд адреса в кэше или смена провайдера), либо tried_at IS NULL (ещё не пробовали). # Это делает функцию loop-safe: при вызове несколько раз в одном прогоне # failed-пары не переотбираются бесконечно. # # AND is_active (#2604 п.1) — очередь без этого фильтра на 98.5% состояла из # is_active=false объявлений чужих регионов (Новосибирск/Казань/Челябинск/…), # а ORDER BY listings_count DESC ставил самый мусорный адрес («Новосибирская # обл.,Новосибирск», сотни listings) В НАЧАЛО — весь batch съедался мусором, # который пользователь никогда не увидит (is_active=false), 8 ночных прогонов # подряд saved=0. Активные объявления с валидным адресом почти всегда попадают # в topN только теперь, когда мусор не конкурирует за место в LIMIT. rows = ( db.execute( text( """ SELECT address, city, COUNT(*) AS listings_count FROM listings WHERE lat IS NULL AND is_active 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, city ORDER BY listings_count DESC, address ASC, city ASC NULLS FIRST 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): # Дедлайн проверяется НА КАЖДОМ адресе, а не только между батчами (#3151). # Раньше единственная проверка бюджета стояла в run-обёртке после возврата # из этой функции, то есть потолок прогона на деле был «бюджет + один полный # батч». Пока адрес стоил ~1.9 с это терялось в шуме; после общего # ограничителя темпа Nominatim (#2953) средняя цена 4.5 с, а на трудном # хвосте (tier-1 + до 4 typo-вариантов под паузой 1 с, плюс retry×3) — до # 33 с. Прогон 5017 (27.08) при `budget_sec=1800` шёл 3150 с. # Оборванный батч — штатный исход: `geocode_tried_at` проставлен только у # обработанных пар, необработанные попадут в выборку следующего прогона. if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic: result.budget_exhausted = True logger.info( "geocode_missing: дедлайн исчерпан на %d/%d адресе — обрываю батч", idx, len(rows), ) break address: str = row["address"] city: str | None = row.get("city") listings_count: int = row["listings_count"] result.addresses_processed += 1 try: # known_city_hint (#2603) — общий гейт по словарю городов области для # всех DB-колоночных callers. Для listings.city он сегодня no-op # (скрапер пишет только шесть кураторских имён из # scraper_kit CITY_DISPLAY_NAMES, все они есть в словаре), но держит # инвариант единым с deals-путями, где колонка росреестровая и в # хвосте лежит мусор. Сырой `city` ниже остаётся ключом группы для # UPDATE — гейт влияет только на подсказку геокодеру. geo = await geocode(address, db, city_hint=known_city_hint(city)) 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 чтобы пара (address, city) не переотбиралась # в следующих batch'ах этого же прогона (loop-safe backoff 7 дней). # IS NOT DISTINCT FROM — city=NULL это отдельная группа, обычное # `=` не поймает NULL-город и не должно задеть другой город с тем # же текстом адреса. # Намеренно БЕЗ `AND is_active` (#2604 п.2): tried_at — backoff-метка # для (address, city) КАК ТЕКСТА, а не для конкретного listing. # is_active=false дубликат этой пары и так никогда не будет выбран # SELECT'ом заново (is_active=false исключён там навсегда) — фильтр # здесь был бы no-op для неактивных строк. Единственный случай когда # это имеет значение — если строка позже реактивируется (is_active # → true): тогда tried_at уже стоит и backoff корректно защищает от # немедленного повторного запроса того же заведомо неудачного адреса. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" " WHERE address = :addr AND city IS NOT DISTINCT FROM :city" " AND lat IS NULL" ), {"addr": address, "city": city}, ) db.commit() continue if geo is None: result.addresses_failed += 1 logger.info( "geocode_missing: NOT FOUND '%s' city=%r (used in %d listings)", address[:60], city, listings_count, ) if not dry_run: # Пометить tried_at — geocoder не нашёл адрес, backoff 7 дней. # Намеренно БЕЗ `AND is_active` (#2604 п.2) — то же обоснование, что # и в except-ветке выше: backoff привязан к тексту (address, city), # не к конкретному listing, is_active=false строка и так не выбирается # SELECT'ом заново; при реактивации backoff корректно защитит от # немедленного повтора заведомо неудачного запроса. db.execute( text( "UPDATE listings SET geocode_tried_at = NOW()" " WHERE address = :addr AND city IS NOT DISTINCT FROM :city" " AND lat IS NULL" ), {"addr": address, "city": city}, ) db.commit() continue if geo.provider == "cache": result.cache_hits += 1 else: result.cache_misses += 1 result.addresses_geocoded += 1 if dry_run: # city в логе (#2603) — с #2594 это часть ключа группы: без него две # строки dry-run с одинаковым текстом адреса неотличимы друг от друга. logger.info( "geocode_missing[dry]: '%s' city=%r → (%.5f, %.5f) provider=%s " "would update %d listings", address[:60], city, 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 проставляются одновременно с координатами. # city IS NOT DISTINCT FROM :city — обновляем ТОЛЬКО пару (address, city), из # которой был geocode-запрос; иначе тот же текст адреса в другом городе # (city IS NULL или другой явный город) перезаписался бы чужими координатами. # # Намеренно БЕЗ `AND is_active` (#2604 п.1): координаты — свойство физического # адреса, а не свойство конкретного объявления. Если у этой же пары # (address, city) есть is_active=false дубликат с lat IS NULL, он получит те же # координаты бесплатно — Nominatim-вызов уже оплачен геокодом активного # листинга, доп. запроса не будет. SELECT выше и так навсегда исключает # is_active=false строки из очереди — без этого UPDATE такой дубликат остался # бы с NULL lat/lon НАВСЕГДА (переезд в EKB-only локальные реестры/analytics по # координатам сломан для него), хотя ответ уже есть в руках. Единственный # довод «за» фильтр — консистентность с SELECT — не перевешивает: это не # ошибка данных (координаты адреса объективны и не зависят от активности), # а чистый выигрыш (та же строка при реактивации уже готова, доп. cost = 0). update_result = db.execute( text( """ UPDATE listings SET lat = :lat, lon = :lon, geo_precision = :precision, geocode_tried_at = NOW() WHERE address = :addr AND city IS NOT DISTINCT FROM :city AND lat IS NULL """ ), { "lat": geo.lat, "lon": geo.lon, "precision": precision, "addr": address, "city": city, }, ) 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() deadline = start + budget_sec while True: res = await geocode_missing_listings( db, batch_size=batch_size, dry_run=False, deadline_monotonic=deadline ) # Аккумулируем 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.budget_exhausted: # Батч оборван дедлайном внутри себя (#3151) — проверять дренаж # по `addresses_total` уже нельзя: он считает ОТОБРАННЫЕ пары, а не # обработанные, и на оборванном батче ничего не говорит об очереди. logger.info( "run_geocode_missing_listings: run_id=%d — бюджет %.0fs исчерпан " "внутри батча (elapsed=%.1fs), завершаем", run_id, budget_sec, elapsed, ) break if res.addresses_total == 0: logger.info( "run_geocode_missing_listings: run_id=%d — нет pending адресов, завершаем", run_id, ) break if res.addresses_total < batch_size: # #2604 п.3: с is_active-фильтром в SELECT очередь резко уже (была # 14294 строк/98.5% мёртвых, стало ~220 активных → десятки уникальных # пар address+city после GROUP BY) — этот дренаж почти всегда сработает # уже на первой итерации (addresses_total < default batch_size=200), и # это ПРАВИЛЬНОЕ поведение: разгребли всё что было, ждём следующего # прогона. Никакого деления тут нет (только сравнение int), пустая # очередь (addresses_total=0) ловится веткой выше, а не этой. 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