Compare commits
No commits in common. "50b57788ee8948d47b9f136b14a589ffb86807d8" and "f13eac3b97d92e4b4e612145b124920929584d44" have entirely different histories.
50b57788ee
...
f13eac3b97
4 changed files with 2 additions and 184 deletions
|
|
@ -40,38 +40,6 @@ class GeocodeResult:
|
||||||
confidence: Literal["exact", "approximate", "locality"] = "approximate"
|
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 ───────────────────────────────────────────────────
|
# ── Address normalisation ───────────────────────────────────────────────────
|
||||||
def normalize_address(address: str) -> str:
|
def normalize_address(address: str) -> str:
|
||||||
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
||||||
|
|
@ -190,7 +158,7 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
try:
|
try:
|
||||||
lat_f = float(item["lat"])
|
lat_f = float(item["lat"])
|
||||||
lon_f = float(item["lon"])
|
lon_f = float(item["lon"])
|
||||||
if is_within_ekb_bbox(lat_f, lon_f):
|
if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95:
|
||||||
return item
|
return item
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
@ -276,7 +244,7 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
||||||
try:
|
try:
|
||||||
lon_str, lat_str = obj["Point"]["pos"].split()
|
lon_str, lat_str = obj["Point"]["pos"].split()
|
||||||
lat_f, lon_f = float(lat_str), float(lon_str)
|
lat_f, lon_f = float(lat_str), float(lon_str)
|
||||||
if is_within_ekb_bbox(lat_f, lon_f):
|
if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95:
|
||||||
best = obj
|
best = obj
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ from selectolax.parser import HTMLParser, Node
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
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 import _clean_address, _is_firewall_page
|
||||||
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
from app.services.scrapers.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
||||||
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
|
from app.services.scrapers.repair_state_normalizer import infer_repair_state_from_text
|
||||||
|
|
@ -447,20 +446,6 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
|
||||||
lon = _try_float(map_el.attributes.get("data-map-lon"))
|
lon = _try_float(map_el.attributes.get("data-map-lon"))
|
||||||
avito_location_id = _try_int(map_el.attributes.get("data-location-id"))
|
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']"))
|
address_full = _clean_address(_text(tree, "[itemprop='address']"))
|
||||||
|
|
||||||
# ── Description ─────────────────────────────────────────────────────────
|
# ── Description ─────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -97,53 +97,6 @@ def test_sale_type_assignment_pereustupka() -> None:
|
||||||
assert result.sale_type == "assignment"
|
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:
|
def test_metro_extraction() -> None:
|
||||||
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
result = parse_detail_html(MINIMAL_HTML, SOURCE_URL)
|
||||||
# "Чкаловская 11-15 мин пешком" → metro_stations
|
# "Чкаловская 11-15 мин пешком" → metro_stations
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
"""Тесты для ЕКБ 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