Merge pull request 'fix(tradein): drop non-EKB avito detail coords on ingest (audit #1871 P2)' (#1879) from audit/1871-p2-avito-coord into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / test (push) Successful in 1m22s
All checks were successful
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 52s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / test (push) Successful in 1m22s
This commit is contained in:
commit
50b57788ee
4 changed files with 184 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
88
tradein-mvp/backend/tests/test_geocoder_bbox.py
Normal file
88
tradein-mvp/backend/tests/test_geocoder_bbox.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue