fix(tradein/scraper): не помечать городом развёртки объявления соседних городов #2626
5 changed files with 367 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue