From 7c9319d3b9d36b26c569e8ff9e480db70d2b917c Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 2 Aug 2026 11:46:02 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/scraper):=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=D0=B5=D1=87=D0=B0=D1=82=D1=8C=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=BC=20=D1=80=D0=B0=D0=B7=D0=B2=D1=91=D1=80?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE=D1=81=D0=B5=D0=B4=D0=BD=D0=B8?= =?UTF-8?q?=D1=85=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4=D0=BE=D0=B2=20(#2626)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_listings_city_from_sweep.py | 107 +++++++++++++++++- .../tests/test_scraper_kit_pipeline_parity.py | 48 ++++++++ .../test_scraper_kit_pipeline_parity2.py | 65 +++++++++++ .../scraper-kit/src/scraper_kit/base.py | 63 ++++++++++- .../src/scraper_kit/orchestration/pipeline.py | 87 ++++++++++++++ 5 files changed, 367 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/tests/test_listings_city_from_sweep.py b/tradein-mvp/backend/tests/test_listings_city_from_sweep.py index e00160f1..1cad0855 100644 --- a/tradein-mvp/backend/tests/test_listings_city_from_sweep.py +++ b/tradein-mvp/backend/tests/test_listings_city_from_sweep.py @@ -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" diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py index a2973834..262af3dd 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index a5d54834..eb05dc07 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index 9af5f933..dbab78d8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 89967ce4..beb03b03 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -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