Черновик регулярного сбора по Москве и области: сетка якорей, ID площадок, гео-профили ДомКлика #3533
15 changed files with 1560 additions and 103 deletions
|
|
@ -54,7 +54,13 @@ def test_resolve_city_name_known_oblast_slugs() -> None:
|
|||
"verkhnyaya_pyshma": "Верхняя Пышма",
|
||||
"serov": "Серов",
|
||||
}
|
||||
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS)
|
||||
# msk-rollout: многогородние скоупы (MULTI_CITY_SLUGS — область и
|
||||
# «Москва+область») намеренно БЕЗ display-имени: в их batch'ах городов
|
||||
# несколько, и любой один штамп city был бы ложью — resolve_city_name
|
||||
# отдаёт для них None, а save_listings пишет city=NULL.
|
||||
from scraper_kit.orchestration.pipeline import MULTI_CITY_SLUGS
|
||||
|
||||
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) - MULTI_CITY_SLUGS
|
||||
assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
|
||||
for slug, name in wave1_expected.items():
|
||||
assert resolve_city_name(slug) == name
|
||||
|
|
@ -76,11 +82,19 @@ def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None:
|
|||
assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
|
||||
|
||||
|
||||
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
|
||||
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors."""
|
||||
def test_resolve_city_name_unknown_slug_raises() -> None:
|
||||
"""Неизвестный slug ПАДАЕТ (msk-rollout), а не штампует молча «Екатеринбург».
|
||||
|
||||
Раньше здесь был ЕКБ-дефолт — симметрично get_city_location/get_city_anchors.
|
||||
Оба они теперь тоже падают: с приходом Москвы тихий откат на ЕКБ означал бы,
|
||||
что развёртка с опечаткой в слаге собирает Екатеринбург под чужим именем города,
|
||||
со штатными счётчиками и без единой аномалии в логах.
|
||||
"""
|
||||
import pytest
|
||||
from scraper_kit.orchestration.pipeline import resolve_city_name
|
||||
|
||||
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
|
||||
with pytest.raises(ValueError, match="nonexistent_city"):
|
||||
resolve_city_name("nonexistent_city")
|
||||
|
||||
|
||||
# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный
|
||||
|
|
|
|||
115
tradein-mvp/backend/tests/test_domclick_geo_profile.py
Normal file
115
tradein-mvp/backend/tests/test_domclick_geo_profile.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Гео-скоуп DomClick — параметр региона, а не зашитый Екатеринбург.
|
||||
|
||||
До этого GUID/aids/страница прогрева/bbox жили модульными константами `_EKB_*`,
|
||||
и прогон с region_code 77/50 собрал бы ЕКБ под чужим регионом. Тесты стерегут
|
||||
две вещи: профиль 66 не изменил НИ БАЙТА в запросах (прод-поведение прежнее),
|
||||
а профили Москвы и области действительно меняют скоуп — без aids и без сверки
|
||||
имени региона, которое вне ЕКБ ненадёжно.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from scraper_kit.providers.domclick.serp import (
|
||||
DOMCLICK_GEO_PROFILES,
|
||||
DomClickScraper,
|
||||
_build_count_url,
|
||||
_build_offers_url,
|
||||
geo_profile_for_region,
|
||||
)
|
||||
|
||||
# Литералы снятые с кода ДО параметризации: любое расхождение = смена прод-запроса.
|
||||
_EKB_OFFERS_URL_BEFORE = (
|
||||
"https://bff-search-web.domclick.ru/api/offers/v1"
|
||||
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
|
||||
"&deal_type=sale&category=living&offer_type=flat&rooms=5%2B"
|
||||
"&sort=qi&sort_dir=desc&offset=40&limit=20"
|
||||
"&sale_price__gte=1000000&sale_price__lte=5000000"
|
||||
)
|
||||
_EKB_COUNT_URL_BEFORE = (
|
||||
"https://bff-search-web.domclick.ru/api/offers/count/v1"
|
||||
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
|
||||
"&deal_type=sale&category=living&offer_type=flat&rooms=2"
|
||||
"&sort=qi&sort_dir=desc"
|
||||
)
|
||||
|
||||
|
||||
def _url_for(region_code: int, *, count: bool) -> str:
|
||||
profile = geo_profile_for_region(region_code)
|
||||
if count:
|
||||
return _build_count_url(
|
||||
"2", None, None, address_guid=profile.address_guid, aids=profile.aids
|
||||
)
|
||||
return _build_offers_url(
|
||||
"5+",
|
||||
1_000_000,
|
||||
5_000_000,
|
||||
40,
|
||||
address_guid=profile.address_guid,
|
||||
aids=profile.aids,
|
||||
)
|
||||
|
||||
|
||||
def test_ekb_offers_url_unchanged_byte_for_byte() -> None:
|
||||
assert _url_for(66, count=False) == _EKB_OFFERS_URL_BEFORE
|
||||
|
||||
|
||||
def test_ekb_count_url_unchanged_byte_for_byte() -> None:
|
||||
assert _url_for(66, count=True) == _EKB_COUNT_URL_BEFORE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("region_code", [77, 50])
|
||||
@pytest.mark.parametrize("count", [False, True])
|
||||
def test_msk_and_oblast_urls_carry_no_aids(region_code: int, count: bool) -> None:
|
||||
"""aids сужает выдачу до агрегатора; у Москвы и области счётчик без него
|
||||
совпадает с сайтом (замер 12.09.2026), поэтому параметра быть не должно."""
|
||||
url = _url_for(region_code, count=count)
|
||||
assert "aids=" not in url
|
||||
assert f"address={geo_profile_for_region(region_code).address_guid}" in url
|
||||
|
||||
|
||||
def test_profiles_are_distinct_and_ekb_keeps_aids() -> None:
|
||||
guids = {code: p.address_guid for code, p in DOMCLICK_GEO_PROFILES.items()}
|
||||
assert len(set(guids.values())) == len(guids)
|
||||
assert DOMCLICK_GEO_PROFILES[66].aids == "20561"
|
||||
# Имя региона сверяется ТОЛЬКО у ЕКБ.
|
||||
assert DOMCLICK_GEO_PROFILES[66].region_name == "Екатеринбург"
|
||||
assert DOMCLICK_GEO_PROFILES[77].region_name is None
|
||||
assert DOMCLICK_GEO_PROFILES[50].region_name is None
|
||||
# Имя города для listings.city: у области его нет и угадывать нечего.
|
||||
assert DOMCLICK_GEO_PROFILES[66].city_name == "Екатеринбург"
|
||||
assert DOMCLICK_GEO_PROFILES[77].city_name == "Москва"
|
||||
assert DOMCLICK_GEO_PROFILES[50].city_name is None
|
||||
|
||||
|
||||
def _geo_ok(region_code: int, item: dict[str, object]) -> bool:
|
||||
"""_is_geo_ok без конструирования скрейпера: метод читает только self._geo."""
|
||||
fake = SimpleNamespace(_geo=geo_profile_for_region(region_code))
|
||||
return DomClickScraper._is_geo_ok(fake, item) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_msk_accepts_novaya_moskva_with_foreign_region_name() -> None:
|
||||
"""Новая Москва приходит как «г. Говорово» — сверка по имени вырезала бы
|
||||
законную выдачу, поэтому вне 66 гард только по bbox."""
|
||||
item = {"offerRegionName": "г. Говорово", "location": {"lat": 55.62, "lon": 37.35}}
|
||||
assert _geo_ok(77, item) is True
|
||||
|
||||
|
||||
def test_msk_rejects_point_outside_bbox() -> None:
|
||||
ekb_point = {"offerRegionName": "Москва", "location": {"lat": 56.83, "lon": 60.60}}
|
||||
assert _geo_ok(77, ekb_point) is False
|
||||
|
||||
|
||||
def test_ekb_still_rejects_foreign_region_name() -> None:
|
||||
item = {"offerRegionName": "г. Говорово", "location": {"lat": 56.83, "lon": 60.60}}
|
||||
assert _geo_ok(66, item) is False
|
||||
|
||||
|
||||
def test_oblast_bbox_accepts_podmoskovie_point() -> None:
|
||||
item = {"offerRegionName": "г. Подольск", "location": {"lat": 55.43, "lon": 37.55}}
|
||||
assert _geo_ok(50, item) is True
|
||||
|
||||
|
||||
def test_unknown_region_code_raises() -> None:
|
||||
with pytest.raises(ValueError, match="region_code=99"):
|
||||
geo_profile_for_region(99)
|
||||
|
|
@ -95,6 +95,13 @@ _WAVE2_SLUGS = {
|
|||
assert len(_WAVE2_SLUGS) == 40
|
||||
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана)
|
||||
|
||||
# msk-rollout (черновик): Москва, область и объединённый скоуп заведены в
|
||||
# CITY_ANCHORS/CITY_LOCATIONS, но строк расписаний у них СОЗНАТЕЛЬНО нет —
|
||||
# реестры едут отдельным заходом от включения сбора. Проверки соответствия
|
||||
# «реестр ↔ schedule row» ниже — про свердловскую волну #262, поэтому
|
||||
# московские скоупы из них вычитаются явно (а не тихо расширяют ожидания).
|
||||
_MSK_SCOPES = {"moskva", "moskovskaya_oblast", "moskva_i_mo"}
|
||||
|
||||
|
||||
def _sql(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
|
@ -262,7 +269,7 @@ def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None:
|
|||
scheduled_avito = rows_262 | rows_179
|
||||
|
||||
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None}
|
||||
assert scheduled_avito == confirmed_avito, (
|
||||
assert scheduled_avito == confirmed_avito - _MSK_SCOPES, (
|
||||
f"missing schedule rows: {confirmed_avito - scheduled_avito}; "
|
||||
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}"
|
||||
)
|
||||
|
|
@ -274,7 +281,7 @@ def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None
|
|||
scheduled_yandex = rows_262 | rows_179
|
||||
|
||||
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None}
|
||||
assert scheduled_yandex == confirmed_yandex, (
|
||||
assert scheduled_yandex == confirmed_yandex - _MSK_SCOPES, (
|
||||
f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
|
||||
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}"
|
||||
)
|
||||
|
|
@ -288,7 +295,7 @@ def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> N
|
|||
confirmed_cian = {
|
||||
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None
|
||||
}
|
||||
assert scheduled_cian == confirmed_cian, (
|
||||
assert scheduled_cian == confirmed_cian - _MSK_SCOPES, (
|
||||
f"missing schedule rows: {confirmed_cian - scheduled_cian}; "
|
||||
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}"
|
||||
)
|
||||
|
|
@ -331,7 +338,7 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
|
|||
seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
|
||||
slug for _p, slug in _row_sources(_MIGRATION_262)
|
||||
}
|
||||
orphaned = set(CITY_ANCHORS) - seeded_slugs
|
||||
orphaned = set(CITY_ANCHORS) - seeded_slugs - _MSK_SCOPES
|
||||
assert not orphaned, (
|
||||
f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
|
||||
)
|
||||
|
|
@ -340,8 +347,8 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
|
|||
def test_city_anchors_wave2_count_and_content() -> None:
|
||||
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
|
||||
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
|
||||
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS
|
||||
assert len(CITY_ANCHORS) == 45
|
||||
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS | _MSK_SCOPES
|
||||
assert len(CITY_ANCHORS) == 45 + len(_MSK_SCOPES)
|
||||
|
||||
|
||||
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
Контекст: планировщик читает регион из строки расписания (#3515,
|
||||
`scheduler._resolve_region_code`), но гео-скоуп свипа задаётся НЕ им — у avito/cian/
|
||||
yandex это anchors города (`CITY_ANCHORS`/`CITY_LOCATIONS`, сегодня только
|
||||
Свердловская обл.), у domclick — зашитый в BFF-URL GUID Екатеринбурга. Заведи мы
|
||||
Свердловская обл.), а у domclick — профиль региона в BFF-URL (DOMCLICK_GEO_PROFILES:
|
||||
66/77/50; код без профиля — падение). Заведи мы
|
||||
строку `*_city_sweep_moscow` с `{"region_code": 77}` — прогон собрал бы ЕКБ и
|
||||
проштамповал его московским регионом, не показав ни одной аномалии в счётчиках.
|
||||
Гард превращает эту тихую порчу в падение до первого запроса.
|
||||
|
|
@ -23,6 +24,7 @@ from scraper_kit.orchestration.pipeline import (
|
|||
run_domclick_city_sweep,
|
||||
run_yandex_city_sweep,
|
||||
)
|
||||
from scraper_kit.providers.domclick.serp import geo_profile_for_region
|
||||
|
||||
_EKB_ANCHOR = [(56.8400, 60.6050, "Центр")]
|
||||
|
||||
|
|
@ -71,11 +73,22 @@ async def test_anchor_sweeps_reject_foreign_region(sweep: Callable[..., Any]) ->
|
|||
await sweep(None, **_required_kwargs(sweep, run_id=1, region_code=77, anchors=None))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("region_code", [50, 77])
|
||||
async def test_domclick_sweep_rejects_foreign_region(region_code: int) -> None:
|
||||
"""У домклика anchors нет вовсе — гео-скоуп зашит в GUID, поэтому режем регион."""
|
||||
@pytest.mark.parametrize("region_code", [1, 99])
|
||||
async def test_domclick_sweep_rejects_region_without_profile(region_code: int) -> None:
|
||||
"""У домклика anchors нет вовсе: гео-скоуп задаёт профиль региона
|
||||
(DOMCLICK_GEO_PROFILES: address GUID, aids, страница прогрева, bbox). 66/77/50
|
||||
теперь поддержаны, а код без профиля по-прежнему падает до первого запроса:
|
||||
тихий fallback на ЕКБ — ровно та порча, от которой стережёт этот файл."""
|
||||
with pytest.raises(ValueError, match=f"region_code={region_code}"):
|
||||
await run_domclick_city_sweep(
|
||||
None, # type: ignore[arg-type]
|
||||
**_required_kwargs(run_domclick_city_sweep, run_id=1, region_code=region_code),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("region_code", [66, 77, 50])
|
||||
def test_domclick_supported_regions_have_profiles(region_code: int) -> None:
|
||||
"""Москва и область больше НЕ режутся: у них свой GUID и свой bbox."""
|
||||
profile = geo_profile_for_region(region_code)
|
||||
assert profile.region_code == region_code
|
||||
assert profile.address_guid
|
||||
|
|
|
|||
233
tradein-mvp/backend/tests/test_msk_sweep_registry.py
Normal file
233
tradein-mvp/backend/tests/test_msk_sweep_registry.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Москва и МО в реестрах развёрток: хост Циана, идентификаторы, сетка якорей.
|
||||
|
||||
Черновик msk-rollout: снимаем привязку avito/cian/yandex к Екатеринбургу. Тесты
|
||||
здесь чисто реестровые (никаких сетевых вызовов): проверяют, что
|
||||
* ЕКБ-путь не сдвинулся ни на байт (хост, слаг, rgid, region=),
|
||||
* Москва/область/объединённый скоуп дают ровно измеренные живьём значения,
|
||||
* сетка якорей Москвы лежит внутри продуктового ядра и покрывает его без дыр,
|
||||
* неизвестный слаг ПАДАЕТ, а не откатывается молча на Екатеринбург.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from scraper_kit.orchestration.pipeline import (
|
||||
CIAN_MOSCOW_HOST,
|
||||
CIAN_SVERDLOVSK_HOST,
|
||||
CITY_LOCATIONS,
|
||||
MOSCOW_ANCHOR_RADIUS_M,
|
||||
MOSCOW_ANCHORS,
|
||||
MOSCOW_CORE_BBOX_DEG,
|
||||
MOSCOW_OBLAST_ANCHOR_RADIUS_M,
|
||||
MOSCOW_OBLAST_CITY_ANCHORS,
|
||||
MOSCOW_OBLAST_CLUSTER_ANCHORS,
|
||||
MULTI_CITY_SLUGS,
|
||||
get_city_anchor_point,
|
||||
get_city_anchors,
|
||||
get_city_location,
|
||||
get_city_stamp_radius_km,
|
||||
resolve_city_name,
|
||||
)
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
from scraper_kit.providers.cian.serp import CianScraper
|
||||
from scraper_kit.providers.yandex.serp import YandexRealtyScraper
|
||||
|
||||
|
||||
def _cfg() -> SimpleNamespace:
|
||||
"""Минимальный ScraperConfig-стаб: конструкторам scraper'ов хватает атрибутов."""
|
||||
return SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
r = 6371.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp = math.radians(lat2 - lat1)
|
||||
dl = math.radians(lon2 - lon1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
# ── ЕКБ не сдвинулся ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ekb_cian_host_and_region_byte_identical() -> None:
|
||||
"""ЕКБ идёт по None-пути (get_city_location(None) → None) — хост класса и region=
|
||||
остаются прежними: ekb.cian.ru + region=4743."""
|
||||
assert get_city_location(None) is None
|
||||
assert CianScraper.base_url == "https://ekb.cian.ru" == CIAN_SVERDLOVSK_HOST
|
||||
|
||||
scraper = CianScraper(_cfg(), city_region_id=None, city_host=None) # type: ignore[arg-type]
|
||||
url = scraper._build_url()
|
||||
assert url.startswith("https://ekb.cian.ru/cat.php?")
|
||||
assert "region=4743" in url
|
||||
|
||||
|
||||
def test_ekb_avito_slug_and_card_filter_unchanged() -> None:
|
||||
"""Без target_city_slug путь URL остаётся /ekaterinburg/, фильтр карточек включён."""
|
||||
scraper = AvitoScraper(_cfg()) # type: ignore[arg-type]
|
||||
assert scraper._city_seg() == "ekaterinburg"
|
||||
assert scraper._filter_cards_by_city_slug is True
|
||||
|
||||
|
||||
def test_ekb_yandex_rgid_unchanged() -> None:
|
||||
scraper = YandexRealtyScraper(_cfg(), city_rgid=None) # type: ignore[arg-type]
|
||||
assert scraper._rgid == 559132
|
||||
|
||||
|
||||
def test_sverdlovsk_entries_keep_default_host() -> None:
|
||||
"""Все свердловские записи остаются на ekb.cian.ru — поле добавлено с дефолтом."""
|
||||
for slug, loc in CITY_LOCATIONS.items():
|
||||
if slug in {"moskva", "moskovskaya_oblast", "moskva_i_mo"}:
|
||||
continue
|
||||
assert loc.cian_host == CIAN_SVERDLOVSK_HOST, slug
|
||||
assert loc.avito_slug_is_region is False, slug
|
||||
|
||||
|
||||
# ── Москва / область / объединённый скоуп ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("slug", "region_id", "rgid"),
|
||||
[
|
||||
("moskva", 1, 587795),
|
||||
("moskovskaya_oblast", 4593, 587654),
|
||||
("moskva_i_mo", -1, 741964),
|
||||
],
|
||||
)
|
||||
def test_moscow_scopes_ids(slug: str, region_id: int, rgid: int) -> None:
|
||||
"""Замер 10-12.09.2026: region=1 Москва, 4593 область, -1 обе вместе."""
|
||||
loc = get_city_location(slug)
|
||||
assert loc is not None
|
||||
assert loc.cian_region_id == region_id
|
||||
assert loc.yandex_rgid == rgid
|
||||
assert loc.cian_host == CIAN_MOSCOW_HOST == "https://www.cian.ru"
|
||||
# avito: ОДИН слаг на оба региона, отдельного слага у области нет.
|
||||
assert loc.avito_slug == "moskva_i_mo"
|
||||
assert loc.avito_slug_is_region is True
|
||||
|
||||
scraper = CianScraper( # type: ignore[arg-type]
|
||||
_cfg(), city_region_id=loc.cian_region_id, city_host=loc.cian_host
|
||||
)
|
||||
url = scraper._build_url()
|
||||
assert url.startswith("https://www.cian.ru/cat.php?")
|
||||
assert f"region={region_id}" in url
|
||||
|
||||
|
||||
def test_moscow_region_slug_disables_card_filter() -> None:
|
||||
"""Слаг региона в ссылках карточек не встречается → per-card фильтр выключается,
|
||||
иначе отфильтровалась бы вся выдача (avito_serp_ekb_only=True в конфиге)."""
|
||||
loc = get_city_location("moskva")
|
||||
assert loc is not None
|
||||
scraper = AvitoScraper( # type: ignore[arg-type]
|
||||
_cfg(),
|
||||
target_city_slug=loc.avito_slug,
|
||||
filter_cards_by_city_slug=not loc.avito_slug_is_region,
|
||||
)
|
||||
assert scraper._city_seg() == "moskva_i_mo"
|
||||
assert scraper._filter_cards_by_city_slug is False
|
||||
|
||||
|
||||
def test_multi_city_scopes_do_not_stamp_a_city() -> None:
|
||||
"""У области и объединённого скоупа город в batch'е не один — штампа быть не должно."""
|
||||
assert MULTI_CITY_SLUGS == frozenset({"moskovskaya_oblast", "moskva_i_mo"})
|
||||
assert resolve_city_name("moskovskaya_oblast") is None
|
||||
assert resolve_city_name("moskva_i_mo") is None
|
||||
assert resolve_city_name("moskva") == "Москва"
|
||||
assert resolve_city_name(None) == "Екатеринбург"
|
||||
|
||||
|
||||
# ── сетка якорей Москвы ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_moscow_grid_inside_core_and_centered() -> None:
|
||||
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
|
||||
assert len(MOSCOW_ANCHORS) == 25
|
||||
for lat, lon, name in MOSCOW_ANCHORS:
|
||||
assert lat_min <= lat <= lat_max, name
|
||||
assert lon_min <= lon <= lon_max, name
|
||||
# anchors[0] — центр ядра: именно его берёт гео-гард штампа.
|
||||
assert get_city_anchor_point("moskva") == (55.75, 37.60)
|
||||
|
||||
|
||||
def test_moscow_grid_covers_core_without_holes() -> None:
|
||||
"""Любая точка ядра не дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего якоря."""
|
||||
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
|
||||
radius_km = MOSCOW_ANCHOR_RADIUS_M / 1000.0
|
||||
worst = 0.0
|
||||
lat_steps = round((lat_max - lat_min) / 0.01)
|
||||
lon_steps = round((lon_max - lon_min) / 0.01)
|
||||
for i in range(lat_steps + 1):
|
||||
lat = lat_min + i * 0.01
|
||||
for j in range(lon_steps + 1):
|
||||
lon = lon_min + j * 0.01
|
||||
nearest = min(
|
||||
_haversine_km(lat, lon, a_lat, a_lon) for a_lat, a_lon, _ in MOSCOW_ANCHORS
|
||||
)
|
||||
worst = max(worst, nearest)
|
||||
assert worst <= radius_km, f"дыра в покрытии: худшая точка ядра в {worst:.2f} км от якоря"
|
||||
|
||||
|
||||
def test_moscow_oblast_anchors_are_satellite_cities() -> None:
|
||||
"""У области — по якорю на город-спутник, координаты из реестра репозитория."""
|
||||
anchors = get_city_anchors("moskovskaya_oblast")
|
||||
assert anchors is not None
|
||||
names = [name for _lat, _lon, name in anchors]
|
||||
assert "Балашиха" in names and "Химки" in names and "Подольск" in names
|
||||
assert len(names) == len(set(names))
|
||||
combined = get_city_anchors("moskva_i_mo")
|
||||
assert combined is not None
|
||||
assert len(combined) == len(MOSCOW_ANCHORS) + len(anchors)
|
||||
|
||||
|
||||
def test_moscow_oblast_anchors_are_two_measured_layers() -> None:
|
||||
"""Оба слоя на месте и не перепутаны: города-спутники + кластеры лот-массы."""
|
||||
anchors = get_city_anchors("moskovskaya_oblast")
|
||||
assert anchors is not None
|
||||
assert anchors == [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS]
|
||||
# Слой 2 добирает то, чего не дают города: без него покрытие лот-массы 73.8%.
|
||||
assert len(MOSCOW_OBLAST_CLUSTER_ANCHORS) > 0
|
||||
cities = {name for _lat, _lon, name in MOSCOW_OBLAST_CITY_ANCHORS}
|
||||
clusters = {name for _lat, _lon, name in MOSCOW_OBLAST_CLUSTER_ANCHORS}
|
||||
assert not (cities & clusters), "подписи слоёв пересеклись — якорь задублирован"
|
||||
|
||||
|
||||
def test_moscow_oblast_anchors_lie_inside_region_50_bbox() -> None:
|
||||
"""Якорь вне области собрал бы чужой регион и проштамповал бы его как 50.
|
||||
|
||||
Границы — bbox_region региона 50 из backend/app/services/regions.py (дубль:
|
||||
kit не имеет права импортировать app.*).
|
||||
"""
|
||||
lat_min, lat_max, lon_min, lon_max = 54.20, 56.96, 35.14, 40.21
|
||||
for lat, lon, name in [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS]:
|
||||
assert lat_min <= lat <= lat_max, f"{name}: широта вне области"
|
||||
assert lon_min <= lon <= lon_max, f"{name}: долгота вне области"
|
||||
|
||||
|
||||
def test_moscow_oblast_radius_is_the_one_coverage_was_measured_at() -> None:
|
||||
"""95.2% лот-массы посчитаны при 10 км; меньший radius_m в расписании даст дыры."""
|
||||
assert MOSCOW_OBLAST_ANCHOR_RADIUS_M == 10000
|
||||
assert MOSCOW_OBLAST_ANCHOR_RADIUS_M > MOSCOW_ANCHOR_RADIUS_M
|
||||
|
||||
|
||||
def test_moscow_stamp_radius_covers_core_diagonal() -> None:
|
||||
"""15 км по умолчанию отрезали бы легитимную Москву — у ядра полудиагональ 29.1 км."""
|
||||
_lat_min, lat_max, _lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
|
||||
center = get_city_anchor_point("moskva")
|
||||
assert center is not None
|
||||
corner = _haversine_km(center[0], center[1], lat_max, lon_max)
|
||||
assert corner > 15.0
|
||||
assert get_city_stamp_radius_km("moskva") >= corner
|
||||
# область/объединённый скоуп штампа не ставят вовсе (city=None → гард инертен).
|
||||
assert resolve_city_name("moskovskaya_oblast") is None
|
||||
|
||||
|
||||
# ── неизвестный слаг падает ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fn", [get_city_anchors, get_city_location, resolve_city_name])
|
||||
def test_unknown_slug_raises_instead_of_ekb_fallback(fn) -> None: # type: ignore[no-untyped-def]
|
||||
with pytest.raises(ValueError, match="nonexistent_city"):
|
||||
fn("nonexistent_city")
|
||||
261
tradein-mvp/backend/tests/test_price_corridor_planner.py
Normal file
261
tradein-mvp/backend/tests/test_price_corridor_planner.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""Тесты чистого планировщика ценовых коридоров (`scraper_kit.pricing.planner`).
|
||||
|
||||
Планировщик синхронный и чистый: «сколько позиций в коридоре» инжектится
|
||||
функцией, поэтому здесь НЕТ ни сети, ни моков площадок — только модели
|
||||
распределения цен.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
|
||||
import pytest
|
||||
from scraper_kit.pricing.planner import (
|
||||
EKB_REGION_CODE,
|
||||
MAX_DEPTH,
|
||||
MIN_WIDTH_RATIO,
|
||||
geometric_mid,
|
||||
plan_price_corridors,
|
||||
price_bisection_enabled,
|
||||
width_ratio,
|
||||
)
|
||||
|
||||
|
||||
def _counter(prices: list[int]):
|
||||
"""`count_of` поверх отсортированного списка цен: сколько попало в [lo, hi]."""
|
||||
ordered = sorted(prices)
|
||||
|
||||
def count_of(lo: int | None, hi: int | None) -> int:
|
||||
left = 0 if lo is None else bisect.bisect_left(ordered, lo)
|
||||
right = len(ordered) if hi is None else bisect.bisect_right(ordered, hi)
|
||||
return max(0, right - left)
|
||||
|
||||
return count_of
|
||||
|
||||
|
||||
def _covers_every_price(plan, prices: list[int]) -> bool:
|
||||
"""Каждая цена попадает хотя бы в один коридор плана."""
|
||||
return all(
|
||||
any((c.lo is None or p >= c.lo) and (c.hi is None or p <= c.hi) for c in plan.corridors)
|
||||
for p in prices
|
||||
)
|
||||
|
||||
|
||||
# ── равномерное распределение ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_uniform_spread_splits_until_every_corridor_fits_target() -> None:
|
||||
"""Равномерная выборка делится, пока каждый коридор не влезет в target."""
|
||||
prices = list(range(1_000_000, 21_000_000, 2_000)) # 10 000 позиций
|
||||
plan = plan_price_corridors(
|
||||
count_of=_counter(prices),
|
||||
target=1_200,
|
||||
hard_cap=1_500,
|
||||
lo=1_000_000,
|
||||
hi=21_000_000,
|
||||
)
|
||||
|
||||
assert plan.stats.root_count == len(prices)
|
||||
assert plan.stats.corridors > 1, "10k позиций при target=1200 обязаны поделиться"
|
||||
assert plan.stats.truncated_corridors == 0
|
||||
assert plan.stats.missed_estimate == 0
|
||||
assert plan.stats.complete
|
||||
assert all(c.count is not None and c.count <= 1_200 for c in plan.corridors)
|
||||
assert _covers_every_price(plan, prices)
|
||||
|
||||
|
||||
def test_small_sample_stays_one_corridor() -> None:
|
||||
"""Выборка ниже target не делится вовсе — один коридор, один probe."""
|
||||
prices = list(range(2_000_000, 3_000_000, 10_000)) # 100 позиций
|
||||
plan = plan_price_corridors(count_of=_counter(prices), target=1_200)
|
||||
|
||||
assert plan.stats.corridors == 1
|
||||
assert plan.stats.probes == 1
|
||||
assert plan.stats.complete
|
||||
|
||||
|
||||
# ── вырожденный случай: всё в одной цене ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_single_price_point_hits_min_width_ratio_and_is_marked_truncated() -> None:
|
||||
"""«Всё в одной цене» упирается в MIN_WIDTH_RATIO, а не зацикливается.
|
||||
|
||||
Коридор вокруг единственной цены сузить до нужного размера невозможно —
|
||||
делить бессмысленно. Планировщик обязан остановиться и ПОМЕТИТЬ коридор
|
||||
усечённым с честным недобором, а не молча отдать план «всё влезло».
|
||||
"""
|
||||
prices = [3_000_000] * 5_000
|
||||
plan = plan_price_corridors(
|
||||
count_of=_counter(prices),
|
||||
target=1_200,
|
||||
hard_cap=1_500,
|
||||
lo=1_000_000,
|
||||
hi=10_000_000,
|
||||
)
|
||||
|
||||
assert plan.stats.truncated_corridors >= 1
|
||||
assert plan.stats.missed_estimate > 0
|
||||
assert not plan.stats.complete
|
||||
|
||||
overflowing = [c for c in plan.corridors if c.count and c.count > 1_500]
|
||||
assert overflowing, "переполненный коридор обязан остаться в плане"
|
||||
for c in overflowing:
|
||||
assert c.truncated, f"коридор {c.label()} переполнен, но не помечен"
|
||||
assert c.missed == c.count - 1_500
|
||||
# Остановились по предохранителю, а не «просто так».
|
||||
assert c.depth >= MAX_DEPTH or width_ratio(c.lo, c.hi) <= MIN_WIDTH_RATIO
|
||||
|
||||
assert plan.stats.missed_estimate == sum(c.missed for c in plan.truncated)
|
||||
|
||||
|
||||
def test_degenerate_plan_terminates_within_depth_and_probe_budget() -> None:
|
||||
"""Вырожденный вход завершается: глубина ограничена, число probe конечно."""
|
||||
plan = plan_price_corridors(
|
||||
count_of=_counter([3_000_000] * 5_000),
|
||||
target=1_200,
|
||||
lo=1_000_000,
|
||||
hi=10_000_000,
|
||||
)
|
||||
|
||||
assert plan.stats.max_depth_reached <= MAX_DEPTH
|
||||
assert plan.stats.probes < 2 ** (MAX_DEPTH + 1)
|
||||
|
||||
|
||||
def test_max_depth_is_never_exceeded_on_open_ended_range() -> None:
|
||||
"""Даже на открытом диапазоне глубина не превышает MAX_DEPTH."""
|
||||
prices = list(range(500_000, 100_000_000, 5_000))
|
||||
plan = plan_price_corridors(count_of=_counter(prices), target=1_000)
|
||||
|
||||
assert plan.stats.max_depth_reached <= MAX_DEPTH
|
||||
assert all(c.depth <= MAX_DEPTH for c in plan.corridors)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_depth", [1, 2, 3])
|
||||
def test_custom_max_depth_caps_recursion(max_depth: int) -> None:
|
||||
"""Пониженный max_depth режет дерево раньше — и недобор виден в статистике."""
|
||||
prices = list(range(1_000_000, 21_000_000, 2_000))
|
||||
plan = plan_price_corridors(
|
||||
count_of=_counter(prices),
|
||||
target=1_200,
|
||||
hard_cap=1_500,
|
||||
lo=1_000_000,
|
||||
hi=21_000_000,
|
||||
max_depth=max_depth,
|
||||
)
|
||||
|
||||
assert plan.stats.max_depth_reached <= max_depth
|
||||
# Мелкий потолок глубины физически не даёт добрать всё — это обязано
|
||||
# проявиться усечением, а не «успехом».
|
||||
assert plan.stats.truncated_corridors > 0
|
||||
assert plan.stats.missed_estimate > 0
|
||||
|
||||
|
||||
# ── регион 66: поведение прежнее ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_region_66_disables_bisection() -> None:
|
||||
"""Свердловская обл. — бисекция выключена; неизвестный регион трактуем как 66."""
|
||||
assert price_bisection_enabled(EKB_REGION_CODE) is False
|
||||
assert price_bisection_enabled(None) is False
|
||||
assert price_bisection_enabled(77) is True # Москва
|
||||
assert price_bisection_enabled(50) is True # Московская обл.
|
||||
|
||||
|
||||
def test_region_66_yields_exactly_one_corridor_over_whole_range() -> None:
|
||||
"""С выключенной бисекцией план = ровно один коридор на весь диапазон.
|
||||
|
||||
Это и есть «поведение региона 66 осталось прежним»: ни одного лишнего
|
||||
запроса к площадке сверх единственного корневого probe.
|
||||
"""
|
||||
prices = list(range(1_000_000, 21_000_000, 2_000))
|
||||
plan = plan_price_corridors(
|
||||
count_of=_counter(prices),
|
||||
target=1_200,
|
||||
lo=1_000_000,
|
||||
hi=21_000_000,
|
||||
enabled=price_bisection_enabled(EKB_REGION_CODE),
|
||||
)
|
||||
|
||||
assert plan.stats.corridors == 1
|
||||
assert plan.stats.probes == 1
|
||||
only = plan.corridors[0]
|
||||
assert (only.lo, only.hi) == (1_000_000, 21_000_000)
|
||||
assert only.depth == 0
|
||||
assert _covers_every_price(plan, prices)
|
||||
|
||||
|
||||
def test_disabled_plan_still_reports_truncation() -> None:
|
||||
"""Выключенная бисекция не значит «молчать»: переполнение всё равно в счётчиках."""
|
||||
plan = plan_price_corridors(
|
||||
count_of=_counter(list(range(1_000_000, 21_000_000, 2_000))),
|
||||
target=1_200,
|
||||
hard_cap=1_500,
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
assert plan.stats.corridors == 1
|
||||
assert plan.stats.truncated_corridors == 1
|
||||
assert plan.stats.missed_estimate == 10_000 - 1_500
|
||||
assert not plan.stats.complete
|
||||
|
||||
|
||||
# ── непрочитанный счётчик ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unreadable_counter_is_marked_not_silently_trusted() -> None:
|
||||
"""`count_of` вернул None → коридор слепой: truncated + unknown, не «пусто»."""
|
||||
plan = plan_price_corridors(count_of=lambda lo, hi: None, target=1_200)
|
||||
|
||||
assert plan.stats.corridors == 1
|
||||
assert plan.stats.unknown_corridors == 1
|
||||
assert plan.corridors[0].unknown_count is True
|
||||
assert plan.corridors[0].truncated is True
|
||||
assert not plan.stats.complete
|
||||
|
||||
|
||||
# ── геометрическая середина ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_geometric_mid_is_geometric_not_arithmetic() -> None:
|
||||
"""sqrt(lo*hi), а не (lo+hi)/2 — иначе верхняя половина вырожденно пуста."""
|
||||
assert geometric_mid(1_000_000, 100_000_000) == 10_000_000
|
||||
assert geometric_mid(1_000_000, 100_000_000) != (1_000_000 + 100_000_000) // 2
|
||||
|
||||
|
||||
def test_geometric_mid_stays_strictly_inside_the_corridor() -> None:
|
||||
"""При hi-lo>=2 середина строго внутри — обе половины непусты."""
|
||||
for lo, hi in ((1_000_000, 1_000_002), (1_000_000, 100_000_000), (None, 700_000)):
|
||||
mid = geometric_mid(lo, hi)
|
||||
assert mid < hi
|
||||
if lo is not None:
|
||||
assert mid > lo
|
||||
|
||||
|
||||
def test_atomic_corridor_is_not_split_and_is_marked_truncated() -> None:
|
||||
"""Коридор шириной в 1 ₽ делить нечем: предохранитель, а не рекурсия по себе.
|
||||
|
||||
`geometric_mid` при hi-lo<=1 строгой внутренней точки дать не может, и
|
||||
половина совпала бы с родителем. Планировщик обязан остановиться сам, не
|
||||
полагаясь на MAX_DEPTH как на единственный тормоз — поэтому
|
||||
min_width_ratio здесь занижен до 1.0, чтобы снять штатный предохранитель.
|
||||
"""
|
||||
plan = plan_price_corridors(
|
||||
count_of=_counter([5_000_000] * 3_000),
|
||||
target=100,
|
||||
hard_cap=100,
|
||||
lo=5_000_000,
|
||||
hi=5_000_001,
|
||||
min_width_ratio=1.0,
|
||||
)
|
||||
|
||||
assert plan.stats.corridors == 1
|
||||
only = plan.corridors[0]
|
||||
assert (only.lo, only.hi) == (5_000_000, 5_000_001)
|
||||
assert only.truncated is True
|
||||
assert only.missed == 3_000 - 100
|
||||
assert plan.stats.max_depth_reached == 0
|
||||
|
||||
|
||||
def test_width_ratio_open_top_is_infinite() -> None:
|
||||
assert width_ratio(1_000_000, None) == float("inf")
|
||||
assert width_ratio(1_000_000, 2_000_000) == pytest.approx(2.0)
|
||||
|
|
@ -550,17 +550,20 @@ async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> N
|
|||
assert kwargs["anchors"] is None
|
||||
|
||||
|
||||
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
|
||||
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
|
||||
async def test_job_avito_city_sweep_unknown_city_raises_instead_of_sweeping() -> None:
|
||||
"""Неизвестный city slug (опечатка в расписании) обязан упасть, а не собрать ЕКБ.
|
||||
|
||||
НЕ падает с KeyError.
|
||||
Раньше `get_city_anchors` отдавал None, и свип уходил на EKB_ANCHORS: опечатка в
|
||||
`default_params.city` тихо давала прогон по Екатеринбургу, проштампованный чужим
|
||||
городом. Теперь реестр падает с ValueError; прогон крашится, а тик планировщика
|
||||
это ловит (`logger.exception` в `_run`, scheduler.py) — цикл не умирает, но и
|
||||
мусора в корпус не попадает.
|
||||
"""
|
||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
||||
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
|
||||
with pytest.raises(ValueError, match="nonexistent_town"):
|
||||
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
|
||||
|
||||
mock_run.assert_awaited_once()
|
||||
_args, kwargs = mock_run.call_args
|
||||
assert kwargs["anchors"] is None
|
||||
mock_run.assert_not_awaited()
|
||||
|
||||
|
||||
# ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ──────
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ from scraper_kit.providers.cian.newbuilding import (
|
|||
save_newbuilding_enrichment,
|
||||
)
|
||||
from scraper_kit.providers.cian.serp import CianScraper
|
||||
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper
|
||||
from scraper_kit.providers.domclick.serp import (
|
||||
ROOM_BUCKETS,
|
||||
DomClickScraper,
|
||||
geo_profile_for_region,
|
||||
)
|
||||
from scraper_kit.providers.yandex.serp import (
|
||||
DEFAULT_PRICE_RANGES,
|
||||
ROOM_PATH,
|
||||
|
|
@ -278,6 +282,146 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
|
|||
(56.8650, 60.6200, "Пионерский"),
|
||||
]
|
||||
|
||||
|
||||
# ── Москва (77) и Московская область (50): якоря развёрток ──────────────────
|
||||
#
|
||||
# Продуктовое ядро Москвы — то же окно, что у REGIONS[77].bbox_product_core в
|
||||
# backend/app/services/regions.py: lat 55.55..55.95, lon 37.30..37.90. Это ~44.5 км
|
||||
# с юга на север и ~37.6 км с запада на восток (на широте 55.75° градус долготы =
|
||||
# 62.6 км). Один центральный anchor, как у компактных городов области, покрыл бы
|
||||
# отсюда круг радиусом 8 км — меньше пятой части ядра. Поэтому сетка.
|
||||
MOSCOW_CORE_BBOX_DEG: tuple[float, float, float, float] = (55.55, 55.95, 37.30, 37.90)
|
||||
|
||||
# Радиус ОДНОГО anchor-запроса московских развёрток. 8 км — не оценка на глаз:
|
||||
# столько намерено на прод-корпусе (35 552 активных вторичных лота region_code=77)
|
||||
# у московской сетки центроидов, см. большой комментарий над `_MOSCOW_GRID_DEG` в
|
||||
# backend/app/api/v1/trade_in.py (R=5..8 дают одинаковые 0.00% вне радиуса, при
|
||||
# R=10 внутрь входит уже Наро-Фоминск). Кит не имеет права импортировать app.*
|
||||
# (гейтится тестом), поэтому здесь осознанный дубль, а не второе независимое число.
|
||||
#
|
||||
# ВАЖНО: `radius_m` у run_avito_city_sweep/run_cian_city_sweep по умолчанию 1500 —
|
||||
# строка расписания московской развёртки обязана передать 8000 явно, иначе круги
|
||||
# соседних узлов не сомкнутся и между ними останутся непокрытые полосы.
|
||||
MOSCOW_ANCHOR_RADIUS_M = 8000
|
||||
|
||||
# Шаг сетки согласован именно с этим радиусом: любая точка ядра должна лежать не
|
||||
# дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего узла. Худшая точка — центр ячейки,
|
||||
# расстояние до узла = hypot(шаг_lat/2, шаг_lon/2). При 0.10° по широте (11.13 км)
|
||||
# и 0.15° по долготе (9.39 км) это hypot(5.56, 4.69) = 7.27 км < 8 км — с запасом
|
||||
# 0.7 км. Узлы стоят по границам окна включительно (55.55/55.65/…/55.95 ×
|
||||
# 37.30/37.45/…/37.90): 5 × 5 = 25 якорей, все внутри ядра, ни один не свисает
|
||||
# наружу. Крупнее (0.15° × 0.20°) дало бы полудиагональ 10.5 км — дыры в покрытии;
|
||||
# мельче — лишние прогоны страниц без нового покрытия.
|
||||
_MOSCOW_GRID_LAT_STEP_DEG = 0.10
|
||||
_MOSCOW_GRID_LON_STEP_DEG = 0.15
|
||||
# Центр ядра сам является узлом сетки, и он же ПЕРВЫЙ в списке: get_city_anchor_point
|
||||
# берёт anchors[0] опорной точкой гео-гарда штампа (см. её docstring), а из угла ядра
|
||||
# гард мерил бы диагональ в 58 км и перестал бы что-либо отсекать.
|
||||
_MOSCOW_CENTER_DEG: tuple[float, float] = (55.75, 37.60)
|
||||
|
||||
|
||||
def _build_moscow_anchor_grid() -> list[tuple[float, float, str]]:
|
||||
"""Сетка якорей ядра Москвы: центр первым, остальные узлы — с юго-запада на север."""
|
||||
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
|
||||
lat_steps = round((lat_max - lat_min) / _MOSCOW_GRID_LAT_STEP_DEG)
|
||||
lon_steps = round((lon_max - lon_min) / _MOSCOW_GRID_LON_STEP_DEG)
|
||||
grid: list[tuple[float, float, str]] = []
|
||||
for i in range(lat_steps + 1):
|
||||
lat = round(lat_min + i * _MOSCOW_GRID_LAT_STEP_DEG, 4)
|
||||
for j in range(lon_steps + 1):
|
||||
lon = round(lon_min + j * _MOSCOW_GRID_LON_STEP_DEG, 4)
|
||||
grid.append((lat, lon, f"Москва {lat:.2f}/{lon:.2f}"))
|
||||
# sort стабилен: центр уезжает в начало, порядок остальных не меняется.
|
||||
grid.sort(key=lambda a: (a[0], a[1]) != _MOSCOW_CENTER_DEG)
|
||||
return grid
|
||||
|
||||
|
||||
MOSCOW_ANCHORS: list[tuple[float, float, str]] = _build_moscow_anchor_grid()
|
||||
|
||||
# Московская область. Двухслойная сетка якорей; оба слоя измерены, ни одна координата
|
||||
# не выдумана.
|
||||
#
|
||||
# Слой 1 — города-спутники (22). Координаты первых 12 продублированы из
|
||||
# `_COVERAGE_NEGATIVE_CENTROIDS_DEG` в backend/app/api/v1/trade_in.py (тот же запрет
|
||||
# на импорт app.* — править нужно оба места); остальные 10 (Домодедово … Наро-Фоминск)
|
||||
# взяты прямым запросом к Nominatim /search (city=<НП>, state=Московская область)
|
||||
# 15.09.2026, все вернулись как place=city/town.
|
||||
#
|
||||
# Слой 2 — кластеры лот-массы (22). Это НЕ центры НП, а центроиды активных объявлений
|
||||
# области в ячейках 0.05°, добранные жадным покрытием поверх слоя 1; подпись — ближайший
|
||||
# НП по обратному геокодированию (zoom=14), она справочная. Слой нужен потому, что одних
|
||||
# городов мало: замер на проде 15.09.2026 по 70 996 активным объявлениям region_code=50
|
||||
# показал, что 22 городских якоря радиусом 10 км накрывают лишь 73.8% лот-массы, а вместе
|
||||
# со вторым слоем — 95.2%.
|
||||
#
|
||||
# Порядок в обоих слоях — по УБЫВАНИЮ измеренной лот-массы (в скобках у каждой строки:
|
||||
# слой 1 — активных лотов в 10 км от якоря, слой 2 — прирост покрытия, который этот якорь
|
||||
# дал жадному алгоритму). Это и есть замер, которого раньше не было.
|
||||
#
|
||||
# ВАЖНО: покрытие считано при радиусе MOSCOW_OBLAST_ANCHOR_RADIUS_M. Расписание, которое
|
||||
# передаст меньший radius_m, получит дыры — 95.2% относятся именно к 10 км.
|
||||
#
|
||||
# Оговорка о смещении: лот-масса взята из УЖЕ собранного корпуса (разовая загрузка
|
||||
# 10-12.09.2026). Там, где разовый сбор не добрал, масса занижена, поэтому 95.2% —
|
||||
# оценка сверху по тому, что видно, а не доказательство полноты по генеральной
|
||||
# совокупности. Первый регулярный прогон обязан пересчитать покрытие по своему улову.
|
||||
MOSCOW_OBLAST_ANCHOR_RADIUS_M = 10000
|
||||
|
||||
MOSCOW_OBLAST_CITY_ANCHORS: list[tuple[float, float, str]] = [
|
||||
(55.6767, 37.8931, "Люберцы"), # 7429
|
||||
(55.7614, 37.8564, "Реутов"), # 6824
|
||||
(55.7444, 38.0128, "Железнодорожный"), # 6465
|
||||
(55.7969, 37.9386, "Балашиха"), # 6429
|
||||
(55.8317, 37.3300, "Красногорск"), # 5556
|
||||
(55.9142, 37.8256, "Королёв"), # 5208
|
||||
(55.9116, 37.7308, "Мытищи"), # 4805
|
||||
(55.8894, 37.4450, "Химки"), # 4701
|
||||
(55.5519, 37.7133, "Видное"), # 4574
|
||||
(55.6789, 37.2639, "Одинцово"), # 4077
|
||||
(55.9206, 37.9916, "Щёлково"), # 4031
|
||||
(55.9386, 37.5100, "Долгопрудный"), # 3506
|
||||
(55.8554, 38.4412, "Ногинск"), # 2820
|
||||
(55.7845, 38.4449, "Электросталь"), # 2462
|
||||
(55.4312, 37.5450, "Подольск"), # 2453
|
||||
(55.4368, 37.7681, "Домодедово"), # 2059
|
||||
(56.3153, 38.1360, "Сергиев Посад"), # 1889
|
||||
(55.5709, 38.2282, "Раменское"), # 1344
|
||||
(55.0939, 38.7670, "Коломна"), # 1118
|
||||
(54.9155, 37.4196, "Серпухов"), # 1059
|
||||
(55.8062, 38.9810, "Орехово-Зуево"), # 824
|
||||
(55.3843, 36.7235, "Наро-Фоминск"), # 802
|
||||
]
|
||||
|
||||
MOSCOW_OBLAST_CLUSTER_ANCHORS: list[tuple[float, float, str]] = [
|
||||
(55.9395, 37.1805, "Рузино"), # +1528
|
||||
(56.0837, 37.9229, "Лесной"), # +1253
|
||||
(55.2779, 38.7415, "Воскресенск"), # +1002
|
||||
(55.5588, 37.0775, "Апрелевка"), # +974
|
||||
(55.7860, 36.9874, "Писково"), # +928
|
||||
(56.2883, 37.4825, "Яхрома"), # +887
|
||||
(56.3704, 36.6872, "Клин"), # +865
|
||||
(55.6113, 37.9765, "Октябрьский"), # +825
|
||||
(56.1460, 37.0592, "Солнечногорск"), # +616
|
||||
(55.8732, 38.1862, "Осеево"), # +597
|
||||
(55.3705, 39.0622, "Егорьевск"), # +587
|
||||
(54.8393, 38.1760, "Кашира"), # +585
|
||||
(55.7768, 38.7096, "Павловский Посад"), # +494
|
||||
(55.1608, 37.4732, "Чехов"), # +493
|
||||
(56.0920, 37.4972, "Некрасовский"), # +440
|
||||
(55.5773, 39.5299, "Шатура"), # +397
|
||||
(56.7358, 37.1643, "Дубна"), # +392
|
||||
(55.9134, 36.8293, "Истра"), # +389
|
||||
(56.4933, 38.1792, "Богородское"), # +312
|
||||
(54.9619, 39.0273, "Луховицы"), # +228
|
||||
(55.7715, 37.2308, "Ильинское-Усово"), # +224
|
||||
(56.2552, 37.9754, "Хотьково"), # +207
|
||||
]
|
||||
|
||||
MOSCOW_OBLAST_ANCHORS: list[tuple[float, float, str]] = [
|
||||
*MOSCOW_OBLAST_CITY_ANCHORS,
|
||||
*MOSCOW_OBLAST_CLUSTER_ANCHORS,
|
||||
]
|
||||
|
||||
# Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
|
||||
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
|
||||
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
|
||||
|
|
@ -342,6 +486,13 @@ CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = {
|
|||
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
|
||||
"talitsa": [(57.0148, 63.7302, "Талица центр")],
|
||||
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")],
|
||||
# ── Москва (77) и Московская область (50) ────────────────────────────────
|
||||
# Три записи вместо одной, чтобы строка расписания могла выбрать скоуп:
|
||||
# только Москва, только область, либо обе сразу (у cian/yandex под это есть
|
||||
# отдельные идентификаторы, у avito один слаг на всё — см. CITY_LOCATIONS).
|
||||
"moskva": MOSCOW_ANCHORS,
|
||||
"moskovskaya_oblast": MOSCOW_OBLAST_ANCHORS,
|
||||
"moskva_i_mo": [*MOSCOW_ANCHORS, *MOSCOW_OBLAST_ANCHORS],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -355,7 +506,14 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] |
|
|||
"""
|
||||
if city_slug is None:
|
||||
return None
|
||||
return CITY_ANCHORS.get(city_slug)
|
||||
try:
|
||||
return CITY_ANCHORS[city_slug]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"get_city_anchors: неизвестный city_slug={city_slug!r}. Раньше здесь был "
|
||||
"тихий откат на ЕКБ (None → EKB_ANCHORS у вызывающей стороны) — развёртка "
|
||||
"чужого города молча собирала Екатеринбург. Заведи слаг в CITY_ANCHORS."
|
||||
) from None
|
||||
|
||||
|
||||
def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
|
||||
|
|
@ -383,6 +541,17 @@ def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
|
|||
return (lat, lon)
|
||||
|
||||
|
||||
# Хост Циана — РЕГИОНАЛЬНЫЙ поддомен, а не городской: ekb.cian.ru общий для всей
|
||||
# Свердловской области (все 45 городов ниже), www.cian.ru обслуживает Москву и МО.
|
||||
# Почему полем CityLocation, а не вторым реестром "слаг → хост": хост и region= —
|
||||
# один и тот же гео-скоуп ОДНОГО запроса, и разъехаться они не имеют права
|
||||
# (region=1 на ekb.cian.ru — чужая либо пустая выдача, причём молча). Отдельная
|
||||
# карта завела бы второй ключ в том же ключевом пространстве и ровно этот риск
|
||||
# рассинхрона; поле в той же frozen-записи рассинхронизировать нечем.
|
||||
CIAN_SVERDLOVSK_HOST = "https://ekb.cian.ru"
|
||||
CIAN_MOSCOW_HOST = "https://www.cian.ru"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CityLocation:
|
||||
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
|
||||
|
|
@ -402,11 +571,25 @@ class CityLocation:
|
|||
Тот же жёсткий инвариант, что у avito_slug — run_yandex_city_sweep
|
||||
явно падает (ValueError), если city_slug известен, а yandex_rgid
|
||||
для него None.
|
||||
cian_host — поддомен Циана (см. CIAN_SVERDLOVSK_HOST выше). Дефолт —
|
||||
свердловский: все 45 записей области остаются байт в байт, а
|
||||
город ВНЕ Свердловской обл. обязан передать хост явно, иначе
|
||||
region= уедет на чужой поддомен.
|
||||
avito_slug_is_region
|
||||
— True, если avito_slug — слаг РЕГИОНА, а не города (moskva_i_mo
|
||||
покрывает Москву и всю область одним скоупом: отдельного слага
|
||||
у области нет, замер 10-12.09.2026). Такой слаг НЕ появляется в
|
||||
ссылках карточек (там /moskva/, /himki/, /balashiha/…), поэтому
|
||||
per-card фильтр `avito_serp_ekb_only` на нём выбросил бы 100%
|
||||
выдачи — run_avito_city_sweep выключает фильтр для таких
|
||||
записей (AvitoScraper(filter_cards_by_city_slug=False)).
|
||||
"""
|
||||
|
||||
avito_slug: str | None
|
||||
cian_region_id: int
|
||||
yandex_rgid: int | None
|
||||
cian_host: str = CIAN_SVERDLOVSK_HOST
|
||||
avito_slug_is_region: bool = False
|
||||
|
||||
|
||||
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
|
||||
|
|
@ -469,14 +652,41 @@ CITY_LOCATIONS: dict[str, CityLocation] = {
|
|||
"verkhoturye": CityLocation(None, 174499, 328251),
|
||||
# ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
|
||||
"mikhaylovsk": CityLocation(None, 175332, None),
|
||||
# ── Москва (77) и Московская область (50) ──────────────────────────────
|
||||
# Значения проверены живьём 10-12.09.2026 (scripts/local-avito-msk/collect.py):
|
||||
# • avito — слаг ОДИН на оба региона (moskva_i_mo), отдельного слага у области
|
||||
# нет; деление на 77/50 происходит позже, на импорте, по полигонам
|
||||
# (app.services.regions.region_for_point), а не по слагу.
|
||||
# • cian — region=1 Москва (92 817 лотов), 4593 область (60 231), -1 обе
|
||||
# вместе (153 049). Два параметра region НЕ объединяются (побеждает
|
||||
# последний), поэтому «Москва+область» — это именно -1, а не пара значений.
|
||||
# • yandex — rgid 587795 Москва (18 705), 587654 область (14 242), 741964 обе
|
||||
# вместе (31 074); контроль ЕКБ 559132 (4 060).
|
||||
# Объединённый скоуп безопасен именно потому, что region_code лота считается по
|
||||
# полигонам, а не по слагу развёртки: штамп региона не соврёт, даже когда в одном
|
||||
# прогоне приезжают лоты обоих регионов.
|
||||
"moskva": CityLocation("moskva_i_mo", 1, 587795, CIAN_MOSCOW_HOST, True),
|
||||
"moskovskaya_oblast": CityLocation("moskva_i_mo", 4593, 587654, CIAN_MOSCOW_HOST, True),
|
||||
"moskva_i_mo": CityLocation("moskva_i_mo", -1, 741964, CIAN_MOSCOW_HOST, True),
|
||||
}
|
||||
|
||||
|
||||
def get_city_location(city_slug: str | None) -> CityLocation | None:
|
||||
"""CityLocation по slug (CITY_LOCATIONS). Неизвестный slug/None → None (=ЕКБ-дефолт)."""
|
||||
"""CityLocation по slug (CITY_LOCATIONS). None → None (=ЕКБ-дефолт).
|
||||
|
||||
Неизвестный slug — ValueError, НЕ ЕКБ-дефолт: тихий откат означал бы, что
|
||||
развёртка с опечаткой в `default_params["city"]` собирает Екатеринбург под
|
||||
чужим именем города, со штатными счётчиками и без аномалий в логах.
|
||||
"""
|
||||
if city_slug is None:
|
||||
return None
|
||||
return CITY_LOCATIONS.get(city_slug)
|
||||
try:
|
||||
return CITY_LOCATIONS[city_slug]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"get_city_location: неизвестный city_slug={city_slug!r} — развёртка "
|
||||
"откатилась бы на ЕКБ. Заведи запись в CITY_LOCATIONS."
|
||||
) from None
|
||||
|
||||
|
||||
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
|
||||
|
|
@ -538,11 +748,19 @@ CITY_DISPLAY_NAMES: dict[str, str] = {
|
|||
"verkhoturye": "Верхотурье",
|
||||
"talitsa": "Талица",
|
||||
"novaya_lyalya": "Новая Ляля",
|
||||
"moskva": "Москва",
|
||||
}
|
||||
EKATERINBURG_CITY_NAME = "Екатеринбург"
|
||||
|
||||
# Скоупы, внутри которых лоты принадлежат РАЗНЫМ городам: один штамп city на такой
|
||||
# batch был бы ложью (у области это Химки/Балашиха/Подольск/…, у объединённого
|
||||
# скоупа — ещё и Москва). resolve_city_name возвращает для них None, save_listings
|
||||
# пишет city=NULL, и город доезжает позже из адреса (геокодер), а не из слага
|
||||
# развёртки. Регион определяется полигонами, поэтому region_code от этого не страдает.
|
||||
MULTI_CITY_SLUGS: frozenset[str] = frozenset({"moskovskaya_oblast", "moskva_i_mo"})
|
||||
|
||||
def resolve_city_name(city_slug: str | None) -> str:
|
||||
|
||||
def resolve_city_name(city_slug: str | None) -> str | None:
|
||||
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
|
||||
|
||||
city_slug=None → Екатеринбург. Это НЕ заглушка «не знаем» — это симметрия с
|
||||
|
|
@ -551,11 +769,26 @@ def resolve_city_name(city_slug: str | None) -> str:
|
|||
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург" —
|
||||
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ — нет».
|
||||
Неизвестный slug (не в CITY_DISPLAY_NAMES) — тоже ЕКБ-дефолт, тем же путём, что и
|
||||
get_city_location/get_city_anchors для неизвестных slug'ов.
|
||||
get_city_location/get_city_anchors (оба теперь падают на неизвестном slug'е).
|
||||
|
||||
Слаг из MULTI_CITY_SLUGS → None: в таком batch'е городов несколько, и любой
|
||||
один штамп был бы ложью (save_listings при city=None просто не трогает поле, а
|
||||
гео-гард штампа там инертен — см. `_geo_guard_active` в scraper_kit.base).
|
||||
Неизвестный slug → ValueError, а не «Екатеринбург»: молчаливый ЕКБ-штамп на
|
||||
лотах чужого города — ровно тот дефект, из-за которого сломанную развёртку вне
|
||||
ЕКБ было не отличить от исправной по логам.
|
||||
"""
|
||||
if city_slug is None:
|
||||
return EKATERINBURG_CITY_NAME
|
||||
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
|
||||
if city_slug in MULTI_CITY_SLUGS:
|
||||
return None
|
||||
try:
|
||||
return CITY_DISPLAY_NAMES[city_slug]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"resolve_city_name: неизвестный city_slug={city_slug!r} — раньше здесь "
|
||||
"молча возвращался 'Екатеринбург'. Заведи имя в CITY_DISPLAY_NAMES."
|
||||
) from None
|
||||
|
||||
|
||||
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
|
||||
|
|
@ -582,6 +815,19 @@ def resolve_city_name(city_slug: str | None) -> str:
|
|||
_DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
|
||||
_CITY_STAMP_RADIUS_KM: dict[str, float] = {
|
||||
"verkhnyaya_pyshma": 8.0,
|
||||
# Москва: гард меряет от ЦЕНТРА ядра (MOSCOW_ANCHORS[0] = 55.75/37.60 — центр
|
||||
# стоит первым специально, см. _build_moscow_anchor_grid). Дальний угол ядра —
|
||||
# hypot(22.25, 18.78) = 29.1 км, поэтому дефолтные 15 км срезали бы city у всего
|
||||
# за МКАД и части внутри неё (Бутово, Митино, Некрасовка, Солнцево — легитимная
|
||||
# Москва, ~20-27 км от центра). 30 км = 29.1 + ~0.9 км запаса на адреса, чьи
|
||||
# координаты чуть выходят за рамку окна.
|
||||
# Цена решения: в 30 км попадают и ближние города области (Химки ~20, Красногорск
|
||||
# ~16, Люберцы ~14, Реутов ~12 км) — их лоты, если их захватит московская выдача,
|
||||
# получат city="Москва". РЕГИОН при этом не врёт: region_code считается по
|
||||
# полигонам (app.services.regions.region_for_point), а не по штампу города.
|
||||
# Развёрток "moskovskaya_oblast"/"moskva_i_mo" это не касается вовсе: у них
|
||||
# city=None (MULTI_CITY_SLUGS), а при city=None гард в save_listings инертен.
|
||||
"moskva": 30.0,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1259,6 +1505,9 @@ async def run_avito_city_sweep(
|
|||
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
|
||||
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
|
||||
_avito_slug = _loc.avito_slug if _loc else city_slug
|
||||
# Слаг региона (moskva_i_mo) в ссылках карточек не встречается — per-card фильтр
|
||||
# по нему выбросил бы всю выдачу. См. CityLocation.avito_slug_is_region.
|
||||
_avito_card_filter = not (_loc.avito_slug_is_region if _loc is not None else False)
|
||||
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
|
||||
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
|
||||
_city_name = resolve_city_name(city_slug)
|
||||
|
|
@ -1406,7 +1655,10 @@ async def run_avito_city_sweep(
|
|||
# shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
|
||||
# переопределяет scraper._browser напрямую, минуя __aenter__.
|
||||
scraper = AvitoScraper(
|
||||
config, target_city_slug=_avito_slug, proxy_provider=proxy_provider
|
||||
config,
|
||||
target_city_slug=_avito_slug,
|
||||
proxy_provider=proxy_provider,
|
||||
filter_cards_by_city_slug=_avito_card_filter,
|
||||
)
|
||||
if browser_mode:
|
||||
scraper._browser = shared_bf
|
||||
|
|
@ -3078,6 +3330,7 @@ async def run_cian_city_sweep(
|
|||
config,
|
||||
proxy_provider=proxy_provider,
|
||||
city_region_id=(_loc.cian_region_id if _loc else None),
|
||||
city_host=(_loc.cian_host if _loc else None),
|
||||
) as scraper:
|
||||
anchor_lots = await scraper.fetch_around_multi_room(
|
||||
_a_lat,
|
||||
|
|
@ -4555,19 +4808,13 @@ async def run_domclick_city_sweep(
|
|||
|
||||
Возвращает DomClickCitySweepCounters.
|
||||
"""
|
||||
# Гео-скоуп этого свипа ЗАШИТ в ЕКБ: providers/domclick/serp.py строит BFF-URL с
|
||||
# _EKB_ADDRESS_GUID/_EKB_AREA_ID и прогревает контекст на _SWEEP_ORIGIN
|
||||
# (ekaterinburg.domclick.ru), а city_id — vestigial, fetch_city его игнорирует.
|
||||
# Строка расписания с region_code 77/50 поэтому собрала бы ЕКБ и проштамповала его
|
||||
# чужим регионом — молча, со штатными счётчиками. Падаем до первого запроса; гард
|
||||
# снимается вместе с параметризацией GUID/aids/origin по региону (GUID Москвы и МО
|
||||
# известны — scripts/local-avito-msk/collect.py MSK_DOMCLICK_GUID/MO_DOMCLICK_GUID).
|
||||
if region_code != DEFAULT_REGION_CODE:
|
||||
raise ValueError(
|
||||
f"domclick_city_sweep: region_code={region_code} не поддержан — гео-скоуп "
|
||||
"BFF-запроса зашит в ЕКБ (address GUID / aids / origin прогрева), прогон "
|
||||
"собрал бы ЕКБ под чужим регионом"
|
||||
)
|
||||
# Гео-скоуп свипа больше не зашит в ЕКБ: его задаёт профиль региона (address
|
||||
# GUID, aids, страница прогрева QRATOR, bbox гарда, имя города) — см.
|
||||
# providers/domclick/serp.py DOMCLICK_GEO_PROFILES. На неизвестном коде региона
|
||||
# падаем ДО первого запроса, а не откатываемся на ЕКБ: тихий fallback и есть тот
|
||||
# баг, ради которого гео-скоуп стал параметром — прогон собрал бы ЕКБ и записал
|
||||
# его под чужим region_code молча, со штатными счётчиками.
|
||||
_geo_profile = geo_profile_for_region(region_code)
|
||||
|
||||
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
|
||||
counters = DomClickCitySweepCounters()
|
||||
|
|
@ -4660,7 +4907,10 @@ async def run_domclick_city_sweep(
|
|||
"""Единственная citywide-фаза: fetch_city + save."""
|
||||
nonlocal lots
|
||||
async with DomClickScraper(
|
||||
config, proxy_provider=proxy_provider, cookies=cookies
|
||||
config,
|
||||
proxy_provider=proxy_provider,
|
||||
cookies=cookies,
|
||||
geo_profile=_geo_profile,
|
||||
) as _scraper:
|
||||
_scraper_ref.append(_scraper)
|
||||
if request_delay_sec is not None:
|
||||
|
|
@ -4687,11 +4937,10 @@ async def run_domclick_city_sweep(
|
|||
)
|
||||
counters.lots_fetched += len(lots)
|
||||
if lots:
|
||||
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
|
||||
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает
|
||||
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не
|
||||
# угадывается.
|
||||
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
|
||||
# Имя города — из профиля региона, а не из сравнения с vestigial
|
||||
# city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
|
||||
# У области (50) city_name=None — одного города нет, угадывать нечего.
|
||||
_dc_city = _geo_profile.city_name
|
||||
inserted, updated = save_listings(
|
||||
db,
|
||||
lots,
|
||||
|
|
|
|||
|
|
@ -1058,8 +1058,10 @@ async def _job_avito_city_sweep(
|
|||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
# #B1 oblast rollout: default_params["city"] (slug, e.g. "nizhniy_tagil") → anchors
|
||||
# города вместо EKB_ANCHORS. Отсутствует/неизвестен → get_city_anchors вернёт None →
|
||||
# run_avito_city_sweep сам падает на EKB_ANCHORS (прежнее поведение без city).
|
||||
# города вместо EKB_ANCHORS. Ключа нет вовсе → anchors=None → citywide-свип ЕКБ
|
||||
# (прежнее поведение расписаний без city). Ключ ЕСТЬ, но слаг неизвестен →
|
||||
# get_city_anchors падает с ValueError: опечатка в расписании раньше тихо давала
|
||||
# прогон по Екатеринбургу под именем чужого города.
|
||||
city = params.get("city")
|
||||
anchors = get_city_anchors(city) if city else None
|
||||
await run_avito_city_sweep(
|
||||
|
|
|
|||
|
|
@ -1,23 +1,63 @@
|
|||
"""scraper_kit.pricing — общие примитивы ценового партиционирования SERP-обхода.
|
||||
|
||||
Единый движок адаптивной бисекции «комнатность × цена» для всех провайдеров
|
||||
(avito / cian / yandex / domclick). См. `bisection.py`.
|
||||
Два слоя, намеренно разделённые:
|
||||
|
||||
* `bisection.py` — боевой async-движок обхода `walk_price_range`: ходит в сеть
|
||||
через provider-callback'и, пагинирует листья. `BisectionStats` — счётчики
|
||||
усечения для уже существующих свипов.
|
||||
* `planner.py` — СИНХРОННЫЙ ЧИСТЫЙ планировщик `plan_price_corridors`: на вход
|
||||
«сколько позиций в коридоре», на выход список коридоров + `PlanStats`.
|
||||
Геометрическая середина `sqrt(lo*hi)` (цены логнормальны) и предохранители
|
||||
`MAX_DEPTH` / `MIN_WIDTH_RATIO`; перенесён из обкатанного живьём
|
||||
`scripts/local-avito-msk/collect.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from scraper_kit.pricing.bisection import (
|
||||
BisectionConfig,
|
||||
BisectionStats,
|
||||
ProbeFailPolicy,
|
||||
ProbeResult,
|
||||
done_range_skipper,
|
||||
walk_price_range,
|
||||
)
|
||||
from scraper_kit.pricing.planner import (
|
||||
EKB_REGION_CODE,
|
||||
MAX_DEPTH,
|
||||
MIN_WIDTH_RATIO,
|
||||
PRICE_CEIL,
|
||||
PRICE_FLOOR,
|
||||
PRICE_PROBE_START,
|
||||
Corridor,
|
||||
CountFn,
|
||||
PlanStats,
|
||||
PricePlan,
|
||||
geometric_mid,
|
||||
plan_price_corridors,
|
||||
price_bisection_enabled,
|
||||
width_ratio,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EKB_REGION_CODE",
|
||||
"MAX_DEPTH",
|
||||
"MIN_WIDTH_RATIO",
|
||||
"PRICE_CEIL",
|
||||
"PRICE_FLOOR",
|
||||
"PRICE_PROBE_START",
|
||||
"BisectionConfig",
|
||||
"BisectionStats",
|
||||
"Corridor",
|
||||
"CountFn",
|
||||
"PlanStats",
|
||||
"PricePlan",
|
||||
"ProbeFailPolicy",
|
||||
"ProbeResult",
|
||||
"done_range_skipper",
|
||||
"geometric_mid",
|
||||
"plan_price_corridors",
|
||||
"price_bisection_enabled",
|
||||
"walk_price_range",
|
||||
"width_ratio",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -88,6 +88,63 @@ class ProbeResult:
|
|||
payload: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BisectionStats:
|
||||
"""Счётчики одного обхода — чтобы усечение выдачи было ЧИСЛОМ, а не тишиной.
|
||||
|
||||
Движок делит диапазон, пока `count > cap`, но деление не бесконечно: упёрлись
|
||||
в `min_bracket` или в `max_depth` — и переполненный диапазон всё равно уходит
|
||||
в `on_leaf`, где пагинация штатно дорежет его до `cap`. Разница `count - cap`
|
||||
в этот момент теряется БЕЗ СЛЕДА: по Свердловской области это никогда не
|
||||
срабатывало (вторичка влезает в сид-брекеты), а по Москве и области, где
|
||||
лотов десятки тысяч, прогон забирал верхушку и рапортовал успех.
|
||||
|
||||
Объект МУТАБЕЛЕН и передаётся в `walk_price_range` опционально: по умолчанию
|
||||
``None`` — ни одной новой ветки в обходе, дерево рекурсии байт-в-байт прежнее
|
||||
(golden-parity провайдеров не двигается). Вызывающий складывает
|
||||
`missed_estimate` в счётчики прогона.
|
||||
"""
|
||||
|
||||
#: Сколько раз дёрнули provider-probe (стоимость планирования в запросах).
|
||||
probes: int = 0
|
||||
#: Сколько диапазонов дошло до пагинации.
|
||||
leaves: int = 0
|
||||
#: Листья, где `count > cap` — поделить не вышло, выдача будет обрезана.
|
||||
truncated_leaves: int = 0
|
||||
#: Оценка недобора: сумма `count - cap` по усечённым листьям.
|
||||
missed_estimate: int = 0
|
||||
#: Листья с непрочитанным счётчиком (полнота недоказуема, не то же, что 0).
|
||||
unknown_leaves: int = 0
|
||||
#: Диапазоны, брошенные по ProbeFailPolicy.SKIP — тоже дыра в покрытии.
|
||||
skipped_probe_fail: int = 0
|
||||
#: Переходы в «пагинировать-до-пустоты» без знания total (yandex DEGRADE).
|
||||
degraded: int = 0
|
||||
max_depth_reached: int = 0
|
||||
|
||||
def record_probe(self, depth: int) -> None:
|
||||
self.probes += 1
|
||||
self.max_depth_reached = max(self.max_depth_reached, depth)
|
||||
|
||||
def record_leaf(self, count: int | None, cap: int) -> None:
|
||||
self.leaves += 1
|
||||
if count is None:
|
||||
self.unknown_leaves += 1
|
||||
return
|
||||
if count > cap:
|
||||
self.truncated_leaves += 1
|
||||
self.missed_estimate += count - cap
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Обход покрыл выборку целиком — ни усечений, ни пропусков, ни слепых листьев."""
|
||||
return (
|
||||
self.truncated_leaves == 0
|
||||
and self.unknown_leaves == 0
|
||||
and self.skipped_probe_fail == 0
|
||||
and self.degraded == 0
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BisectionConfig:
|
||||
"""Per-provider константы/политики бисекции (все различия провайдеров тут).
|
||||
|
|
@ -222,6 +279,7 @@ async def walk_price_range(
|
|||
on_leaf: LeafFn,
|
||||
on_degraded: DegradedFn | None = None,
|
||||
should_skip: SkipFn | None = None,
|
||||
stats: BisectionStats | None = None,
|
||||
depth: int = 0,
|
||||
) -> None:
|
||||
"""Рекурсивно обойти ценовой диапазон [lo, hi] адаптивной бисекцией.
|
||||
|
|
@ -249,6 +307,10 @@ async def walk_price_range(
|
|||
(:func:`done_range_skipper`, #3315). Проверяется на КАЖДОМ узле
|
||||
рекурсии: у частично покрытого диапазона probe остаётся, а его уже
|
||||
готовые половины отсекаются на входе, а не в листе после probe.
|
||||
stats: опциональный :class:`BisectionStats` — мутабельный аккумулятор
|
||||
probe/leaf/усечений. ``None`` (умолчание) = ни одной новой ветки в
|
||||
обходе. Передан — вызывающий получает `missed_estimate` и обязан
|
||||
донести его до счётчиков прогона, иначе усечение снова станет тишиной.
|
||||
depth: текущая глубина рекурсии (для ``max_depth``).
|
||||
"""
|
||||
if should_skip is not None and should_skip(lo, hi):
|
||||
|
|
@ -256,22 +318,34 @@ async def walk_price_range(
|
|||
|
||||
result = await probe(lo, hi, depth)
|
||||
total = result.count
|
||||
if stats is not None:
|
||||
stats.record_probe(depth)
|
||||
|
||||
# ── Probe-fail: счётчик недоступен ────────────────────────────────────────
|
||||
if total is None:
|
||||
policy = config.probe_fail_policy
|
||||
if policy is ProbeFailPolicy.SKIP:
|
||||
if stats is not None:
|
||||
stats.skipped_probe_fail += 1
|
||||
return
|
||||
if policy is ProbeFailPolicy.DEGRADE:
|
||||
if stats is not None:
|
||||
stats.degraded += 1
|
||||
if on_degraded is not None:
|
||||
await on_degraded(lo, hi)
|
||||
return
|
||||
# SPLIT_OR_SKIP (avito): открытый брекет пагинируем best-effort; закрытый
|
||||
# ещё делимый — принудительно делим; иначе пропускаем (хвост теряем).
|
||||
if hi is None:
|
||||
if stats is not None:
|
||||
stats.record_leaf(None, config.cap)
|
||||
await on_leaf(lo, hi, result)
|
||||
return
|
||||
_lo = lo if lo is not None else config.open_split_floor
|
||||
if not _can_split_span(hi - _lo, config) and stats is not None:
|
||||
# Закрытый неделимый диапазон без счётчика просто бросается — это
|
||||
# такая же дыра в покрытии, как SKIP, и она обязана быть видна.
|
||||
stats.skipped_probe_fail += 1
|
||||
if _can_split_span(hi - _lo, config):
|
||||
mid = (_lo + hi) // 2
|
||||
await walk_price_range(
|
||||
|
|
@ -282,6 +356,7 @@ async def walk_price_range(
|
|||
on_leaf=on_leaf,
|
||||
on_degraded=on_degraded,
|
||||
should_skip=should_skip,
|
||||
stats=stats,
|
||||
depth=depth + 1,
|
||||
)
|
||||
await walk_price_range(
|
||||
|
|
@ -292,6 +367,7 @@ async def walk_price_range(
|
|||
on_leaf=on_leaf,
|
||||
on_degraded=on_degraded,
|
||||
should_skip=should_skip,
|
||||
stats=stats,
|
||||
depth=depth + 1,
|
||||
)
|
||||
return
|
||||
|
|
@ -303,6 +379,8 @@ async def walk_price_range(
|
|||
# Открытый брекет без split-потолка (avito/cian/yandex): делить нельзя —
|
||||
# пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
|
||||
if hi is None and config.open_split_ceiling is None:
|
||||
if stats is not None:
|
||||
stats.record_leaf(total, config.cap)
|
||||
await on_leaf(lo, hi, result)
|
||||
return
|
||||
|
||||
|
|
@ -329,6 +407,7 @@ async def walk_price_range(
|
|||
on_leaf=on_leaf,
|
||||
on_degraded=on_degraded,
|
||||
should_skip=should_skip,
|
||||
stats=stats,
|
||||
depth=depth + 1,
|
||||
)
|
||||
await walk_price_range(
|
||||
|
|
@ -339,9 +418,15 @@ async def walk_price_range(
|
|||
on_leaf=on_leaf,
|
||||
on_degraded=on_degraded,
|
||||
should_skip=should_skip,
|
||||
stats=stats,
|
||||
depth=depth + 1,
|
||||
)
|
||||
return
|
||||
|
||||
# Leaf-бакет: пагинация + дедуп + on_bucket — целиком на стороне провайдера.
|
||||
# Сюда же приезжает переполненный диапазон, который НЕ дали поделить
|
||||
# min_bracket / max_depth: пагинация дорежет его до cap, и без stats разница
|
||||
# count-cap пропала бы бесследно.
|
||||
if stats is not None:
|
||||
stats.record_leaf(total, config.cap)
|
||||
await on_leaf(lo, hi, result)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
"""Чистый планировщик ценовых коридоров под жёсткий потолок выдачи площадки.
|
||||
|
||||
ЗАЧЕМ. У каждой площадки жёсткий cap на набор фильтров: Avito ~1500 позиций,
|
||||
Cian 1512, Yandex 500, DomClick 2000. По Свердловской области это не мешало —
|
||||
вторичка целиком влезает в сид-брекеты (`scraper_kit.price_brackets`). По Москве
|
||||
и области лотов десятки тысяч (Cian 153k на объединённом скоупе, Yandex 31k,
|
||||
DomClick 17.5k по области), и без дробления по цене прогон ТИХО забирает верхушку
|
||||
выдачи и рапортует успех. Этот модуль превращает молчаливое усечение в число.
|
||||
|
||||
ЧЕМ ОТЛИЧАЕТСЯ ОТ `pricing.bisection`. `walk_price_range` — боевой async-движок
|
||||
обхода: он ходит в сеть, пагинирует листья и завязан на provider-callback'и, а
|
||||
его арифметика деления — `mid = (lo + hi) // 2`. Здесь СИНХРОННАЯ ЧИСТАЯ функция:
|
||||
на вход «сколько позиций в коридоре», на выход список коридоров + честная
|
||||
статистика. Ни одного сетевого вызова внутри, поэтому она тестируется как
|
||||
обычная функция и её можно прогнать на модели распределения до прогона.
|
||||
|
||||
Алгоритм перенесён из обкатанного живьём `scripts/local-avito-msk/collect.py`
|
||||
(:245-249, :303-336, :1408-1434) и сохраняет три его решения:
|
||||
|
||||
1. **Геометрическая середина** `sqrt(lo*hi)`, а не арифметическая. Цены
|
||||
логнормальны: арифметическая середина коридора 1М..100М (≈50М) отрезает
|
||||
вырожденно-пустую верхнюю половину, и дерево вырождается в лестницу —
|
||||
деление идёт, а переполненный левый край так и не разъезжается.
|
||||
2. **Верхняя граница ищется удвоением** от `probe_start` (8 млн ₽), а не берётся
|
||||
константой: потолок рынка по региону заранее неизвестен.
|
||||
3. **Два предохранителя** — `MAX_DEPTH` и `MIN_WIDTH_RATIO`. Неделимый
|
||||
переполненный коридор НЕ молчит: он помечается `truncated` и его недобор
|
||||
попадает в `PlanStats.missed_estimate`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Нижняя граница осмысленного коридора, ₽ (подстановка вместо None-lo в арифметике).
|
||||
PRICE_FLOOR = 500_000
|
||||
# Старт удвоения при поиске верхней границы открытого коридора, ₽.
|
||||
PRICE_PROBE_START = 8_000_000
|
||||
# Абсолютный потолок поиска верхней границы, ₽.
|
||||
PRICE_CEIL = 2_000_000_000
|
||||
# Уже этого (по ГЕОМЕТРИЧЕСКОЙ ширине hi/lo) коридор не делим.
|
||||
MIN_WIDTH_RATIO = 1.05
|
||||
# Жёсткий предел глубины рекурсии: гарантия остановки, не более 2^12 листьев.
|
||||
MAX_DEPTH = 12
|
||||
|
||||
# Свердловская область: текущий боевой скоуп, где бисекция не нужна (см.
|
||||
# `price_bisection_enabled`).
|
||||
EKB_REGION_CODE = 66
|
||||
|
||||
#: «Сколько всего позиций в коридоре [lo, hi]». ``None`` — счётчик не прочитался
|
||||
#: (captcha / битая страница), это НЕ ноль. Границы включительные, ``None`` = без
|
||||
#: границы.
|
||||
CountFn = Callable[[int | None, int | None], int | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Corridor:
|
||||
"""Один ценовой коридор плана."""
|
||||
|
||||
lo: int | None
|
||||
hi: int | None
|
||||
count: int | None = None
|
||||
#: Коридор заведомо не влезает в потолок площадки и поделить его уже нельзя.
|
||||
truncated: bool = False
|
||||
#: Оценка недобора: ``count - hard_cap`` (0, если count неизвестен).
|
||||
missed: int = 0
|
||||
depth: int = 0
|
||||
#: Счётчик не прочитался — коридор уходит в прогон, но за полноту не ручаемся.
|
||||
unknown_count: bool = False
|
||||
|
||||
def label(self) -> str:
|
||||
lo = "-" if self.lo is None else f"{self.lo:_}"
|
||||
hi = "-" if self.hi is None else f"{self.hi:_}"
|
||||
return f"[{lo} .. {hi}]"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanStats:
|
||||
"""Честная сводка по плану. Усечение обязано быть видно здесь, а не в логе."""
|
||||
|
||||
corridors: int = 0
|
||||
truncated_corridors: int = 0
|
||||
#: Сколько позиций заведомо не будет собрано — сумма `missed` по усечённым.
|
||||
missed_estimate: int = 0
|
||||
#: Коридоры с непрочитанным счётчиком (полнота недоказуема).
|
||||
unknown_corridors: int = 0
|
||||
#: Сколько раз дёрнули `count_of` (стоимость планирования в запросах).
|
||||
probes: int = 0
|
||||
max_depth_reached: int = 0
|
||||
#: Счётчик по корневому запросу — сколько всего в скоупе.
|
||||
root_count: int | None = None
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""План покрывает выборку целиком (ни усечений, ни слепых коридоров)."""
|
||||
return self.truncated_corridors == 0 and self.unknown_corridors == 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PricePlan:
|
||||
corridors: list[Corridor] = field(default_factory=list)
|
||||
stats: PlanStats = field(default_factory=PlanStats)
|
||||
|
||||
@property
|
||||
def truncated(self) -> list[Corridor]:
|
||||
return [c for c in self.corridors if c.truncated]
|
||||
|
||||
|
||||
def geometric_mid(lo: int | None, hi: int, *, price_floor: int = PRICE_FLOOR) -> int:
|
||||
"""Геометрическая середина коридора: ``sqrt(lo*hi)``.
|
||||
|
||||
Цены логнормальны, поэтому арифметическая середина делит ВЫБОРКУ крайне
|
||||
неровно. Результат зажат в ``[lo+1, hi-1]``, чтобы деление всегда давало две
|
||||
непустые половины и рекурсия не могла зациклиться на границе.
|
||||
|
||||
ПРЕДУСЛОВИЕ: ``hi - lo >= 2``. У атомарного коридора (``hi - lo <= 1``)
|
||||
строгой внутренней точки не существует вовсе, и зажим вернул бы саму границу
|
||||
— половина совпала бы с родителем, то есть рекурсия по себе. Делимость
|
||||
проверяет вызывающий (`plan_price_corridors`), см. там ветку `upper - eff_lo
|
||||
<= 1`; в исходнике `collect.py` этой проверки нет, и от бесконечного спуска
|
||||
его спасает только MAX_DEPTH.
|
||||
"""
|
||||
low = max(int(lo or price_floor), 1)
|
||||
mid = int(math.sqrt(low * float(hi)))
|
||||
return max(low + 1, min(hi - 1, mid))
|
||||
|
||||
|
||||
def width_ratio(lo: int | None, hi: int | None, *, price_floor: int = PRICE_FLOOR) -> float:
|
||||
"""Геометрическая ширина коридора ``hi/lo`` (``inf`` для открытого верха)."""
|
||||
if hi is None:
|
||||
return float("inf")
|
||||
return float(hi) / max(float(lo or price_floor), 1.0)
|
||||
|
||||
|
||||
def price_bisection_enabled(region_code: int | None) -> bool:
|
||||
"""Нужна ли ценовая бисекция для региона.
|
||||
|
||||
УМОЛЧАНИЕ — ВЫКЛЮЧЕНО ДЛЯ 66 (Свердловская обл.) И ДЛЯ НЕИЗВЕСТНОГО РЕГИОНА.
|
||||
Причина не в том, что бисекция там вредна, а в том, что она там НЕ НУЖНА и
|
||||
поэтому её включение было бы чистым регрессом: вторичка области целиком
|
||||
влезает в сид-брекеты `price_brackets`, счётчик ни в одном брекете не бьётся
|
||||
в cap, и каждый лишний probe — это лишний запрос к площадке под антибот. То
|
||||
есть для 66 план обязан быть РОВНО ТЕМ ЖЕ обходом, что и сегодня.
|
||||
|
||||
``None`` (регион не передали) трактуем как 66, а не как «включить»: провайдеры
|
||||
фоллбэчат на ЕКБ-дефолт, когда регион не задан, и «безопаснее включить»
|
||||
здесь означало бы молча поменять боевое поведение прогонов, которые просто
|
||||
не прокинули параметр.
|
||||
|
||||
Для всех остальных регионов — включено: именно там (77/50 — Москва и область)
|
||||
выдача кратно превышает cap площадки.
|
||||
"""
|
||||
if region_code is None:
|
||||
return False
|
||||
return int(region_code) != EKB_REGION_CODE
|
||||
|
||||
|
||||
def plan_price_corridors(
|
||||
*,
|
||||
count_of: CountFn,
|
||||
target: int,
|
||||
hard_cap: int | None = None,
|
||||
lo: int | None = None,
|
||||
hi: int | None = None,
|
||||
enabled: bool = True,
|
||||
max_depth: int = MAX_DEPTH,
|
||||
min_width_ratio: float = MIN_WIDTH_RATIO,
|
||||
price_floor: int = PRICE_FLOOR,
|
||||
probe_start: int = PRICE_PROBE_START,
|
||||
price_ceil: int = PRICE_CEIL,
|
||||
) -> PricePlan:
|
||||
"""Разбить ценовой диапазон на коридоры, влезающие в потолок выдачи.
|
||||
|
||||
Args:
|
||||
count_of: «сколько позиций в [lo, hi]»; ``None`` — счётчик не прочитался.
|
||||
target: целевой размер коридора. Берётся С ЗАПАСОМ ниже ``hard_cap``:
|
||||
счётчик площадки врёт в обе стороны и выдача плывёт между probe и
|
||||
пагинацией, поэтому коридор ровно в cap приезжает усечённым.
|
||||
hard_cap: жёсткий потолок выдачи площадки; по умолчанию ``target``.
|
||||
Недобор считается от него, а не от ``target``.
|
||||
lo / hi: границы корневого диапазона (``None`` = без границы).
|
||||
enabled: ``False`` -> ровно ОДИН коридор на весь диапазон, ноль делений
|
||||
(режим региона 66, см. :func:`price_bisection_enabled`). Корневой
|
||||
probe всё равно делается — чтобы усечение, если оно там есть, попало
|
||||
в статистику, а не осталось незамеченным.
|
||||
max_depth / min_width_ratio: предохранители, см. модульный docstring.
|
||||
|
||||
Returns:
|
||||
:class:`PricePlan` — коридоры в порядке обхода + :class:`PlanStats`.
|
||||
"""
|
||||
cap = hard_cap if hard_cap is not None else target
|
||||
corridors: list[Corridor] = []
|
||||
probes = 0
|
||||
deepest = 0
|
||||
|
||||
def probe(p_lo: int | None, p_hi: int | None) -> int | None:
|
||||
nonlocal probes
|
||||
probes += 1
|
||||
return count_of(p_lo, p_hi)
|
||||
|
||||
def emit(c_lo: int | None, c_hi: int | None, count: int | None, depth: int) -> None:
|
||||
nonlocal deepest
|
||||
deepest = max(deepest, depth)
|
||||
if count is None:
|
||||
# Слепой коридор: собираем, но полноту не декларируем.
|
||||
corridors.append(
|
||||
Corridor(
|
||||
lo=c_lo,
|
||||
hi=c_hi,
|
||||
count=None,
|
||||
truncated=True,
|
||||
missed=0,
|
||||
depth=depth,
|
||||
unknown_count=True,
|
||||
)
|
||||
)
|
||||
return
|
||||
over = count > cap
|
||||
corridors.append(
|
||||
Corridor(
|
||||
lo=c_lo,
|
||||
hi=c_hi,
|
||||
count=count,
|
||||
truncated=over,
|
||||
missed=max(0, count - cap) if over else 0,
|
||||
depth=depth,
|
||||
)
|
||||
)
|
||||
|
||||
def find_upper(f_lo: int | None) -> int:
|
||||
"""Верхнюю границу открытого коридора ищем удвоением от probe_start."""
|
||||
cand = max(int(f_lo or price_floor) * 2, probe_start)
|
||||
while cand < price_ceil:
|
||||
cnt = probe(cand, None)
|
||||
if cnt is not None and cnt <= target:
|
||||
return cand
|
||||
cand *= 2
|
||||
return cand
|
||||
|
||||
def split(s_lo: int | None, s_hi: int | None, depth: int, count: int | None) -> None:
|
||||
if count is None or count <= target:
|
||||
emit(s_lo, s_hi, count, depth)
|
||||
return
|
||||
too_narrow = width_ratio(s_lo, s_hi, price_floor=price_floor) <= min_width_ratio
|
||||
if depth >= max_depth or too_narrow:
|
||||
# Предохранитель сработал: делить больше нельзя. НЕ молчим — коридор
|
||||
# уезжает в план помеченным, недобор попадает в missed_estimate.
|
||||
emit(s_lo, s_hi, count, depth)
|
||||
return
|
||||
upper = s_hi if s_hi is not None else find_upper(s_lo)
|
||||
if upper - (s_lo if s_lo is not None else price_floor) <= 1:
|
||||
# Атомарный коридор: строгой внутренней точки нет, делить нечем.
|
||||
# Третий предохранитель рядом с MAX_DEPTH / MIN_WIDTH_RATIO — при
|
||||
# дефолтном min_width_ratio=1.05 недостижим, но min_width_ratio
|
||||
# параметризуем, и без этой ветки деление ушло бы в рекурсию по себе.
|
||||
emit(s_lo, s_hi, count, depth)
|
||||
return
|
||||
if s_hi is None:
|
||||
# Хвост выше найденной границы закрывается отдельным коридором:
|
||||
# иначе верх рынка просто выпал бы из плана.
|
||||
emit(upper, None, probe(upper, None), depth + 1)
|
||||
mid = geometric_mid(s_lo, upper, price_floor=price_floor)
|
||||
for sub_lo, sub_hi in ((s_lo, mid), (mid, upper)):
|
||||
split(sub_lo, sub_hi, depth + 1, probe(sub_lo, sub_hi))
|
||||
|
||||
root_count = probe(lo, hi)
|
||||
if enabled:
|
||||
split(lo, hi, 0, root_count)
|
||||
else:
|
||||
emit(lo, hi, root_count, 0)
|
||||
|
||||
truncated = [c for c in corridors if c.truncated]
|
||||
stats = PlanStats(
|
||||
corridors=len(corridors),
|
||||
truncated_corridors=len(truncated),
|
||||
missed_estimate=sum(c.missed for c in truncated),
|
||||
unknown_corridors=sum(1 for c in corridors if c.unknown_count),
|
||||
probes=probes,
|
||||
max_depth_reached=deepest,
|
||||
root_count=root_count,
|
||||
)
|
||||
return PricePlan(corridors=corridors, stats=stats)
|
||||
|
|
@ -398,6 +398,7 @@ class AvitoScraper(BaseScraper):
|
|||
delay_provider: Callable[[str], float] | None = None,
|
||||
target_city_slug: str | None = None,
|
||||
proxy_provider: ProxyProvider | None = None,
|
||||
filter_cards_by_city_slug: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
|
||||
|
|
@ -420,6 +421,14 @@ class AvitoScraper(BaseScraper):
|
|||
# None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only,
|
||||
# чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/.
|
||||
self._target_city_slug = target_city_slug
|
||||
# Слаг РЕГИОНА (moskva_i_mo покрывает Москву и всю область одним скоупом) в
|
||||
# ссылках карточек не встречается — там /moskva/, /himki/, /balashiha/… .
|
||||
# Поэтому per-card фильтр по слагу города-цели на таком скоупе выбросил бы
|
||||
# 100% выдачи (замер 10-12.09.2026). False — фильтр выключен ДЛЯ ЭТОГО
|
||||
# прогона, независимо от глобального config.avito_serp_ekb_only: флаг
|
||||
# настройки говорит «отбрасывать добивку по всей России», а не «собирать
|
||||
# только Екатеринбург», и на региональном слаге он неприменим физически.
|
||||
self._filter_cards_by_city_slug = filter_cards_by_city_slug
|
||||
# #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время
|
||||
# fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы
|
||||
# остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча.
|
||||
|
|
@ -2111,7 +2120,7 @@ class AvitoScraper(BaseScraper):
|
|||
ts_map = _build_sort_timestamp_map(html)
|
||||
cards = tree.css('[data-marker="item"]')
|
||||
lots: list[ScrapedLot] = []
|
||||
ekb_only = self._config.avito_serp_ekb_only
|
||||
ekb_only = self._config.avito_serp_ekb_only and self._filter_cards_by_city_slug
|
||||
# #2487: слаг города, карточки которого оставляем. Дефолт "ekaterinburg" —
|
||||
# ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load).
|
||||
kept_slug = self._target_city_slug or "ekaterinburg"
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ _MIN_BRACKET = 50_000
|
|||
# Cian SERP показывает ~28 офферов на страницу.
|
||||
_CIAN_OFFERS_PER_PAGE = 28
|
||||
|
||||
|
||||
# Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся
|
||||
# per-call (price_cap_per_bucket) — здесь только структурные константы/политики.
|
||||
# probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается.
|
||||
|
|
@ -97,6 +98,7 @@ def _cian_bisection_config(cap: int) -> BisectionConfig:
|
|||
probe_fail_policy=ProbeFailPolicy.SKIP,
|
||||
)
|
||||
|
||||
|
||||
# Комнатности по умолчанию для exhaustive-загрузки.
|
||||
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
|
||||
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
|
||||
|
|
@ -140,6 +142,7 @@ class CianScraper(BaseScraper):
|
|||
delay_provider: Callable[[str], float] | None = None,
|
||||
proxy_provider: ProxyProvider | None = None,
|
||||
city_region_id: int | None = None,
|
||||
city_host: str | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
|
||||
|
|
@ -153,9 +156,17 @@ class CianScraper(BaseScraper):
|
|||
# None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас).
|
||||
self._proxy_provider = proxy_provider
|
||||
self._browser: BrowserFetcher | None = None
|
||||
# #12 (oblast rollout): region= города-цели SERP-запроса (ekb.cian.ru — общий
|
||||
# поддомен всей Свердловской обл., НЕ меняется). None → ЕКБ-дефолт (CIAN_EKB_REGION_ID).
|
||||
# #12 (oblast rollout): region= города-цели SERP-запроса. None → ЕКБ-дефолт
|
||||
# (CIAN_EKB_REGION_ID).
|
||||
self._region_id = city_region_id or CIAN_EKB_REGION_ID
|
||||
# Поддомен Циана — РЕГИОНАЛЬНЫЙ, а не городской: ekb.cian.ru общий для всей
|
||||
# Свердловской обл. (поэтому раньше был захардкожен классовым base_url), но
|
||||
# Москву и область он не обслуживает — там www.cian.ru. Приходит из реестра
|
||||
# города (CityLocation.cian_host в orchestration/pipeline.py) вместе с region=,
|
||||
# чтобы хост и region не разъехались. None → класс-дефолт ЕКБ: URL прежних
|
||||
# свердловских развёрток остаются байт в байт.
|
||||
if city_host is not None:
|
||||
self.base_url = city_host
|
||||
# #2625: счётчики Redux-state extraction за время жизни этого scraper-инстанса.
|
||||
# Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены
|
||||
# вёрстки (state extraction провалилась → None). Читаются вызывающим кодом
|
||||
|
|
@ -296,8 +307,9 @@ class CianScraper(BaseScraper):
|
|||
) -> str:
|
||||
"""URL для Cian каталога вторички города-цели (по умолчанию ЕКБ).
|
||||
|
||||
Используем ekb.cian.ru (city-specific subdomain — общий для всей Свердловской
|
||||
обл., НЕ меняется per-city). region= param — фактический скоуп выборки
|
||||
Хост берётся из self.base_url (региональный поддомен: ekb.cian.ru — вся
|
||||
Свердловская обл., www.cian.ru — Москва и МО; задаётся city_host в __init__,
|
||||
по умолчанию ЕКБ). region= param — фактический скоуп выборки
|
||||
(#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан).
|
||||
min_price / max_price — опциональные ценовые границы (руб.) для price-бакетов.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import hashlib
|
|||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlencode
|
||||
|
|
@ -49,8 +50,6 @@ logger = logging.getLogger(__name__)
|
|||
# ── API constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
_BFF_BASE = "https://bff-search-web.domclick.ru"
|
||||
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
|
||||
_EKB_AREA_ID = "20561"
|
||||
|
||||
# Buckets to sweep — порядок влияет на логи.
|
||||
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
|
||||
|
|
@ -81,13 +80,123 @@ _DOMCLICK_BISECTION = BisectionConfig(
|
|||
open_split_floor=0,
|
||||
)
|
||||
|
||||
# ── EKB geo guard ─────────────────────────────────────────────────────────────
|
||||
# ── Гео-профиль региона ───────────────────────────────────────────────────────
|
||||
|
||||
_EKB_LAT_MIN: float = 56.6
|
||||
_EKB_LAT_MAX: float = 57.0
|
||||
_EKB_LON_MIN: float = 60.2
|
||||
_EKB_LON_MAX: float = 60.9
|
||||
_EKB_REGION_NAME: str = "Екатеринбург"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomClickGeoProfile:
|
||||
"""Всё, что отличает свип одного региона от свипа другого.
|
||||
|
||||
Раньше эти величины были модульными константами `_EKB_*`, и прогон с
|
||||
region_code != 66 собрал бы Екатеринбург, проштамповав его чужим регионом.
|
||||
|
||||
Поля:
|
||||
address_guid — параметр `address` BFF-запроса (регион/локалити DomClick).
|
||||
aids — параметр `aids` (сужение выдачи). ``None`` = параметр НЕ
|
||||
добавлять: у Москвы и области счётчик без него совпадает
|
||||
с сайтом (замер 12.09.2026), у ЕКБ aids=20561 обязателен.
|
||||
origin_url — городская страница выдачи, на которой прогревается
|
||||
контекст перед запросом к BFF (#3264). BFF — не страница,
|
||||
а JSON-ручка SPA: навигацией на неё приходит только
|
||||
ChallengeTimeout, а подзапросом из прогретой выдачи —
|
||||
HTTP 200. Прогревать нужно СВОЙ город.
|
||||
bbox — (lat_min, lat_max, lon_min, lon_max) гео-гарда выдачи.
|
||||
region_name — ожидаемый ``item["offerRegionName"]``. ``None`` = НЕ
|
||||
сверять имя, гард только по bbox: вне ЕКБ поле ненадёжно,
|
||||
офферы Новой Москвы приходят как «г. Говорово», а не
|
||||
«Москва» (замер 12.09.2026).
|
||||
city_name — что писать в listings.city. ``None`` = не угадывать
|
||||
(у области нет одного города).
|
||||
"""
|
||||
|
||||
region_code: int
|
||||
address_guid: str
|
||||
aids: str | None
|
||||
origin_url: str
|
||||
bbox: tuple[float, float, float, float]
|
||||
region_name: str | None
|
||||
city_name: str | None
|
||||
|
||||
|
||||
# Реестр профилей по коду региона.
|
||||
#
|
||||
# bbox для 77 и 50 — ДУБЛЬ значений backend/app/services/regions.py
|
||||
# REGIONS[code].bbox_region (порядок тот же: lat_min, lat_max, lon_min, lon_max).
|
||||
# Дубль намеренный и неустранимый: kit не имеет права импортировать app.* (это
|
||||
# гейтится тестом), а общего места у kit и backend нет. Правишь bbox_region
|
||||
# там — поправь здесь.
|
||||
#
|
||||
# bbox для 66 — НЕ bbox_region Свердловской области, а исторический ГОРОДСКОЙ
|
||||
# bbox ЕКБ-гарда: свип 66 citywide по Екатеринбургу, а не по всей области.
|
||||
# Значения 66 (GUID, aids, origin, bbox, имя региона) сохранены байт в байт,
|
||||
# чтобы параметризация не изменила поведение прод-прогонов.
|
||||
DOMCLICK_GEO_PROFILES: dict[int, DomClickGeoProfile] = {
|
||||
66: DomClickGeoProfile(
|
||||
region_code=66,
|
||||
address_guid="0d475b79-88de-4054-818c-37d8f9d0d440",
|
||||
aids="20561",
|
||||
origin_url="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
bbox=(56.6, 57.0, 60.2, 60.9),
|
||||
region_name="Екатеринбург",
|
||||
city_name="Екатеринбург",
|
||||
),
|
||||
# GUID Москвы и области проверены живым запросом 12.09.2026 (у Москвы region и
|
||||
# locality — один и тот же GUID), см. scripts/local-avito-msk/collect.py
|
||||
# MSK_DOMCLICK_GUID / MO_DOMCLICK_GUID. aids для обоих НЕ нужен: без него
|
||||
# счётчик листинга совпадает с сайтом.
|
||||
#
|
||||
# Страница прогрева 77/50 — апексный домен, проверено запросом 15.09.2026:
|
||||
# msk.domclick.ru -> 301 https://domclick.ru/...
|
||||
# moskovskaya-oblast.domclick.ru-> 301 https://domclick.ru/...
|
||||
# domclick.ru -> 401 (QRATOR-челлендж, как у ЕКБ)
|
||||
# ekaterinburg.domclick.ru -> 401 (контроль)
|
||||
# Схема `<город>.domclick.ru` работает НЕ для всех городов: у Москвы своего
|
||||
# субдомена нет, апекс и есть московская выдача. 301 увёл бы прогрев на
|
||||
# другой URL и с большой вероятностью потерял бы куку QRATOR.
|
||||
77: DomClickGeoProfile(
|
||||
region_code=77,
|
||||
address_guid="1d1463ae-c80f-4d19-9331-a1b68a85b553",
|
||||
aids=None,
|
||||
origin_url="https://domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
bbox=(55.10, 56.10, 36.80, 38.10),
|
||||
region_name=None,
|
||||
city_name="Москва",
|
||||
),
|
||||
50: DomClickGeoProfile(
|
||||
region_code=50,
|
||||
address_guid="9930cc20-32c6-4f6f-a55e-cd67086c5171",
|
||||
aids=None,
|
||||
# Область выдаётся с того же апексного домена; см. оговорку выше.
|
||||
origin_url="https://domclick.ru/pokupka/kvartiry/vtorichka",
|
||||
bbox=(54.20, 56.96, 35.14, 40.21),
|
||||
region_name=None,
|
||||
city_name=None,
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_GEO_PROFILE: DomClickGeoProfile = DOMCLICK_GEO_PROFILES[66]
|
||||
|
||||
# Обратная совместимость: страница прогрева ЕКБ как модульная константа
|
||||
# (импортируется тестами subresource-режима).
|
||||
_SWEEP_ORIGIN = DEFAULT_GEO_PROFILE.origin_url
|
||||
|
||||
|
||||
def geo_profile_for_region(region_code: int) -> DomClickGeoProfile:
|
||||
"""Профиль по коду региона. ValueError на неизвестном коде.
|
||||
|
||||
Молчаливый fallback на ЕКБ здесь недопустим: он и есть тот баг, ради
|
||||
которого гео-скоуп стал параметром — прогон собрал бы ЕКБ и записал его
|
||||
под чужим region_code со штатными счётчиками.
|
||||
"""
|
||||
profile = DOMCLICK_GEO_PROFILES.get(region_code)
|
||||
if profile is None:
|
||||
_known = ", ".join(str(code) for code in sorted(DOMCLICK_GEO_PROFILES))
|
||||
raise ValueError(
|
||||
f"domclick: нет гео-профиля для region_code={region_code} "
|
||||
f"(известны: {_known}); заведи запись в DOMCLICK_GEO_PROFILES "
|
||||
"(address GUID, aids, страница прогрева, bbox) перед прогоном"
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
# ── JSON extraction ───────────────────────────────────────────────────────────
|
||||
|
|
@ -152,14 +261,19 @@ def _build_offers_url(
|
|||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
offset: int,
|
||||
*,
|
||||
address_guid: str,
|
||||
aids: str | None,
|
||||
) -> str:
|
||||
"""Строит URL для GET /api/offers/v1 с пагинацией.
|
||||
|
||||
urlencode кодирует "5+" → "5%2B" (literal '+' в query string = space → reject).
|
||||
aids=None — параметр не добавляется (Москва и область, см. DomClickGeoProfile).
|
||||
"""
|
||||
params: list[tuple[str, str]] = [
|
||||
("address", _EKB_ADDRESS_GUID),
|
||||
("aids", _EKB_AREA_ID),
|
||||
params: list[tuple[str, str]] = [("address", address_guid)]
|
||||
if aids is not None:
|
||||
params.append(("aids", aids))
|
||||
params += [
|
||||
("deal_type", "sale"),
|
||||
("category", "living"),
|
||||
("offer_type", "flat"),
|
||||
|
|
@ -176,25 +290,19 @@ def _build_offers_url(
|
|||
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
|
||||
|
||||
|
||||
# Страница, на которой прогревается контекст перед запросом к BFF (#3264).
|
||||
# BFF — не страница, а JSON-ручка SPA; навигацией браузера на неё через мобильные узлы
|
||||
# пула приходит только ChallengeTimeout (прод, прогоны 5330 и 5351 — 9/9 и 6/6 запросов
|
||||
# зависли), а подзапросом из прогретой выдачи — HTTP 200 за 6 секунд на всех четырёх
|
||||
# узлах и всех шести комнатных корзинах (замер 30.08.2026, суммарно 6359 офферов против
|
||||
# 6367, снятых напрямую с резидентного IP). Прогрев обязателен: рукопожатие QRATOR
|
||||
# попадает в куки контекста именно при заходе на страницу.
|
||||
_SWEEP_ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
|
||||
|
||||
|
||||
def _build_count_url(
|
||||
rooms: str,
|
||||
price_gte: int | None,
|
||||
price_lte: int | None,
|
||||
*,
|
||||
address_guid: str,
|
||||
aids: str | None,
|
||||
) -> str:
|
||||
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
|
||||
params: list[tuple[str, str]] = [
|
||||
("address", _EKB_ADDRESS_GUID),
|
||||
("aids", _EKB_AREA_ID),
|
||||
params: list[tuple[str, str]] = [("address", address_guid)]
|
||||
if aids is not None:
|
||||
params.append(("aids", aids))
|
||||
params += [
|
||||
("deal_type", "sale"),
|
||||
("category", "living"),
|
||||
("offer_type", "flat"),
|
||||
|
|
@ -261,7 +369,7 @@ class DomClickScraper(BaseScraper):
|
|||
|
||||
Counters (публичные после fetch_city):
|
||||
parse_failures — офферы с ошибкой маппинга
|
||||
geo_filtered — офферы вне ЕКБ bbox или с неверным offerRegionName
|
||||
geo_filtered — офферы вне bbox профиля или с неверным offerRegionName
|
||||
blocked — True если sweep был прерван QRATOR-блоком
|
||||
fetch_errors — не-block ошибки извлечения JSON (truncated/garbled/bad shape)
|
||||
buckets_total — сколько комнатных бакетов прогон собирался пройти
|
||||
|
|
@ -282,8 +390,12 @@ class DomClickScraper(BaseScraper):
|
|||
delay_provider: Callable[[str], float] | None = None,
|
||||
proxy_provider: ProxyProvider | None = None,
|
||||
cookies: dict[str, str] | None = None,
|
||||
geo_profile: DomClickGeoProfile | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# Гео-скоп прогона: GUID/aids в URL, страница прогрева и bbox гарда.
|
||||
# Дефолт — ЕКБ (прежнее поведение всех вызовов без явного профиля).
|
||||
self._geo = geo_profile or DEFAULT_GEO_PROFILE
|
||||
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
|
||||
# BrowserFetcher, delay_provider заменяет прямой импорт
|
||||
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
|
||||
|
|
@ -352,11 +464,12 @@ class DomClickScraper(BaseScraper):
|
|||
|
||||
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
|
||||
вызывающим кодом (run_domclick_city_sweep), но:
|
||||
- city_id — vestigial (EКБ захардкожен через GUID).
|
||||
- city_id — vestigial: гео-скоп берётся из geo_profile (GUID/aids/bbox),
|
||||
а не из city_id.
|
||||
- rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
|
||||
|
||||
Args:
|
||||
city_id: игнорируется (EKB захардкожен).
|
||||
city_id: игнорируется (гео-скоп — в geo_profile скрейпера).
|
||||
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
|
||||
pages: максимальное число страниц на бакет (safety cap).
|
||||
start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854).
|
||||
|
|
@ -508,12 +621,18 @@ class DomClickScraper(BaseScraper):
|
|||
извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0
|
||||
(бакет пропускается, sweep продолжается).
|
||||
"""
|
||||
url = _build_count_url(rooms, price_gte, price_lte)
|
||||
url = _build_count_url(
|
||||
rooms,
|
||||
price_gte,
|
||||
price_lte,
|
||||
address_guid=self._geo.address_guid,
|
||||
aids=self._geo.aids,
|
||||
)
|
||||
logger.debug("domklik: count url=%s", url)
|
||||
html = await fetcher.fetch(
|
||||
url,
|
||||
origin=_SWEEP_ORIGIN,
|
||||
referer=_SWEEP_ORIGIN,
|
||||
origin=self._geo.origin_url,
|
||||
referer=self._geo.origin_url,
|
||||
cookies=self._cookies,
|
||||
fetch_mode="subresource",
|
||||
)
|
||||
|
|
@ -603,15 +722,22 @@ class DomClickScraper(BaseScraper):
|
|||
offset = page_idx * PAGE_SIZE
|
||||
if offset >= OFFSET_CAP:
|
||||
break
|
||||
url = _build_offers_url(rooms, price_gte, price_lte, offset)
|
||||
url = _build_offers_url(
|
||||
rooms,
|
||||
price_gte,
|
||||
price_lte,
|
||||
offset,
|
||||
address_guid=self._geo.address_guid,
|
||||
aids=self._geo.aids,
|
||||
)
|
||||
logger.debug("domklik: offers url=%s", url)
|
||||
html = await fetcher.fetch(
|
||||
url,
|
||||
origin=_SWEEP_ORIGIN,
|
||||
referer=_SWEEP_ORIGIN,
|
||||
cookies=self._cookies,
|
||||
fetch_mode="subresource",
|
||||
)
|
||||
url,
|
||||
origin=self._geo.origin_url,
|
||||
referer=self._geo.origin_url,
|
||||
cookies=self._cookies,
|
||||
fetch_mode="subresource",
|
||||
)
|
||||
try:
|
||||
data = _extract_json(html)
|
||||
except DomClickBlockedError:
|
||||
|
|
@ -652,22 +778,23 @@ class DomClickScraper(BaseScraper):
|
|||
break
|
||||
|
||||
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
|
||||
"""Гео-гард: пропускает только листинги ЕКБ в bbox.
|
||||
"""Гео-гард по bbox профиля (и по имени региона, если оно задано).
|
||||
|
||||
aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
|
||||
У ЕКБ aids=20561 и так даёт чистую выдачу, а сверка имени остаётся
|
||||
defensive-проверкой. Вне 66 profile.region_name обязан быть None:
|
||||
offerRegionName там ненадёжен — офферы Новой Москвы приходят как
|
||||
«г. Говорово», и сверка по имени вырезала бы законную выдачу.
|
||||
"""
|
||||
region = item.get("offerRegionName", "")
|
||||
if region != _EKB_REGION_NAME:
|
||||
expected_region = self._geo.region_name
|
||||
if expected_region is not None and item.get("offerRegionName", "") != expected_region:
|
||||
return False
|
||||
loc = item.get("location") or {}
|
||||
lat = loc.get("lat")
|
||||
lon = loc.get("lon")
|
||||
if lat is None or lon is None:
|
||||
return False
|
||||
return (
|
||||
_EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX
|
||||
and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
|
||||
)
|
||||
lat_min, lat_max, lon_min, lon_max = self._geo.bbox
|
||||
return lat_min <= float(lat) <= lat_max and lon_min <= float(lon) <= lon_max
|
||||
|
||||
def _map_item(
|
||||
self, item: dict[str, Any], *, force_rooms: int | None = None
|
||||
|
|
@ -844,6 +971,7 @@ async def scrape_domclick_city(
|
|||
pages: int = 100,
|
||||
*,
|
||||
delay_provider: Callable[[str], float] | None = None,
|
||||
geo_profile: DomClickGeoProfile | None = None,
|
||||
) -> list[ScrapedLot]:
|
||||
"""Удобная точка входа для вызова из Celery tasks.
|
||||
|
||||
|
|
@ -852,5 +980,7 @@ async def scrape_domclick_city(
|
|||
import asyncio
|
||||
lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None))
|
||||
"""
|
||||
async with DomClickScraper(config, delay_provider=delay_provider) as scraper:
|
||||
async with DomClickScraper(
|
||||
config, delay_provider=delay_provider, geo_profile=geo_profile
|
||||
) as scraper:
|
||||
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue