Черновик регулярного сбора по Москве и области: сетка якорей, ID площадок, гео-профили ДомКлика #3533

Merged
lekss361 merged 6 commits from feat/msk-sweep-draft into main 2026-09-15 17:51:32 +00:00
15 changed files with 1560 additions and 103 deletions

View file

@ -54,7 +54,13 @@ def test_resolve_city_name_known_oblast_slugs() -> None:
"verkhnyaya_pyshma": "Верхняя Пышма", "verkhnyaya_pyshma": "Верхняя Пышма",
"serov": "Серов", "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() assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
for slug, name in wave1_expected.items(): for slug, name in wave1_expected.items():
assert resolve_city_name(slug) == name 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 == "Екатеринбург" assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None: def test_resolve_city_name_unknown_slug_raises() -> None:
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors.""" """Неизвестный slug ПАДАЕТ (msk-rollout), а не штампует молча «Екатеринбург».
Раньше здесь был ЕКБ-дефолт симметрично get_city_location/get_city_anchors.
Оба они теперь тоже падают: с приходом Москвы тихий откат на ЕКБ означал бы,
что развёртка с опечаткой в слаге собирает Екатеринбург под чужим именем города,
со штатными счётчиками и без единой аномалии в логах.
"""
import pytest
from scraper_kit.orchestration.pipeline import resolve_city_name 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 (НЕ силентный # ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный

View 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)

View file

@ -95,6 +95,13 @@ _WAVE2_SLUGS = {
assert len(_WAVE2_SLUGS) == 40 assert len(_WAVE2_SLUGS) == 40
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана) 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: def _sql(path: Path) -> str:
return path.read_text(encoding="utf-8") 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 scheduled_avito = rows_262 | rows_179
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None} 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"missing schedule rows: {confirmed_avito - scheduled_avito}; "
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_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 scheduled_yandex = rows_262 | rows_179
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None} 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"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_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 = { confirmed_cian = {
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None 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"missing schedule rows: {confirmed_cian - scheduled_cian}; "
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_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)} | { seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
slug for _p, slug in _row_sources(_MIGRATION_262) 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, ( assert not orphaned, (
f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(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: def test_city_anchors_wave2_count_and_content() -> None:
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 — """CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи).""" ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS | _MSK_SCOPES
assert len(CITY_ANCHORS) == 45 assert len(CITY_ANCHORS) == 45 + len(_MSK_SCOPES)
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None: def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:

View file

@ -3,7 +3,8 @@
Контекст: планировщик читает регион из строки расписания (#3515, Контекст: планировщик читает регион из строки расписания (#3515,
`scheduler._resolve_region_code`), но гео-скоуп свипа задаётся НЕ им у avito/cian/ `scheduler._resolve_region_code`), но гео-скоуп свипа задаётся НЕ им у avito/cian/
yandex это anchors города (`CITY_ANCHORS`/`CITY_LOCATIONS`, сегодня только 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}` прогон собрал бы ЕКБ и строку `*_city_sweep_moscow` с `{"region_code": 77}` прогон собрал бы ЕКБ и
проштамповал его московским регионом, не показав ни одной аномалии в счётчиках. проштамповал его московским регионом, не показав ни одной аномалии в счётчиках.
Гард превращает эту тихую порчу в падение до первого запроса. Гард превращает эту тихую порчу в падение до первого запроса.
@ -23,6 +24,7 @@ from scraper_kit.orchestration.pipeline import (
run_domclick_city_sweep, run_domclick_city_sweep,
run_yandex_city_sweep, run_yandex_city_sweep,
) )
from scraper_kit.providers.domclick.serp import geo_profile_for_region
_EKB_ANCHOR = [(56.8400, 60.6050, "Центр")] _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)) await sweep(None, **_required_kwargs(sweep, run_id=1, region_code=77, anchors=None))
@pytest.mark.parametrize("region_code", [50, 77]) @pytest.mark.parametrize("region_code", [1, 99])
async def test_domclick_sweep_rejects_foreign_region(region_code: int) -> None: async def test_domclick_sweep_rejects_region_without_profile(region_code: int) -> None:
"""У домклика anchors нет вовсе — гео-скоуп зашит в GUID, поэтому режем регион.""" """У домклика anchors нет вовсе: гео-скоуп задаёт профиль региона
(DOMCLICK_GEO_PROFILES: address GUID, aids, страница прогрева, bbox). 66/77/50
теперь поддержаны, а код без профиля по-прежнему падает до первого запроса:
тихий fallback на ЕКБ ровно та порча, от которой стережёт этот файл."""
with pytest.raises(ValueError, match=f"region_code={region_code}"): with pytest.raises(ValueError, match=f"region_code={region_code}"):
await run_domclick_city_sweep( await run_domclick_city_sweep(
None, # type: ignore[arg-type] None, # type: ignore[arg-type]
**_required_kwargs(run_domclick_city_sweep, run_id=1, region_code=region_code), **_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

View 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")

View 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)

View file

@ -550,17 +550,20 @@ async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> N
assert kwargs["anchors"] is None assert kwargs["anchors"] is None
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None: async def test_job_avito_city_sweep_unknown_city_raises_instead_of_sweeping() -> None:
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=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: with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
with pytest.raises(ValueError, match="nonexistent_town"):
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx()) await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
mock_run.assert_awaited_once() mock_run.assert_not_awaited()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
# ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ────── # ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ──────

View file

@ -72,7 +72,11 @@ from scraper_kit.providers.cian.newbuilding import (
save_newbuilding_enrichment, save_newbuilding_enrichment,
) )
from scraper_kit.providers.cian.serp import CianScraper 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 ( from scraper_kit.providers.yandex.serp import (
DEFAULT_PRICE_RANGES, DEFAULT_PRICE_RANGES,
ROOM_PATH, ROOM_PATH,
@ -278,6 +282,146 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
(56.8650, 60.6200, "Пионерский"), (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). # Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules. # По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек # default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
@ -342,6 +486,13 @@ CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = {
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")], "verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
"talitsa": [(57.0148, 63.7302, "Талица центр")], "talitsa": [(57.0148, 63.7302, "Талица центр")],
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")], "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: if city_slug is None:
return 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: 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) 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) @dataclass(frozen=True)
class CityLocation: class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout). """Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
@ -402,11 +571,25 @@ class CityLocation:
Тот же жёсткий инвариант, что у avito_slug run_yandex_city_sweep Тот же жёсткий инвариант, что у avito_slug run_yandex_city_sweep
явно падает (ValueError), если city_slug известен, а yandex_rgid явно падает (ValueError), если city_slug известен, а yandex_rgid
для него None. для него 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 avito_slug: str | None
cian_region_id: int cian_region_id: int
yandex_rgid: int | None yandex_rgid: int | None
cian_host: str = CIAN_SVERDLOVSK_HOST
avito_slug_is_region: bool = False
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах, # Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
@ -469,14 +652,41 @@ CITY_LOCATIONS: dict[str, CityLocation] = {
"verkhoturye": CityLocation(None, 174499, 328251), "verkhoturye": CityLocation(None, 174499, 328251),
# ── wave 2: ни avito, ни yandex — только cian (1 город) ───────────────────── # ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
"mikhaylovsk": CityLocation(None, 175332, None), "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: 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: if city_slug is None:
return 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). # Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
@ -538,11 +748,19 @@ CITY_DISPLAY_NAMES: dict[str, str] = {
"verkhoturye": "Верхотурье", "verkhoturye": "Верхотурье",
"talitsa": "Талица", "talitsa": "Талица",
"novaya_lyalya": "Новая Ляля", "novaya_lyalya": "Новая Ляля",
"moskva": "Москва",
} }
EKATERINBURG_CITY_NAME = "Екатеринбург" 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). """Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
city_slug=None Екатеринбург. Это НЕ заглушка «не знаем» это симметрия с city_slug=None Екатеринбург. Это НЕ заглушка «не знаем» это симметрия с
@ -551,11 +769,26 @@ def resolve_city_name(city_slug: str | None) -> str:
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург" собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург"
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ нет». иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ нет».
Неизвестный slug (не в CITY_DISPLAY_NAMES) тоже ЕКБ-дефолт, тем же путём, что и Неизвестный 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: if city_slug is None:
return EKATERINBURG_CITY_NAME 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), за пределами # Гео-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 _DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
_CITY_STAMP_RADIUS_KM: dict[str, float] = { _CITY_STAMP_RADIUS_KM: dict[str, float] = {
"verkhnyaya_pyshma": 8.0, "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 — # kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor. # вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
_avito_slug = _loc.avito_slug if _loc else city_slug _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'ы # #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла. # одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(city_slug) _city_name = resolve_city_name(city_slug)
@ -1406,7 +1655,10 @@ async def run_avito_city_sweep(
# shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда # shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
# переопределяет scraper._browser напрямую, минуя __aenter__. # переопределяет scraper._browser напрямую, минуя __aenter__.
scraper = AvitoScraper( 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: if browser_mode:
scraper._browser = shared_bf scraper._browser = shared_bf
@ -3078,6 +3330,7 @@ async def run_cian_city_sweep(
config, config,
proxy_provider=proxy_provider, proxy_provider=proxy_provider,
city_region_id=(_loc.cian_region_id if _loc else None), city_region_id=(_loc.cian_region_id if _loc else None),
city_host=(_loc.cian_host if _loc else None),
) as scraper: ) as scraper:
anchor_lots = await scraper.fetch_around_multi_room( anchor_lots = await scraper.fetch_around_multi_room(
_a_lat, _a_lat,
@ -4555,19 +4808,13 @@ async def run_domclick_city_sweep(
Возвращает DomClickCitySweepCounters. Возвращает DomClickCitySweepCounters.
""" """
# Гео-скоуп этого свипа ЗАШИТ в ЕКБ: providers/domclick/serp.py строит BFF-URL с # Гео-скоуп свипа больше не зашит в ЕКБ: его задаёт профиль региона (address
# _EKB_ADDRESS_GUID/_EKB_AREA_ID и прогревает контекст на _SWEEP_ORIGIN # GUID, aids, страница прогрева QRATOR, bbox гарда, имя города) — см.
# (ekaterinburg.domclick.ru), а city_id — vestigial, fetch_city его игнорирует. # providers/domclick/serp.py DOMCLICK_GEO_PROFILES. На неизвестном коде региона
# Строка расписания с region_code 77/50 поэтому собрала бы ЕКБ и проштамповала его # падаем ДО первого запроса, а не откатываемся на ЕКБ: тихий fallback и есть тот
# чужим регионом — молча, со штатными счётчиками. Падаем до первого запроса; гард # баг, ради которого гео-скоуп стал параметром — прогон собрал бы ЕКБ и записал
# снимается вместе с параметризацией GUID/aids/origin по региону (GUID Москвы и МО # его под чужим region_code молча, со штатными счётчиками.
# известны — scripts/local-avito-msk/collect.py MSK_DOMCLICK_GUID/MO_DOMCLICK_GUID). _geo_profile = geo_profile_for_region(region_code)
if region_code != DEFAULT_REGION_CODE:
raise ValueError(
f"domclick_city_sweep: region_code={region_code} не поддержан — гео-скоуп "
"BFF-запроса зашит в ЕКБ (address GUID / aids / origin прогрева), прогон "
"собрал бы ЕКБ под чужим регионом"
)
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0 _resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
counters = DomClickCitySweepCounters() counters = DomClickCitySweepCounters()
@ -4660,7 +4907,10 @@ async def run_domclick_city_sweep(
"""Единственная citywide-фаза: fetch_city + save.""" """Единственная citywide-фаза: fetch_city + save."""
nonlocal lots nonlocal lots
async with DomClickScraper( async with DomClickScraper(
config, proxy_provider=proxy_provider, cookies=cookies config,
proxy_provider=proxy_provider,
cookies=cookies,
geo_profile=_geo_profile,
) as _scraper: ) as _scraper:
_scraper_ref.append(_scraper) _scraper_ref.append(_scraper)
if request_delay_sec is not None: if request_delay_sec is not None:
@ -4687,11 +4937,10 @@ async def run_domclick_city_sweep(
) )
counters.lots_fetched += len(lots) counters.lots_fetched += len(lots)
if lots: if lots:
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug # Имя города — из профиля региона, а не из сравнения с vestigial
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает # city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не # У области (50) city_name=None — одного города нет, угадывать нечего.
# угадывается. _dc_city = _geo_profile.city_name
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
inserted, updated = save_listings( inserted, updated = save_listings(
db, db,
lots, lots,

View file

@ -1058,8 +1058,10 @@ async def _job_avito_city_sweep(
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
) -> None: ) -> None:
# #B1 oblast rollout: default_params["city"] (slug, e.g. "nizhniy_tagil") → anchors # #B1 oblast rollout: default_params["city"] (slug, e.g. "nizhniy_tagil") → anchors
# города вместо EKB_ANCHORS. Отсутствует/неизвестен → get_city_anchors вернёт None → # города вместо EKB_ANCHORS. Ключа нет вовсе → anchors=None → citywide-свип ЕКБ
# run_avito_city_sweep сам падает на EKB_ANCHORS (прежнее поведение без city). # (прежнее поведение расписаний без city). Ключ ЕСТЬ, но слаг неизвестен →
# get_city_anchors падает с ValueError: опечатка в расписании раньше тихо давала
# прогон по Екатеринбургу под именем чужого города.
city = params.get("city") city = params.get("city")
anchors = get_city_anchors(city) if city else None anchors = get_city_anchors(city) if city else None
await run_avito_city_sweep( await run_avito_city_sweep(

View file

@ -1,23 +1,63 @@
"""scraper_kit.pricing — общие примитивы ценового партиционирования SERP-обхода. """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 __future__ import annotations
from scraper_kit.pricing.bisection import ( from scraper_kit.pricing.bisection import (
BisectionConfig, BisectionConfig,
BisectionStats,
ProbeFailPolicy, ProbeFailPolicy,
ProbeResult, ProbeResult,
done_range_skipper, done_range_skipper,
walk_price_range, 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__ = [ __all__ = [
"EKB_REGION_CODE",
"MAX_DEPTH",
"MIN_WIDTH_RATIO",
"PRICE_CEIL",
"PRICE_FLOOR",
"PRICE_PROBE_START",
"BisectionConfig", "BisectionConfig",
"BisectionStats",
"Corridor",
"CountFn",
"PlanStats",
"PricePlan",
"ProbeFailPolicy", "ProbeFailPolicy",
"ProbeResult", "ProbeResult",
"done_range_skipper", "done_range_skipper",
"geometric_mid",
"plan_price_corridors",
"price_bisection_enabled",
"walk_price_range", "walk_price_range",
"width_ratio",
] ]

View file

@ -88,6 +88,63 @@ class ProbeResult:
payload: Any = None 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) @dataclass(frozen=True)
class BisectionConfig: class BisectionConfig:
"""Per-provider константы/политики бисекции (все различия провайдеров тут). """Per-provider константы/политики бисекции (все различия провайдеров тут).
@ -222,6 +279,7 @@ async def walk_price_range(
on_leaf: LeafFn, on_leaf: LeafFn,
on_degraded: DegradedFn | None = None, on_degraded: DegradedFn | None = None,
should_skip: SkipFn | None = None, should_skip: SkipFn | None = None,
stats: BisectionStats | None = None,
depth: int = 0, depth: int = 0,
) -> None: ) -> None:
"""Рекурсивно обойти ценовой диапазон [lo, hi] адаптивной бисекцией. """Рекурсивно обойти ценовой диапазон [lo, hi] адаптивной бисекцией.
@ -249,6 +307,10 @@ async def walk_price_range(
(:func:`done_range_skipper`, #3315). Проверяется на КАЖДОМ узле (:func:`done_range_skipper`, #3315). Проверяется на КАЖДОМ узле
рекурсии: у частично покрытого диапазона probe остаётся, а его уже рекурсии: у частично покрытого диапазона probe остаётся, а его уже
готовые половины отсекаются на входе, а не в листе после probe. готовые половины отсекаются на входе, а не в листе после probe.
stats: опциональный :class:`BisectionStats` мутабельный аккумулятор
probe/leaf/усечений. ``None`` (умолчание) = ни одной новой ветки в
обходе. Передан вызывающий получает `missed_estimate` и обязан
донести его до счётчиков прогона, иначе усечение снова станет тишиной.
depth: текущая глубина рекурсии (для ``max_depth``). depth: текущая глубина рекурсии (для ``max_depth``).
""" """
if should_skip is not None and should_skip(lo, hi): 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) result = await probe(lo, hi, depth)
total = result.count total = result.count
if stats is not None:
stats.record_probe(depth)
# ── Probe-fail: счётчик недоступен ──────────────────────────────────────── # ── Probe-fail: счётчик недоступен ────────────────────────────────────────
if total is None: if total is None:
policy = config.probe_fail_policy policy = config.probe_fail_policy
if policy is ProbeFailPolicy.SKIP: if policy is ProbeFailPolicy.SKIP:
if stats is not None:
stats.skipped_probe_fail += 1
return return
if policy is ProbeFailPolicy.DEGRADE: if policy is ProbeFailPolicy.DEGRADE:
if stats is not None:
stats.degraded += 1
if on_degraded is not None: if on_degraded is not None:
await on_degraded(lo, hi) await on_degraded(lo, hi)
return return
# SPLIT_OR_SKIP (avito): открытый брекет пагинируем best-effort; закрытый # SPLIT_OR_SKIP (avito): открытый брекет пагинируем best-effort; закрытый
# ещё делимый — принудительно делим; иначе пропускаем (хвост теряем). # ещё делимый — принудительно делим; иначе пропускаем (хвост теряем).
if hi is None: if hi is None:
if stats is not None:
stats.record_leaf(None, config.cap)
await on_leaf(lo, hi, result) await on_leaf(lo, hi, result)
return return
_lo = lo if lo is not None else config.open_split_floor _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): if _can_split_span(hi - _lo, config):
mid = (_lo + hi) // 2 mid = (_lo + hi) // 2
await walk_price_range( await walk_price_range(
@ -282,6 +356,7 @@ async def walk_price_range(
on_leaf=on_leaf, on_leaf=on_leaf,
on_degraded=on_degraded, on_degraded=on_degraded,
should_skip=should_skip, should_skip=should_skip,
stats=stats,
depth=depth + 1, depth=depth + 1,
) )
await walk_price_range( await walk_price_range(
@ -292,6 +367,7 @@ async def walk_price_range(
on_leaf=on_leaf, on_leaf=on_leaf,
on_degraded=on_degraded, on_degraded=on_degraded,
should_skip=should_skip, should_skip=should_skip,
stats=stats,
depth=depth + 1, depth=depth + 1,
) )
return return
@ -303,6 +379,8 @@ async def walk_price_range(
# Открытый брекет без split-потолка (avito/cian/yandex): делить нельзя — # Открытый брекет без split-потолка (avito/cian/yandex): делить нельзя —
# пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted). # пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
if hi is None and config.open_split_ceiling is None: 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) await on_leaf(lo, hi, result)
return return
@ -329,6 +407,7 @@ async def walk_price_range(
on_leaf=on_leaf, on_leaf=on_leaf,
on_degraded=on_degraded, on_degraded=on_degraded,
should_skip=should_skip, should_skip=should_skip,
stats=stats,
depth=depth + 1, depth=depth + 1,
) )
await walk_price_range( await walk_price_range(
@ -339,9 +418,15 @@ async def walk_price_range(
on_leaf=on_leaf, on_leaf=on_leaf,
on_degraded=on_degraded, on_degraded=on_degraded,
should_skip=should_skip, should_skip=should_skip,
stats=stats,
depth=depth + 1, depth=depth + 1,
) )
return return
# Leaf-бакет: пагинация + дедуп + on_bucket — целиком на стороне провайдера. # 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) await on_leaf(lo, hi, result)

View file

@ -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)

View file

@ -398,6 +398,7 @@ class AvitoScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None, delay_provider: Callable[[str], float] | None = None,
target_city_slug: str | None = None, target_city_slug: str | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
filter_cards_by_city_slug: bool = True,
) -> None: ) -> None:
super().__init__() super().__init__()
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
@ -420,6 +421,14 @@ class AvitoScraper(BaseScraper):
# None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only, # None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only,
# чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/. # чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/.
self._target_city_slug = target_city_slug 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. Включается на время # #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время
# fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы # fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы
# остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча. # остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча.
@ -2111,7 +2120,7 @@ class AvitoScraper(BaseScraper):
ts_map = _build_sort_timestamp_map(html) ts_map = _build_sort_timestamp_map(html)
cards = tree.css('[data-marker="item"]') cards = tree.css('[data-marker="item"]')
lots: list[ScrapedLot] = [] 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" — # #2487: слаг города, карточки которого оставляем. Дефолт "ekaterinburg" —
# ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load). # ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load).
kept_slug = self._target_city_slug or "ekaterinburg" kept_slug = self._target_city_slug or "ekaterinburg"

View file

@ -86,6 +86,7 @@ _MIN_BRACKET = 50_000
# Cian SERP показывает ~28 офферов на страницу. # Cian SERP показывает ~28 офферов на страницу.
_CIAN_OFFERS_PER_PAGE = 28 _CIAN_OFFERS_PER_PAGE = 28
# Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся # Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся
# per-call (price_cap_per_bucket) — здесь только структурные константы/политики. # per-call (price_cap_per_bucket) — здесь только структурные константы/политики.
# probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается. # probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается.
@ -97,6 +98,7 @@ def _cian_bisection_config(cap: int) -> BisectionConfig:
probe_fail_policy=ProbeFailPolicy.SKIP, probe_fail_policy=ProbeFailPolicy.SKIP,
) )
# Комнатности по умолчанию для exhaustive-загрузки. # Комнатности по умолчанию для exhaustive-загрузки.
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+. # Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param. # TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
@ -140,6 +142,7 @@ class CianScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None, delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
city_region_id: int | None = None, city_region_id: int | None = None,
city_host: str | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
@ -153,9 +156,17 @@ class CianScraper(BaseScraper):
# None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас). # None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас).
self._proxy_provider = proxy_provider self._proxy_provider = proxy_provider
self._browser: BrowserFetcher | None = None self._browser: BrowserFetcher | None = None
# #12 (oblast rollout): region= города-цели SERP-запроса (ekb.cian.ru — общий # #12 (oblast rollout): region= города-цели SERP-запроса. None → ЕКБ-дефолт
# поддомен всей Свердловской обл., НЕ меняется). None → ЕКБ-дефолт (CIAN_EKB_REGION_ID). # (CIAN_EKB_REGION_ID).
self._region_id = city_region_id or 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-инстанса. # #2625: счётчики Redux-state extraction за время жизни этого scraper-инстанса.
# Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены # Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены
# вёрстки (state extraction провалилась → None). Читаются вызывающим кодом # вёрстки (state extraction провалилась → None). Читаются вызывающим кодом
@ -296,8 +307,9 @@ class CianScraper(BaseScraper):
) -> str: ) -> str:
"""URL для Cian каталога вторички города-цели (по умолчанию ЕКБ). """URL для Cian каталога вторички города-цели (по умолчанию ЕКБ).
Используем ekb.cian.ru (city-specific subdomain общий для всей Свердловской Хост берётся из self.base_url (региональный поддомен: ekb.cian.ru вся
обл., НЕ меняется per-city). region= param фактический скоуп выборки Свердловская обл., www.cian.ru Москва и МО; задаётся city_host в __init__,
по умолчанию ЕКБ). region= param фактический скоуп выборки
(#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан). (#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан).
min_price / max_price опциональные ценовые границы (руб.) для price-бакетов. min_price / max_price опциональные ценовые границы (руб.) для price-бакетов.
""" """

View file

@ -32,6 +32,7 @@ import hashlib
import json import json
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from datetime import date from datetime import date
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode from urllib.parse import urlencode
@ -49,8 +50,6 @@ logger = logging.getLogger(__name__)
# ── API constants ───────────────────────────────────────────────────────────── # ── API constants ─────────────────────────────────────────────────────────────
_BFF_BASE = "https://bff-search-web.domclick.ru" _BFF_BASE = "https://bff-search-web.domclick.ru"
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
_EKB_AREA_ID = "20561"
# Buckets to sweep — порядок влияет на логи. # Buckets to sweep — порядок влияет на логи.
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+") ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
@ -81,13 +80,123 @@ _DOMCLICK_BISECTION = BisectionConfig(
open_split_floor=0, open_split_floor=0,
) )
# ── EKB geo guard ───────────────────────────────────────────────────────────── # ── Гео-профиль региона ───────────────────────────────────────────────────────
_EKB_LAT_MIN: float = 56.6
_EKB_LAT_MAX: float = 57.0 @dataclass(frozen=True)
_EKB_LON_MIN: float = 60.2 class DomClickGeoProfile:
_EKB_LON_MAX: float = 60.9 """Всё, что отличает свип одного региона от свипа другого.
_EKB_REGION_NAME: str = "Екатеринбург"
Раньше эти величины были модульными константами `_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 ─────────────────────────────────────────────────────────── # ── JSON extraction ───────────────────────────────────────────────────────────
@ -152,14 +261,19 @@ def _build_offers_url(
price_gte: int | None, price_gte: int | None,
price_lte: int | None, price_lte: int | None,
offset: int, offset: int,
*,
address_guid: str,
aids: str | None,
) -> str: ) -> str:
"""Строит URL для GET /api/offers/v1 с пагинацией. """Строит URL для GET /api/offers/v1 с пагинацией.
urlencode кодирует "5+" "5%2B" (literal '+' в query string = space reject). urlencode кодирует "5+" "5%2B" (literal '+' в query string = space reject).
aids=None параметр не добавляется (Москва и область, см. DomClickGeoProfile).
""" """
params: list[tuple[str, str]] = [ params: list[tuple[str, str]] = [("address", address_guid)]
("address", _EKB_ADDRESS_GUID), if aids is not None:
("aids", _EKB_AREA_ID), params.append(("aids", aids))
params += [
("deal_type", "sale"), ("deal_type", "sale"),
("category", "living"), ("category", "living"),
("offer_type", "flat"), ("offer_type", "flat"),
@ -176,25 +290,19 @@ def _build_offers_url(
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}" 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( def _build_count_url(
rooms: str, rooms: str,
price_gte: int | None, price_gte: int | None,
price_lte: int | None, price_lte: int | None,
*,
address_guid: str,
aids: str | None,
) -> str: ) -> str:
"""Строит URL для GET /api/offers/count/v1 (без offset/limit).""" """Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
params: list[tuple[str, str]] = [ params: list[tuple[str, str]] = [("address", address_guid)]
("address", _EKB_ADDRESS_GUID), if aids is not None:
("aids", _EKB_AREA_ID), params.append(("aids", aids))
params += [
("deal_type", "sale"), ("deal_type", "sale"),
("category", "living"), ("category", "living"),
("offer_type", "flat"), ("offer_type", "flat"),
@ -261,7 +369,7 @@ class DomClickScraper(BaseScraper):
Counters (публичные после fetch_city): Counters (публичные после fetch_city):
parse_failures офферы с ошибкой маппинга parse_failures офферы с ошибкой маппинга
geo_filtered офферы вне ЕКБ bbox или с неверным offerRegionName geo_filtered офферы вне bbox профиля или с неверным offerRegionName
blocked True если sweep был прерван QRATOR-блоком blocked True если sweep был прерван QRATOR-блоком
fetch_errors не-block ошибки извлечения JSON (truncated/garbled/bad shape) fetch_errors не-block ошибки извлечения JSON (truncated/garbled/bad shape)
buckets_total сколько комнатных бакетов прогон собирался пройти buckets_total сколько комнатных бакетов прогон собирался пройти
@ -282,8 +390,12 @@ class DomClickScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None, delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
cookies: dict[str, str] | None = None, cookies: dict[str, str] | None = None,
geo_profile: DomClickGeoProfile | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
# Гео-скоп прогона: GUID/aids в URL, страница прогрева и bbox гарда.
# Дефолт — ЕКБ (прежнее поведение всех вызовов без явного профиля).
self._geo = geo_profile or DEFAULT_GEO_PROFILE
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для # Strangler-инжекция (#2133): config даёт browser_http_endpoint для
# BrowserFetcher, delay_provider заменяет прямой импорт # BrowserFetcher, delay_provider заменяет прямой импорт
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД. # app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
@ -352,11 +464,12 @@ class DomClickScraper(BaseScraper):
Аргументы city_id и rooms принимаются для совместимости сигнатуры с Аргументы city_id и rooms принимаются для совместимости сигнатуры с
вызывающим кодом (run_domclick_city_sweep), но: вызывающим кодом (run_domclick_city_sweep), но:
- city_id vestigial (EКБ захардкожен через GUID). - city_id vestigial: гео-скоп берётся из geo_profile (GUID/aids/bbox),
а не из city_id.
- rooms игнорируется; всегда обходятся все ROOM_BUCKETS внутри. - rooms игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
Args: Args:
city_id: игнорируется (EKB захардкожен). city_id: игнорируется (гео-скоп в geo_profile скрейпера).
rooms: игнорируется (ROOM_BUCKETS перебирается всегда). rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
pages: максимальное число страниц на бакет (safety cap). pages: максимальное число страниц на бакет (safety cap).
start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854). start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854).
@ -508,12 +621,18 @@ class DomClickScraper(BaseScraper):
извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и return 0 извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и return 0
(бакет пропускается, sweep продолжается). (бакет пропускается, 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) logger.debug("domklik: count url=%s", url)
html = await fetcher.fetch( html = await fetcher.fetch(
url, url,
origin=_SWEEP_ORIGIN, origin=self._geo.origin_url,
referer=_SWEEP_ORIGIN, referer=self._geo.origin_url,
cookies=self._cookies, cookies=self._cookies,
fetch_mode="subresource", fetch_mode="subresource",
) )
@ -603,12 +722,19 @@ class DomClickScraper(BaseScraper):
offset = page_idx * PAGE_SIZE offset = page_idx * PAGE_SIZE
if offset >= OFFSET_CAP: if offset >= OFFSET_CAP:
break 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) logger.debug("domklik: offers url=%s", url)
html = await fetcher.fetch( html = await fetcher.fetch(
url, url,
origin=_SWEEP_ORIGIN, origin=self._geo.origin_url,
referer=_SWEEP_ORIGIN, referer=self._geo.origin_url,
cookies=self._cookies, cookies=self._cookies,
fetch_mode="subresource", fetch_mode="subresource",
) )
@ -652,22 +778,23 @@ class DomClickScraper(BaseScraper):
break break
def _is_geo_ok(self, item: dict[str, Any]) -> bool: 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", "") expected_region = self._geo.region_name
if region != _EKB_REGION_NAME: if expected_region is not None and item.get("offerRegionName", "") != expected_region:
return False return False
loc = item.get("location") or {} loc = item.get("location") or {}
lat = loc.get("lat") lat = loc.get("lat")
lon = loc.get("lon") lon = loc.get("lon")
if lat is None or lon is None: if lat is None or lon is None:
return False return False
return ( lat_min, lat_max, lon_min, lon_max = self._geo.bbox
_EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX return lat_min <= float(lat) <= lat_max and lon_min <= float(lon) <= lon_max
and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
)
def _map_item( def _map_item(
self, item: dict[str, Any], *, force_rooms: int | None = None self, item: dict[str, Any], *, force_rooms: int | None = None
@ -844,6 +971,7 @@ async def scrape_domclick_city(
pages: int = 100, pages: int = 100,
*, *,
delay_provider: Callable[[str], float] | None = None, delay_provider: Callable[[str], float] | None = None,
geo_profile: DomClickGeoProfile | None = None,
) -> list[ScrapedLot]: ) -> list[ScrapedLot]:
"""Удобная точка входа для вызова из Celery tasks. """Удобная точка входа для вызова из Celery tasks.
@ -852,5 +980,7 @@ async def scrape_domclick_city(
import asyncio import asyncio
lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None)) 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) return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)