From 04550bc94fd25ba980ad2fb43624bf0faef0ef6d Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 23 Jun 2026 14:33:10 +0500 Subject: [PATCH] fix(tradein): drop non-EKB avito detail coords on ingest (audit #1871 P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avito_detail.parse_detail_html писал data-map-lat/lon прямо в listings UPDATE COALESCE без bbox-валидации — в отличие от geocoder.py, который фильтрует везде. Итог: 406 active avito-листингов с координатами Питер/Тюмень/Уфа просочились в ЕКБ-датасет (ложные аналоги через Tier A address-match оценщика). Prod-verified 2026-06-23. - geocoder: DRY-хелпер is_within_ekb_bbox(lat, lon, bbox) + именованные EKB_BBOX_TIGHT (geocoder-фильтр, числа не изменены) и EKB_BBOX_WIDE (ingest-guard); 2 inline tight-проверки → хелпер (поведение identical). - avito_detail: после извлечения lat/lon → reset None + logger.warning если вне wide-bbox; листинг падает в geocode-cron путь (COALESCE NULL не затирает прежнее хорошее значение). - tests: bbox-хелпер (ЕКБ True, Питер/Тюмень/Уфа False, inclusive, WIDE⊇TIGHT инвариант) + parse_detail_html guard end-to-end. WIDE строго содержит TIGHT — guard физически не режет ничего, что geocoder сам бы пропустил. NULL lat (14935, geocode backlog) — отдельная проблема, не в scope. One-time cleanup существующих 406 — отдельно (database-expert, prod data mutation). Refs #1871 --- tradein-mvp/backend/app/services/geocoder.py | 36 +++++++- .../app/services/scrapers/avito_detail.py | 15 ++++ .../backend/tests/test_avito_detail_parse.py | 47 ++++++++++ .../backend/tests/test_geocoder_bbox.py | 88 +++++++++++++++++++ 4 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_geocoder_bbox.py 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""" + +
№ {item_id}
+ 9 500 000 ₽ +
+ + """ + + +def test_coord_guard_keeps_ekb_coords() -> None: + """ЕКБ-координаты (внутри wide-bbox) пишутся как есть → попадут в UPDATE (#1871).""" + html = _html_with_coords("8043000001", "56.797713", "60.609135") + result = parse_detail_html(html, "https://www.avito.ru/test_8043000001") + assert result.lat == 56.797713 + assert result.lon == 60.609135 + assert result.avito_location_id == 654070 + + +def test_coord_guard_drops_piter_coords() -> None: + """Питерские координаты (вне wide-bbox) → lat/lon сбрасываются в None (#1871). + + Это значение уходит в save_detail_enrichment UPDATE COALESCE(:lat, lat) → NULL, + то есть существующее значение в БД не перетирается мусором, листинг уходит + в geocode-cron путь. + """ + html = _html_with_coords("8043000002", "59.9386", "30.3141") # СПб + result = parse_detail_html(html, "https://www.avito.ru/test_8043000002") + assert result.lat is None + assert result.lon is None + # avito_location_id НЕ трогаем — фикс только на координаты + assert result.avito_location_id == 654070 + + +def test_coord_guard_no_map_element() -> None: + """Без map-элемента координат нет — guard не падает, lat/lon=None.""" + html = """ + +
№ 8043000003
+ 9 500 000 ₽ + + """ + result = parse_detail_html(html, "https://www.avito.ru/test_8043000003") + assert result.lat is None + assert result.lon is None + + def test_metro_extraction() -> None: result = parse_detail_html(MINIMAL_HTML, SOURCE_URL) # "Чкаловская 11-15 мин пешком" → metro_stations diff --git a/tradein-mvp/backend/tests/test_geocoder_bbox.py b/tradein-mvp/backend/tests/test_geocoder_bbox.py new file mode 100644 index 00000000..d161f051 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_bbox.py @@ -0,0 +1,88 @@ +"""Тесты для ЕКБ bbox-хелперов geocoder (#1871). + +Покрывают: +- is_within_ekb_bbox_wide — ingest-guard (avito_detail координаты); +- is_within_ekb_bbox — geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора); +- инвариант WIDE ⊇ TIGHT. +""" + +import pytest + +from app.services.geocoder import ( + EKB_BBOX_TIGHT, + EKB_BBOX_WIDE, + is_within_ekb_bbox, + is_within_ekb_bbox_wide, +) + +# Центр ЕКБ (Плотинка) — внутри обоих bbox. +EKB_CENTER = (56.838, 60.605) + + +@pytest.mark.parametrize( + "lat,lon,expected", + [ + (*EKB_CENTER, True), # центр ЕКБ + (56.797713, 60.609135, True), # реальная EKB-карточка из avito-fixture + (59.9386, 30.3141, False), # Питер (#1871) + (57.1530, 65.5343, False), # Тюмень (#1871) + (54.7388, 55.9721, False), # Уфа (#1871) + (57.0, 60.5, True), # приграничье севернее tight, но в wide (В.Пышма зона) + ], +) +def test_is_within_ekb_bbox_wide(lat: float, lon: float, expected: bool) -> None: + assert is_within_ekb_bbox_wide(lat, lon) is expected + + +@pytest.mark.parametrize( + "lat,lon", + [ + (56.6, 60.3), # юго-западный угол wide + (57.1, 60.9), # северо-восточный угол wide + (56.6, 60.9), # юго-восточный угол + (57.1, 60.3), # северо-западный угол + ], +) +def test_wide_bbox_boundary_inclusive(lat: float, lon: float) -> None: + """Границы wide-bbox включаются (inclusive).""" + assert is_within_ekb_bbox_wide(lat, lon) is True + + +def test_wide_bbox_just_outside() -> None: + """Чуть за границей wide-bbox → False.""" + assert is_within_ekb_bbox_wide(56.59, 60.605) is False # южнее + assert is_within_ekb_bbox_wide(57.11, 60.605) is False # севернее + assert is_within_ekb_bbox_wide(56.838, 60.29) is False # западнее + assert is_within_ekb_bbox_wide(56.838, 60.91) is False # восточнее + + +def test_tight_bbox_behavior_unchanged() -> None: + """geocoder tight-фильтр: те же числа, что и до DRY-рефактора (60.40-60.85 / 56.65-56.95).""" + # внутри + assert is_within_ekb_bbox(56.838, 60.605) is True + # границы tight inclusive + assert is_within_ekb_bbox(56.65, 60.40) is True + assert is_within_ekb_bbox(56.95, 60.85) is True + # чуть за tight → False (но в wide) + assert is_within_ekb_bbox(56.64, 60.605) is False + assert is_within_ekb_bbox(56.96, 60.605) is False + assert is_within_ekb_bbox(56.838, 60.39) is False + assert is_within_ekb_bbox(56.838, 60.86) is False + + +def test_wide_strictly_contains_tight() -> None: + """Инвариант: всё, что прошло бы tight, проходит и wide. + + Гарантирует, что ingest-guard не режет ничего, что geocoder сам бы пропустил. + """ + t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT + w_lat_min, w_lat_max, w_lon_min, w_lon_max = EKB_BBOX_WIDE + assert w_lat_min <= t_lat_min + assert w_lat_max >= t_lat_max + assert w_lon_min <= t_lon_min + assert w_lon_max >= t_lon_max + # точечная проверка на углах tight + for lat in (t_lat_min, t_lat_max): + for lon in (t_lon_min, t_lon_max): + assert is_within_ekb_bbox(lat, lon) is True + assert is_within_ekb_bbox_wide(lat, lon) is True