fix(tradein): drop non-EKB avito detail coords on ingest (audit #1871 P2)
All checks were successful
CI / changes (pull_request) Successful in 5s
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

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
This commit is contained in:
Light1YT 2026-06-23 14:33:10 +05:00
parent f13eac3b97
commit 04550bc94f
4 changed files with 184 additions and 2 deletions

View file

@ -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:

View file

@ -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 ─────────────────────────────────────────────────────────

View file

@ -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"""
<html><body>
<div data-marker="item-view/item-id"> {item_id}</div>
<span itemprop="price" content="9500000">9 500 000 </span>
<div data-marker="item-map-wrapper" data-map-lat="{lat}" data-map-lon="{lon}" data-location-id="654070"></div>
</body></html>
"""
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 = """
<html><body>
<div data-marker="item-view/item-id"> 8043000003</div>
<span itemprop="price" content="9500000">9 500 000 </span>
</body></html>
"""
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

View file

@ -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