fix(tradein/scraper): не помечать городом развёртки объявления соседних городов #2626

Merged
lekss361 merged 1 commit from fix/tradein-city-stamp-geo-guard into main 2026-08-02 11:46:03 +00:00
5 changed files with 367 additions and 3 deletions

View file

@ -80,12 +80,20 @@ def _kit_matcher() -> MagicMock:
return matcher
def _lot(source: str = "avito", source_id: str = "1", address: str | None = None) -> KitLot:
def _lot(
source: str = "avito",
source_id: str = "1",
address: str | None = None,
lat: float | None = None,
lon: float | None = None,
) -> KitLot:
return KitLot(
source=source,
source_url=f"https://www.{source}.ru/item/{source_id}",
source_id=source_id,
address=address,
lat=lat,
lon=lon,
price_rub=3_000_000,
)
@ -197,6 +205,103 @@ def test_save_listings_reconcile_update_coalesces_city() -> None:
assert params["city"] == "Серов"
# ── Geo-guard: соседний-город-в-развёртке ──────────────────────────────────────
#
# Замер на проде (см. PR): yandex-развёртка city_slug="verkhnyaya_pyshma"
# (radius_m=25000 вокруг anchor'а В.Пышмы, ~15.3км от центра ЕКБ) проставляла
# "Верхняя Пышма" 97% найденного — большинство физически лежит в Екатеринбурге.
# save_listings(..., city_anchor=..., city_radius_km=...) режет city per-lot, если
# у лота ЕСТЬ координаты и они дальше city_radius_km от city_anchor.
_VP_ANCHOR = (56.976, 60.578) # CITY_ANCHORS["verkhnyaya_pyshma"][0][:2]
_VP_RADIUS_KM = 8.0 # get_city_stamp_radius_km("verkhnyaya_pyshma")
_EKB_CENTER = (56.8389, 60.6057) # ~15.34км от _VP_ANCHOR — вне guard-радиуса
def test_save_listings_geo_guard_drops_city_for_lot_outside_radius() -> None:
"""Лот с координатами ЕКБ в развёртке 'Верхняя Пышма' (guard 8км) — city НЕ
проставляется 'Верхняя Пышма' (дистанция ~15.3км > 8км)."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Победы, 30", lat=_EKB_CENTER[0], lon=_EKB_CENTER[1])
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(
db,
[lot],
matcher=_kit_matcher(),
region_code=66,
city="Верхняя Пышма",
city_anchor=_VP_ANCHOR,
city_radius_km=_VP_RADIUS_KM,
)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] is None, "лот физически в ЕКБ НЕ должен получить 'Верхняя Пышма'"
def test_save_listings_geo_guard_keeps_city_for_lot_inside_radius() -> None:
"""Лот с координатами самой В.Пышмы (anchor, dist=0) — city проставлен как обычно."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Кривоусова, 5", lat=_VP_ANCHOR[0], lon=_VP_ANCHOR[1])
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(
db,
[lot],
matcher=_kit_matcher(),
region_code=66,
city="Верхняя Пышма",
city_anchor=_VP_ANCHOR,
city_radius_km=_VP_RADIUS_KM,
)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] == "Верхняя Пышма"
def test_save_listings_geo_guard_stamps_city_for_lot_without_coords() -> None:
"""Лот БЕЗ координат (avito — большинство, напр. Серов 142/150) — нечем сверить
против anchor'а, поэтому city ВСЁ РАВНО проставляется (решение #2620): провайдер
уже скоупил SERP/API-запрос на этот город (city_slug/rgid/region_id), а без city
колонка теряет главную ценность именно для адресов без города в тексте."""
db = _mock_db_insert_path()
lot = _lot(address="ул. Ленина, 1", lat=None, lon=None)
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(
db,
[lot],
matcher=_kit_matcher(),
region_code=66,
city="Верхняя Пышма",
city_anchor=_VP_ANCHOR,
city_radius_km=_VP_RADIUS_KM,
)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] == "Верхняя Пышма"
def test_save_listings_geo_guard_inactive_ekaterinburg_sweep_unaffected() -> None:
"""ЕКБ-развёртка (city_anchor/city_radius_km не переданы, как в run_*_city_sweep
при city_slug=None) guard выключен, city проставляется независимо от координат
лота (даже координаты далёкого Серова не режутся старое поведение сохранено)."""
db = _mock_db_insert_path()
serov_coords = (59.604, 60.578)
lot = _lot(address="ул. Ленина, 1", lat=serov_coords[0], lon=serov_coords[1])
with patch("scraper_kit.base.upsert_listing_snapshot", return_value=None):
kit_save_listings(
db,
[lot],
matcher=_kit_matcher(),
region_code=66,
city="Екатеринбург",
)
_sql, params = _find_call(db, "INSERT INTO listings (")
assert params["city"] == "Екатеринбург"
# ── Migration 196: listings.city column ────────────────────────────────────────
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"

