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
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:
parent
4b3d8b4cca
commit
7c9319d3b9
5 changed files with 367 additions and 3 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue