diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 42eaf764..984b9d2e 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -40,6 +40,38 @@ class GeocodeResult: confidence: Literal["exact", "approximate", "locality"] = "approximate" +# ── EKB bounding boxes ─────────────────────────────────────────────────────── +# Два bbox с разным назначением: +# * TIGHT — для geocoder-фильтрации результатов Yandex/Nominatim при опечатках +# (не вернуть Челябинск/Пермь как «совпадение» по фуззи-матчу). Узкий по дизайну. +# * WIDE — для ingest-guard на координаты, ПРИШЕДШИЕ ИЗВНЕ (avito_detail +# data-map-lat/lon). Чуть шире, чтобы не резать легитимное приграничье ЕКБ +# (Верхняя Пышма / Среднеуральск / Берёзовский), при этом отсекая +# Питер/Тюмень/Уфу (#1871). WIDE строго содержит TIGHT, поэтому всё, что +# прошло бы tight-фильтр, проходит и wide-guard. +# bbox = (lat_min, lat_max, lon_min, lon_max). +EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85) +EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9) + + +def is_within_ekb_bbox( + lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT +) -> bool: + """True если (lat, lon) внутри bbox (inclusive). bbox = (lat_min, lat_max, lon_min, lon_max).""" + lat_min, lat_max, lon_min, lon_max = bbox + return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max + + +def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool: + """Ingest-guard: True если координаты в широком ЕКБ-bbox (#1871). + + Используется для валидации координат из avito detail-страниц перед записью в БД. + Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ + (Питер/Тюмень/Уфа). + """ + return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE) + + # ── Address normalisation ─────────────────────────────────────────────────── def normalize_address(address: str) -> str: """Нормализация для cache lookup: lowercase + trim + collapse whitespace. @@ -158,7 +190,7 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No try: lat_f = float(item["lat"]) lon_f = float(item["lon"]) - if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95: + if is_within_ekb_bbox(lat_f, lon_f): return item except Exception: continue @@ -244,7 +276,7 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None: try: lon_str, lat_str = obj["Point"]["pos"].split() lat_f, lon_f = float(lat_str), float(lon_str) - if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95: + if is_within_ekb_bbox(lat_f, lon_f): best = obj break except Exception: diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py index f502c50a..24192abc 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py @@ -33,6 +33,7 @@ from selectolax.parser import HTMLParser, Node from sqlalchemy import text from sqlalchemy.orm import Session +from app.services.geocoder import is_within_ekb_bbox_wide from app.services.scrapers.avito import _clean_address, _is_firewall_page from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text @@ -446,6 +447,20 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment: lon = _try_float(map_el.attributes.get("data-map-lon")) avito_location_id = _try_int(map_el.attributes.get("data-location-id")) + # bbox-guard (#1871): detail-страница иногда отдаёт координаты вне ЕКБ + # (Питер/Тюмень/Уфа — кросс-региональные дубли объявлений). Эти координаты + # попадают в UPDATE COALESCE без bbox-валидации (в отличие от geocoder.py, + # где bbox-фильтр везде). Сбрасываем в None → листинг уходит в geocode-cron + # путь, который сам отфильтрует по адресу. SERP-ingest координат не даёт вовсе. + if lat is not None and lon is not None and not is_within_ekb_bbox_wide(lat, lon): + logger.warning( + "avito_detail #1871: dropped non-EKB coords item=%s lat=%s lon=%s", + item_id, + lat, + lon, + ) + lat = lon = None + address_full = _clean_address(_text(tree, "[itemprop='address']")) # ── Description ───────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_avito_detail_parse.py b/tradein-mvp/backend/tests/test_avito_detail_parse.py index 7a2f6b0e..1e4ca0ca 100644 --- a/tradein-mvp/backend/tests/test_avito_detail_parse.py +++ b/tradein-mvp/backend/tests/test_avito_detail_parse.py @@ -97,6 +97,53 @@ def test_sale_type_assignment_pereustupka() -> None: assert result.sale_type == "assignment" +def _html_with_coords(item_id: str, lat: str, lon: str) -> str: + return f""" +
+