fix(tradein/scraper): не помечать городом развёртки объявления соседних городов (#2626)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 1m38s

This commit is contained in:
lekss361 2026-08-02 11:46:02 +00:00
parent 4b3d8b4cca
commit 7c9319d3b9
5 changed files with 367 additions and 3 deletions

View file

@ -80,12 +80,20 @@ def _kit_matcher() -> MagicMock:
return matcher 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( return KitLot(
source=source, source=source,
source_url=f"https://www.{source}.ru/item/{source_id}", source_url=f"https://www.{source}.ru/item/{source_id}",
source_id=source_id, source_id=source_id,
address=address, address=address,
lat=lat,
lon=lon,
price_rub=3_000_000, price_rub=3_000_000,
) )
@ -197,6 +205,103 @@ def test_save_listings_reconcile_update_coalesces_city() -> None:
assert params["city"] == "Серов" 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 ──────────────────────────────────────── # ── Migration 196: listings.city column ────────────────────────────────────────
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _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) await _drive(scenario, capture=capture)
save_mock = capture["save_mock"] save_mock = capture["save_mock"]
assert save_mock.call_args.kwargs["city"] == "Екатеринбург" 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 assert save_mock.call_count > 0
for call in save_mock.call_args_list: for call in save_mock.call_args_list:
assert call.kwargs["city"] == "Екатеринбург" 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 asyncio
import hashlib import hashlib
import logging import logging
import math
import random import random
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import date, datetime, timedelta 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 ───────────────────────────────────── # ── Запись пачки результатов в Postgres ─────────────────────────────────────
def save_listings( def save_listings(
db: Session, db: Session,
@ -304,6 +321,8 @@ def save_listings(
run_id: int | None = None, run_id: int | None = None,
skip_seen_today: bool = False, skip_seen_today: bool = False,
city: str | None = None, city: str | None = None,
city_anchor: tuple[float, float] | None = None,
city_radius_km: float | None = None,
) -> tuple[int, int]: ) -> tuple[int, int]:
"""Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash. """Пишем list[ScrapedLot] в `listings` с upsert по dedup_hash.
@ -333,6 +352,21 @@ def save_listings(
admin/manual пути) колонка остаётся NULL, backward-compatible. admin/manual пути) колонка остаётся NULL, backward-compatible.
ON CONFLICT COALESCE (новое значение НЕ затирает уже известный город ON CONFLICT COALESCE (новое значение НЕ затирает уже известный город
NULL'ом, если какой-то caller ещё не передаёт city). 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: Returns:
(inserted, updated) counters для логов. (inserted, updated) counters для логов.
@ -346,14 +380,38 @@ def save_listings(
reconciled = 0 # UPDATE by (source,source_id) при dedup_hash-дрейфе reconciled = 0 # UPDATE by (source,source_id) при dedup_hash-дрейфе
matched = 0 matched = 0
match_failures = 0 match_failures = 0
geo_guard_dropped = 0 # city NULL'ен из-за geo-guard (лот вне city_radius_km от anchor'а)
today_msk = datetime.now(_MSK).date() 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: for lot in lots:
ppm2 = lot.price_per_m2 or lot.compute_price_per_m2() ppm2 = lot.price_per_m2 or lot.compute_price_per_m2()
dedup = lot.compute_dedup_hash() dedup = lot.compute_dedup_hash()
card_hash = lot.compute_card_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 # 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 # dedup_hash) BEFORE the upsert — needed to know the *prior* card
# content and to implement skip_seen_today logic. # content and to implement skip_seen_today logic.
@ -389,7 +447,7 @@ def save_listings(
"dedup": dedup, "dedup": dedup,
"region_code": region_code, "region_code": region_code,
"address": lot.address, "address": lot.address,
"city": city, "city": lot_city,
"lat": lot.lat, "lat": lot.lat,
"lon": lot.lon, "lon": lot.lon,
"rooms": lot.rooms, "rooms": lot.rooms,
@ -777,7 +835,7 @@ def save_listings(
db.commit() db.commit()
logger.info( logger.info(
"save_listings: source=%s inserted=%d updated=%d reconciled=%d " "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 "?", lots[0].source if lots else "?",
inserted, inserted,
updated, updated,
@ -785,6 +843,7 @@ def save_listings(
skipped, skipped,
matched, matched,
match_failures, match_failures,
geo_guard_dropped,
len(lots), len(lots),
) )
return inserted, updated 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) 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) @dataclass(frozen=True)
class CityLocation: class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout). """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) 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 = { _CHROME_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "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", "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'ы # #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла. # одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(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)) counters = CitySweepCounters(anchors_total=len(_anchors))
all_touched_house_ids: set[int] = set() all_touched_house_ids: set[int] = set()
@ -1078,6 +1145,8 @@ async def run_avito_city_sweep(
matcher=matcher, matcher=matcher,
region_code=region_code, region_code=region_code,
city=_city_name, city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
) )
counters.lots_inserted += ins counters.lots_inserted += ins
counters.lots_updated += upd counters.lots_updated += upd
@ -1833,6 +1902,13 @@ async def run_yandex_city_sweep(
_loc = get_city_location(city_slug) _loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep. # #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(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
)
_rooms_list = rooms_list or list(ROOM_PATH.keys()) _rooms_list = rooms_list or list(ROOM_PATH.keys())
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES _price_ranges = price_ranges or DEFAULT_PRICE_RANGES
@ -1937,6 +2013,8 @@ async def run_yandex_city_sweep(
region_code=region_code, region_code=region_code,
run_id=run_id, run_id=run_id,
city=_city_name, city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
) )
counters.lots_inserted += ins counters.lots_inserted += ins
counters.lots_updated += upd counters.lots_updated += upd
@ -2346,6 +2424,13 @@ async def run_cian_city_sweep(
_loc = get_city_location(city_slug) _loc = get_city_location(city_slug)
# #2594: город для save_listings(..., city=...) — один на весь sweep. # #2594: город для save_listings(..., city=...) — один на весь sweep.
_city_name = resolve_city_name(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 = CianCitySweepCounters(anchors_total=len(_anchors)) counters = CianCitySweepCounters(anchors_total=len(_anchors))
consecutive_failures = 0 consecutive_failures = 0
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
@ -2447,6 +2532,8 @@ async def run_cian_city_sweep(
region_code=region_code, region_code=region_code,
run_id=run_id, run_id=run_id,
city=_city_name, city=_city_name,
city_anchor=_city_anchor_point,
city_radius_km=_city_radius_km,
) )
counters.lots_inserted += inserted counters.lots_inserted += inserted
counters.lots_updated += updated counters.lots_updated += updated