View file

@ -357,3 +357,51 @@ async def test_city_defaults_to_ekaterinburg_when_no_city_slug() -> None:
await _drive(scenario, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Екатеринбург"
# ── Гео-guard: соседний-город-в-развёртке — save_listings получает anchor+radius ──
#
# Замер на проде (см. PR): city_slug="verkhnyaya_pyshma" развёртка стамповала
# 'Верхняя Пышма' на лоты, физически лежащие в ЕКБ. save_listings режет city
# per-lot, если получит city_anchor/city_radius_km — оркестратор обязан их передать
# для oblast-города и НЕ передавать (None/None) для ЕКБ (нет большего соседа).
@pytest.mark.asyncio
async def test_avito_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None:
"""city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km
из pipeline.get_city_anchor_point/get_city_stamp_radius_km (НЕ None/None)."""
from scraper_kit.orchestration.pipeline import (
get_city_anchor_point,
get_city_stamp_radius_km,
)
scenario = _Scenario(
anchors=[(56.976, 60.578, "В.Пышма центр")],
per_anchor=[("lots", 3, 3, 0)],
city_slug="verkhnyaya_pyshma",
)
capture: dict[str, Any] = {}
await _drive(scenario, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma")
assert save_mock.call_args.kwargs["city_radius_km"] == get_city_stamp_radius_km(
"verkhnyaya_pyshma"
)
@pytest.mark.asyncio
async def test_avito_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None:
"""city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None
guard остаётся выключенным (нет города крупнее ЕКБ, ЕКБ-развёртка не должна
ломаться геопроверкой)."""
scenario = _Scenario(
anchors=[(56.84, 60.60, "A1")],
per_anchor=[("lots", 3, 3, 0)],
city_slug=None,
)
capture: dict[str, Any] = {}
await _drive(scenario, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city_anchor"] is None
assert save_mock.call_args.kwargs["city_radius_km"] is None

View file

@ -462,3 +462,68 @@ async def test_full_load_stamps_ekaterinburg(source: str) -> None:
assert save_mock.call_count > 0
for call in save_mock.call_args_list:
assert call.kwargs["city"] == "Екатеринбург"
# ── Гео-guard: соседний-город-в-развёртке — save_listings получает anchor+radius ──
#
# Замер на проде (см. PR): oblast city-sweep (yandex/cian) стамповал город-цель на
# лоты, физически лежащие в куда более крупном ЕКБ (yandex radius_m=25000 вокруг
# anchor'а В.Пышмы, ~15.3км от центра ЕКБ — захватывает почти весь город). Оркестратор
# обязан передать city_anchor/city_radius_km для oblast-города и НЕ передавать
# (None/None) для ЕКБ (нет большего соседа — guard там не нужен).
@pytest.mark.asyncio
async def test_yandex_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None:
"""city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km
из pipeline.get_city_anchor_point/get_city_stamp_radius_km."""
from scraper_kit.orchestration.pipeline import (
get_city_anchor_point,
get_city_stamp_radius_km,
)
capture: dict[str, Any] = {}
await _drive_yandex_city(city_slug="verkhnyaya_pyshma", capture=capture)
save_mock = capture["save_mock"]
call = save_mock.call_args_list[-1]
assert call.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma")
assert call.kwargs["city_radius_km"] == get_city_stamp_radius_km("verkhnyaya_pyshma")
@pytest.mark.asyncio
async def test_yandex_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None:
"""city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None —
ЕКБ-развёртка не ломается геопроверкой (нет города крупнее ЕКБ в регионе)."""
capture: dict[str, Any] = {}
await _drive_yandex_city(city_slug=None, capture=capture)
save_mock = capture["save_mock"]
call = save_mock.call_args_list[-1]
assert call.kwargs["city_anchor"] is None
assert call.kwargs["city_radius_km"] is None
@pytest.mark.asyncio
async def test_cian_city_sweep_passes_geo_guard_anchor_for_oblast_city() -> None:
"""city_slug='verkhnyaya_pyshma' → save_listings получает city_anchor/city_radius_km."""
from scraper_kit.orchestration.pipeline import (
get_city_anchor_point,
get_city_stamp_radius_km,
)
capture: dict[str, Any] = {}
await _drive_cian_city(city_slug="verkhnyaya_pyshma", capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city_anchor"] == get_city_anchor_point("verkhnyaya_pyshma")
assert save_mock.call_args.kwargs["city_radius_km"] == get_city_stamp_radius_km(
"verkhnyaya_pyshma"
)
@pytest.mark.asyncio
async def test_cian_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None:
"""city_slug=None (ЕКБ) → save_listings получает city_anchor=None/city_radius_km=None."""
capture: dict[str, Any] = {}
await _drive_cian_city(city_slug=None, capture=capture)
save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city_anchor"] is None
assert save_mock.call_args.kwargs["city_radius_km"] is None

View file

@ -25,6 +25,7 @@ from __future__ import annotations
import asyncio
import hashlib
import logging
import math
import random
from abc import ABC, abstractmethod
from datetime import date, datetime, timedelta
@ -294,6 +295,22 @@ class BaseScraper(ABC):
...
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Ортодромическое расстояние (км) между двумя точками (сферическая Земля).
Используется гео-guard'ом `save_listings(..., city_anchor=..., city_radius_km=...)`
(соседний-город-в-развёртке): сверяет физические координаты лота с anchor'ом
города-цели без ST_DWithin/PostGIS round-trip чистая математика, лот уже в
памяти (lat/lon Python float на ScrapedLot).
"""
r_earth_km = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
d_phi = math.radians(lat2 - lat1)
d_lmb = math.radians(lon2 - lon1)
a = math.sin(d_phi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(d_lmb / 2) ** 2
return 2 * r_earth_km * math.asin(math.sqrt(a))
# ── Запись пачки результатов в Postgres ─────────────────────────────────────
def save_listings(
db: Session,
@ -304,6 +321,8 @@ def save_listings(
run_id: int | None = None,
skip_seen_today: bool = False,
city: str | None = None,
city_anchor: tuple[float, float] | None = None,
city_radius_km: float | None = None,
) -> tuple[int, int]:
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
@ -333,6 +352,21 @@ def save_listings(
admin/manual пути) колонка остаётся NULL, backward-compatible.
ON CONFLICT COALESCE (новое значение НЕ затирает уже известный город
NULL'ом, если какой-то caller ещё не передаёт city).
city_anchor: (lat, lon) референсной точки города-цели этого batch'агео-guard
(соседний-город-в-развёртке, замер на проде: 97% лотов, помеченных
"Верхняя Пышма" из yandex-развёртки radius_m=25000, физически лежат в
Екатеринбурге anchor города-цели всего ~15км от центра ЕКБ, широкий
radius_m захватывает весь ЕКБ). Если задан ВМЕСТЕ с `city_radius_km` для
каждого лота С координатами (lot.lat/lot.lon НЕ None) считаем haversine-
расстояние до `city_anchor`; лот ДАЛЬШЕ `city_radius_km` НЕ получает `city`
этого batch'а (пишется NULL, а не угадывается чужой город). Лоты БЕЗ
координат city проставляется как обычно (нечем сверить; провайдер уже
скоупил SERP/API-запрос на этот город через city_slug/rgid/region_id см.
resolve_city_name в orchestration/pipeline.py). None/None (default,
backward-compatible) guard выключен, старое поведение (ОДИН city на
весь batch без проверки координат) так вызывается EKB-развёртка (нет
в регионе города КРУПНЕЕ ЕКБ, чей SERP мог бы её "поглотить").
city_radius_km: см. `city_anchor` оба параметра включают guard ТОЛЬКО вместе.
Returns:
(inserted, updated) counters для логов.
@ -346,14 +380,38 @@ def save_listings(
reconciled = 0 # UPDATE by (source,source_id) при dedup_hash-дрейфе
matched = 0
match_failures = 0
geo_guard_dropped = 0 # city NULL'ен из-за geo-guard (лот вне city_radius_km от anchor'а)
today_msk = datetime.now(_MSK).date()
_geo_guard_active = city is not None and city_anchor is not None and city_radius_km is not None
for lot in lots:
ppm2 = lot.price_per_m2 or lot.compute_price_per_m2()
dedup = lot.compute_dedup_hash()
card_hash = lot.compute_card_hash()
# ── Гео-guard: соседний-город-в-развёртке (см. docstring city_anchor) ──
# Лот С координатами дальше city_radius_km от anchor'аНЕ доверяем city
# этого batch'а (пишем NULL, не текущий-но-неверный город). Лоты БЕЗ координат
# проверить нечем — city проставляется как обычно (см. docstring).
lot_city = city
if _geo_guard_active and lot.lat is not None and lot.lon is not None:
assert city_anchor is not None and city_radius_km is not None # narrow for mypy
dist_km = _haversine_km(lot.lat, lot.lon, city_anchor[0], city_anchor[1])
if dist_km > city_radius_km:
lot_city = None
geo_guard_dropped += 1
logger.debug(
"save_listings:geo_guard_dropped source=%s dedup=%s dist_km=%.1f "
"> city_radius_km=%.1f (target_city=%s anchor=%s)",
lot.source,
dedup,
dist_km,
city_radius_km,
city,
city_anchor,
)
# Pre-read the existing row's card_hash and last_seen_at (keyed by
# dedup_hash) BEFORE the upsert — needed to know the *prior* card
# content and to implement skip_seen_today logic.
@ -389,7 +447,7 @@ def save_listings(
"dedup": dedup,
"region_code": region_code,
"address": lot.address,
"city": city,
"city": lot_city,
"lat": lot.lat,
"lon": lot.lon,
"rooms": lot.rooms,
@ -777,7 +835,7 @@ def save_listings(
db.commit()
logger.info(
"save_listings: source=%s inserted=%d updated=%d reconciled=%d "
"skipped_seen_today=%d matched=%d match_failures=%d (total %d)",
"skipped_seen_today=%d matched=%d match_failures=%d geo_guard_dropped=%d (total %d)",
lots[0].source if lots else "?",
inserted,
updated,
@ -785,6 +843,7 @@ def save_listings(
skipped,
matched,
match_failures,
geo_guard_dropped,
len(lots),
)
return inserted, updated

View file

@ -310,6 +310,29 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] |
return CITY_ANCHORS.get(city_slug)
def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
"""(lat, lon) референсной точки города-цели — для гео-guard'а `save_listings`.
Соседний-город-в-развёртке (замер на проде): oblast city-sweep стамповал СВОЙ
город-цель на 100% найденного, включая лоты, физически лежащие в куда более
крупном соседнем городе, случайно захваченные широким radius_m/loose SERP-city-
фильтром провайдера (напр. Верхняя Пышма ~15км от ЕКБ, yandex radius_m=25000
97% "verkhnyaya_pyshma"-развёртки на проде физически в ЕКБ). Берём ПЕРВЫЙ (и пока
единственный) anchor CITY_ANCHORS[city_slug] та же точка, что реально ходит в
scraper (fetch_around/fetch_around_multi_room), поэтому guard сверяется с
РЕАЛЬНЫМ центром запроса, а не с отдельно захардкоженными координатами.
None (EKB/неизвестный slug) гео-guard НЕ применяется у вызывающей стороны (см.
run_*_city_sweep): в регионе нет города КРУПНЕЕ ЕКБ, чей SERP мог бы её
"поглотить" симметричный риск для ЕКБ-развёртки отсутствует.
"""
anchors = get_city_anchors(city_slug)
if not anchors:
return None
lat, lon, _name = anchors[0]
return (lat, lon)
@dataclass(frozen=True)
class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
@ -378,6 +401,43 @@ def resolve_city_name(city_slug: str | None) -> str:
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
# которого save_listings НЕ доверяет city этого batch'а — см. save_listings docstring
# (scraper_kit.base) и замер на проде в PR. НЕ путать с radius_m поисковых запросов
# scraper'а (avito/cian 1500м, yandex 25000м вокруг того же anchor'а — ЭТО определяет
# ЧТО скачано; guard-радиус — какому city-batch'у из скачанного верить).
#
# Дефолт 15км — с запасом покрывает застройку города + ближние пригороды и остаётся
# НАМНОГО меньше дистанции до ЕКБ у 4 из 5 oblast-городов (Первоуральск ~41км,
# Каменск-Уральский ~93км, Нижний Тагил ~125км, Серов ~307км от центра ЕКБ) — guard
# у них никогда ложно не режет настоящие лоты, но всё ещё ловит редкие выбросы
# (напр. геокод-артефакты, см. PR: 3 avito-лота "Серов" физически в ЕКБ).
#
# Верхняя Пышма — особый случай: САМ anchor'а города лишь ~15.3км от центра ЕКБ
# (агломерации почти смыкаются) — дефолтный 15км-порог никогда бы не сработал (лот
# из ЕКБ остался бы формально "в радиусе" В.Пышмы). Уменьшенный порог 8км (~половина
# дистанции до ЕКБ, safety margin ~7.3км) всё ещё покрывает застройку самой
# В.Пышмы (компактный город, ~5км в поперечнике) и реально режет EKB-заброс (на
# проде — 97% "verkhnyaya_pyshma"-лотов из yandex-развёртки).
_DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
_CITY_STAMP_RADIUS_KM: dict[str, float] = {
"verkhnyaya_pyshma": 8.0,
}
def get_city_stamp_radius_km(city_slug: str | None) -> float:
"""Гео-guard радиус (км) для city_slug — см. `_CITY_STAMP_RADIUS_KM` выше.
None/неизвестный slug `_DEFAULT_CITY_STAMP_RADIUS_KM`. Вызывающая сторона
передаёт результат в `save_listings(..., city_radius_km=...)` ТОЛЬКО вместе с
`get_city_anchor_point(city_slug)` (при anchor=None guard всё равно выключен
см. save_listings docstring).
"""
if city_slug is None:
return _DEFAULT_CITY_STAMP_RADIUS_KM
return _CITY_STAMP_RADIUS_KM.get(city_slug, _DEFAULT_CITY_STAMP_RADIUS_KM)
_CHROME_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
@ -934,6 +994,13 @@ async def run_avito_city_sweep(
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
counters = CitySweepCounters(anchors_total=len(_anchors))
all_touched_house_ids: set[int] = set()
@ -1078,6 +1145,8 @@ async def run_avito_city_sweep(
matcher=matcher,
region_code=region_code,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += ins
counters.lots_updated += upd
@ -1833,6 +1902,13 @@ async def run_yandex_city_sweep(
_loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
_rooms_list = rooms_list or list(ROOM_PATH.keys())
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES
@ -1937,6 +2013,8 @@ async def run_yandex_city_sweep(
region_code=region_code,
run_id=run_id,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += ins
counters.lots_updated += upd
@ -2346,6 +2424,13 @@ async def run_cian_city_sweep(
_loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(city_slug)
# Гео-guard (соседний-город-в-развёртке): anchor=None у ЕКБ (city_slug=None) — guard
# выключен у save_listings (оба параметра обязаны быть not-None вместе), старое
# поведение. См. get_city_anchor_point docstring.
_city_anchor_point = get_city_anchor_point(city_slug)
_city_radius_km = (
get_city_stamp_radius_km(city_slug) if _city_anchor_point is not None else None
)
counters = CianCitySweepCounters(anchors_total=len(_anchors))
consecutive_failures = 0
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
@ -2447,6 +2532,8 @@ async def run_cian_city_sweep(
region_code=region_code,
run_id=run_id,
city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
)
counters.lots_inserted += inserted
counters.lots_updated += updated