All checks were successful
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Add geocode_tried_at = NOW() to the success UPDATE SET clause so successfully-geocoded listings record their attempt time, matching the per-ID admin endpoint contract and the column spec.
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""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
|