Черновик регулярного сбора по Москве и области: сетка якорей, 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": "Верхняя Пышма",
"serov": "Серов",
}
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS)
# msk-rollout: многогородние скоупы (MULTI_CITY_SLUGS — область и
# «Москва+область») намеренно БЕЗ display-имени: в их batch'ах городов
# несколько, и любой один штамп city был бы ложью — resolve_city_name
# отдаёт для них None, а save_listings пишет city=NULL.
from scraper_kit.orchestration.pipeline import MULTI_CITY_SLUGS
assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) - MULTI_CITY_SLUGS
assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
for slug, name in wave1_expected.items():
assert resolve_city_name(slug) == name
@ -76,11 +82,19 @@ def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None:
assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None:
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors."""
def test_resolve_city_name_unknown_slug_raises() -> None:
"""Неизвестный slug ПАДАЕТ (msk-rollout), а не штампует молча «Екатеринбург».
Раньше здесь был ЕКБ-дефолт симметрично get_city_location/get_city_anchors.
Оба они теперь тоже падают: с приходом Москвы тихий откат на ЕКБ означал бы,
что развёртка с опечаткой в слаге собирает Екатеринбург под чужим именем города,
со штатными счётчиками и без единой аномалии в логах.
"""
import pytest
from scraper_kit.orchestration.pipeline import resolve_city_name
assert resolve_city_name("nonexistent_city") == "Екатеринбург"
with pytest.raises(ValueError, match="nonexistent_city"):
resolve_city_name("nonexistent_city")
# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный

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 "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана)
# msk-rollout (черновик): Москва, область и объединённый скоуп заведены в
# CITY_ANCHORS/CITY_LOCATIONS, но строк расписаний у них СОЗНАТЕЛЬНО нет —
# реестры едут отдельным заходом от включения сбора. Проверки соответствия
# «реестр ↔ schedule row» ниже — про свердловскую волну #262, поэтому
# московские скоупы из них вычитаются явно (а не тихо расширяют ожидания).
_MSK_SCOPES = {"moskva", "moskovskaya_oblast", "moskva_i_mo"}
def _sql(path: Path) -> str:
return path.read_text(encoding="utf-8")
@ -262,7 +269,7 @@ def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None:
scheduled_avito = rows_262 | rows_179
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None}
assert scheduled_avito == confirmed_avito, (
assert scheduled_avito == confirmed_avito - _MSK_SCOPES, (
f"missing schedule rows: {confirmed_avito - scheduled_avito}; "
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}"
)
@ -274,7 +281,7 @@ def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None
scheduled_yandex = rows_262 | rows_179
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None}
assert scheduled_yandex == confirmed_yandex, (
assert scheduled_yandex == confirmed_yandex - _MSK_SCOPES, (
f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}"
)
@ -288,7 +295,7 @@ def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> N
confirmed_cian = {
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None
}
assert scheduled_cian == confirmed_cian, (
assert scheduled_cian == confirmed_cian - _MSK_SCOPES, (
f"missing schedule rows: {confirmed_cian - scheduled_cian}; "
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}"
)
@ -331,7 +338,7 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
slug for _p, slug in _row_sources(_MIGRATION_262)
}
orphaned = set(CITY_ANCHORS) - seeded_slugs
orphaned = set(CITY_ANCHORS) - seeded_slugs - _MSK_SCOPES
assert not orphaned, (
f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
)
@ -340,8 +347,8 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
def test_city_anchors_wave2_count_and_content() -> None:
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS
assert len(CITY_ANCHORS) == 45
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS | _MSK_SCOPES
assert len(CITY_ANCHORS) == 45 + len(_MSK_SCOPES)
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:

View file

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

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
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
async def test_job_avito_city_sweep_unknown_city_raises_instead_of_sweeping() -> None:
"""Неизвестный city slug (опечатка в расписании) обязан упасть, а не собрать ЕКБ.
НЕ падает с KeyError.
Раньше `get_city_anchors` отдавал None, и свип уходил на EKB_ANCHORS: опечатка в
`default_params.city` тихо давала прогон по Екатеринбургу, проштампованный чужим
городом. Теперь реестр падает с ValueError; прогон крашится, а тик планировщика
это ловит (`logger.exception` в `_run`, scheduler.py) цикл не умирает, но и
мусора в корпус не попадает.
"""
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
with pytest.raises(ValueError, match="nonexistent_town"):
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
mock_run.assert_awaited_once()
_args, kwargs = mock_run.call_args
assert kwargs["anchors"] is None
mock_run.assert_not_awaited()
# ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ──────

View file

@ -72,7 +72,11 @@ from scraper_kit.providers.cian.newbuilding import (
save_newbuilding_enrichment,
)
from scraper_kit.providers.cian.serp import CianScraper
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper
from scraper_kit.providers.domclick.serp import (
ROOM_BUCKETS,
DomClickScraper,
geo_profile_for_region,
)
from scraper_kit.providers.yandex.serp import (
DEFAULT_PRICE_RANGES,
ROOM_PATH,
@ -278,6 +282,146 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
(56.8650, 60.6200, "Пионерский"),
]
# ── Москва (77) и Московская область (50): якоря развёрток ──────────────────
#
# Продуктовое ядро Москвы — то же окно, что у REGIONS[77].bbox_product_core в
# backend/app/services/regions.py: lat 55.55..55.95, lon 37.30..37.90. Это ~44.5 км
# с юга на север и ~37.6 км с запада на восток (на широте 55.75° градус долготы =
# 62.6 км). Один центральный anchor, как у компактных городов области, покрыл бы
# отсюда круг радиусом 8 км — меньше пятой части ядра. Поэтому сетка.
MOSCOW_CORE_BBOX_DEG: tuple[float, float, float, float] = (55.55, 55.95, 37.30, 37.90)
# Радиус ОДНОГО anchor-запроса московских развёрток. 8 км — не оценка на глаз:
# столько намерено на прод-корпусе (35 552 активных вторичных лота region_code=77)
# у московской сетки центроидов, см. большой комментарий над `_MOSCOW_GRID_DEG` в
# backend/app/api/v1/trade_in.py (R=5..8 дают одинаковые 0.00% вне радиуса, при
# R=10 внутрь входит уже Наро-Фоминск). Кит не имеет права импортировать app.*
# (гейтится тестом), поэтому здесь осознанный дубль, а не второе независимое число.
#
# ВАЖНО: `radius_m` у run_avito_city_sweep/run_cian_city_sweep по умолчанию 1500 —
# строка расписания московской развёртки обязана передать 8000 явно, иначе круги
# соседних узлов не сомкнутся и между ними останутся непокрытые полосы.
MOSCOW_ANCHOR_RADIUS_M = 8000
# Шаг сетки согласован именно с этим радиусом: любая точка ядра должна лежать не
# дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего узла. Худшая точка — центр ячейки,
# расстояние до узла = hypot(шаг_lat/2, шаг_lon/2). При 0.10° по широте (11.13 км)
# и 0.15° по долготе (9.39 км) это hypot(5.56, 4.69) = 7.27 км < 8 км — с запасом
# 0.7 км. Узлы стоят по границам окна включительно (55.55/55.65/…/55.95 ×
# 37.30/37.45/…/37.90): 5 × 5 = 25 якорей, все внутри ядра, ни один не свисает
# наружу. Крупнее (0.15° × 0.20°) дало бы полудиагональ 10.5 км — дыры в покрытии;
# мельче — лишние прогоны страниц без нового покрытия.
_MOSCOW_GRID_LAT_STEP_DEG = 0.10
_MOSCOW_GRID_LON_STEP_DEG = 0.15
# Центр ядра сам является узлом сетки, и он же ПЕРВЫЙ в списке: get_city_anchor_point
# берёт anchors[0] опорной точкой гео-гарда штампа (см. её docstring), а из угла ядра
# гард мерил бы диагональ в 58 км и перестал бы что-либо отсекать.
_MOSCOW_CENTER_DEG: tuple[float, float] = (55.75, 37.60)
def _build_moscow_anchor_grid() -> list[tuple[float, float, str]]:
"""Сетка якорей ядра Москвы: центр первым, остальные узлы — с юго-запада на север."""
lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG
lat_steps = round((lat_max - lat_min) / _MOSCOW_GRID_LAT_STEP_DEG)
lon_steps = round((lon_max - lon_min) / _MOSCOW_GRID_LON_STEP_DEG)
grid: list[tuple[float, float, str]] = []
for i in range(lat_steps + 1):
lat = round(lat_min + i * _MOSCOW_GRID_LAT_STEP_DEG, 4)
for j in range(lon_steps + 1):
lon = round(lon_min + j * _MOSCOW_GRID_LON_STEP_DEG, 4)
grid.append((lat, lon, f"Москва {lat:.2f}/{lon:.2f}"))
# sort стабилен: центр уезжает в начало, порядок остальных не меняется.
grid.sort(key=lambda a: (a[0], a[1]) != _MOSCOW_CENTER_DEG)
return grid
MOSCOW_ANCHORS: list[tuple[float, float, str]] = _build_moscow_anchor_grid()
# Московская область. Двухслойная сетка якорей; оба слоя измерены, ни одна координата
# не выдумана.
#
# Слой 1 — города-спутники (22). Координаты первых 12 продублированы из
# `_COVERAGE_NEGATIVE_CENTROIDS_DEG` в backend/app/api/v1/trade_in.py (тот же запрет
# на импорт app.* — править нужно оба места); остальные 10 (Домодедово … Наро-Фоминск)
# взяты прямым запросом к Nominatim /search (city=<НП>, state=Московская область)
# 15.09.2026, все вернулись как place=city/town.
#
# Слой 2 — кластеры лот-массы (22). Это НЕ центры НП, а центроиды активных объявлений
# области в ячейках 0.05°, добранные жадным покрытием поверх слоя 1; подпись — ближайший
# НП по обратному геокодированию (zoom=14), она справочная. Слой нужен потому, что одних
# городов мало: замер на проде 15.09.2026 по 70 996 активным объявлениям region_code=50
# показал, что 22 городских якоря радиусом 10 км накрывают лишь 73.8% лот-массы, а вместе
# со вторым слоем — 95.2%.
#
# Порядок в обоих слоях — по УБЫВАНИЮ измеренной лот-массы (в скобках у каждой строки:
# слой 1 — активных лотов в 10 км от якоря, слой 2 — прирост покрытия, который этот якорь
# дал жадному алгоритму). Это и есть замер, которого раньше не было.
#
# ВАЖНО: покрытие считано при радиусе MOSCOW_OBLAST_ANCHOR_RADIUS_M. Расписание, которое
# передаст меньший radius_m, получит дыры — 95.2% относятся именно к 10 км.
#
# Оговорка о смещении: лот-масса взята из УЖЕ собранного корпуса (разовая загрузка
# 10-12.09.2026). Там, где разовый сбор не добрал, масса занижена, поэтому 95.2% —
# оценка сверху по тому, что видно, а не доказательство полноты по генеральной
# совокупности. Первый регулярный прогон обязан пересчитать покрытие по своему улову.
MOSCOW_OBLAST_ANCHOR_RADIUS_M = 10000
MOSCOW_OBLAST_CITY_ANCHORS: list[tuple[float, float, str]] = [
(55.6767, 37.8931, "Люберцы"), # 7429
(55.7614, 37.8564, "Реутов"), # 6824
(55.7444, 38.0128, "Железнодорожный"), # 6465
(55.7969, 37.9386, "Балашиха"), # 6429
(55.8317, 37.3300, "Красногорск"), # 5556
(55.9142, 37.8256, "Королёв"), # 5208
(55.9116, 37.7308, "Мытищи"), # 4805
(55.8894, 37.4450, "Химки"), # 4701
(55.5519, 37.7133, "Видное"), # 4574
(55.6789, 37.2639, "Одинцово"), # 4077
(55.9206, 37.9916, "Щёлково"), # 4031
(55.9386, 37.5100, "Долгопрудный"), # 3506
(55.8554, 38.4412, "Ногинск"), # 2820
(55.7845, 38.4449, "Электросталь"), # 2462
(55.4312, 37.5450, "Подольск"), # 2453
(55.4368, 37.7681, "Домодедово"), # 2059
(56.3153, 38.1360, "Сергиев Посад"), # 1889
(55.5709, 38.2282, "Раменское"), # 1344
(55.0939, 38.7670, "Коломна"), # 1118
(54.9155, 37.4196, "Серпухов"), # 1059
(55.8062, 38.9810, "Орехово-Зуево"), # 824
(55.3843, 36.7235, "Наро-Фоминск"), # 802
]
MOSCOW_OBLAST_CLUSTER_ANCHORS: list[tuple[float, float, str]] = [
(55.9395, 37.1805, "Рузино"), # +1528
(56.0837, 37.9229, "Лесной"), # +1253
(55.2779, 38.7415, "Воскресенск"), # +1002
(55.5588, 37.0775, "Апрелевка"), # +974
(55.7860, 36.9874, "Писково"), # +928
(56.2883, 37.4825, "Яхрома"), # +887
(56.3704, 36.6872, "Клин"), # +865
(55.6113, 37.9765, "Октябрьский"), # +825
(56.1460, 37.0592, "Солнечногорск"), # +616
(55.8732, 38.1862, "Осеево"), # +597
(55.3705, 39.0622, "Егорьевск"), # +587
(54.8393, 38.1760, "Кашира"), # +585
(55.7768, 38.7096, "Павловский Посад"), # +494
(55.1608, 37.4732, "Чехов"), # +493
(56.0920, 37.4972, "Некрасовский"), # +440
(55.5773, 39.5299, "Шатура"), # +397
(56.7358, 37.1643, "Дубна"), # +392
(55.9134, 36.8293, "Истра"), # +389
(56.4933, 38.1792, "Богородское"), # +312
(54.9619, 39.0273, "Луховицы"), # +228
(55.7715, 37.2308, "Ильинское-Усово"), # +224
(56.2552, 37.9754, "Хотьково"), # +207
]
MOSCOW_OBLAST_ANCHORS: list[tuple[float, float, str]] = [
*MOSCOW_OBLAST_CITY_ANCHORS,
*MOSCOW_OBLAST_CLUSTER_ANCHORS,
]
# Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
@ -342,6 +486,13 @@ CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = {
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
"talitsa": [(57.0148, 63.7302, "Талица центр")],
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")],
# ── Москва (77) и Московская область (50) ────────────────────────────────
# Три записи вместо одной, чтобы строка расписания могла выбрать скоуп:
# только Москва, только область, либо обе сразу (у cian/yandex под это есть
# отдельные идентификаторы, у avito один слаг на всё — см. CITY_LOCATIONS).
"moskva": MOSCOW_ANCHORS,
"moskovskaya_oblast": MOSCOW_OBLAST_ANCHORS,
"moskva_i_mo": [*MOSCOW_ANCHORS, *MOSCOW_OBLAST_ANCHORS],
}
@ -355,7 +506,14 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] |
"""
if city_slug is None:
return None
return CITY_ANCHORS.get(city_slug)
try:
return CITY_ANCHORS[city_slug]
except KeyError:
raise ValueError(
f"get_city_anchors: неизвестный city_slug={city_slug!r}. Раньше здесь был "
"тихий откат на ЕКБ (None → EKB_ANCHORS у вызывающей стороны) — развёртка "
"чужого города молча собирала Екатеринбург. Заведи слаг в CITY_ANCHORS."
) from None
def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
@ -383,6 +541,17 @@ def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None:
return (lat, lon)
# Хост Циана — РЕГИОНАЛЬНЫЙ поддомен, а не городской: ekb.cian.ru общий для всей
# Свердловской области (все 45 городов ниже), www.cian.ru обслуживает Москву и МО.
# Почему полем CityLocation, а не вторым реестром "слаг → хост": хост и region= —
# один и тот же гео-скоуп ОДНОГО запроса, и разъехаться они не имеют права
# (region=1 на ekb.cian.ru — чужая либо пустая выдача, причём молча). Отдельная
# карта завела бы второй ключ в том же ключевом пространстве и ровно этот риск
# рассинхрона; поле в той же frozen-записи рассинхронизировать нечем.
CIAN_SVERDLOVSK_HOST = "https://ekb.cian.ru"
CIAN_MOSCOW_HOST = "https://www.cian.ru"
@dataclass(frozen=True)
class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
@ -402,11 +571,25 @@ class CityLocation:
Тот же жёсткий инвариант, что у avito_slug run_yandex_city_sweep
явно падает (ValueError), если city_slug известен, а yandex_rgid
для него None.
cian_host поддомен Циана (см. CIAN_SVERDLOVSK_HOST выше). Дефолт
свердловский: все 45 записей области остаются байт в байт, а
город ВНЕ Свердловской обл. обязан передать хост явно, иначе
region= уедет на чужой поддомен.
avito_slug_is_region
True, если avito_slug слаг РЕГИОНА, а не города (moskva_i_mo
покрывает Москву и всю область одним скоупом: отдельного слага
у области нет, замер 10-12.09.2026). Такой слаг НЕ появляется в
ссылках карточек (там /moskva/, /himki/, /balashiha/), поэтому
per-card фильтр `avito_serp_ekb_only` на нём выбросил бы 100%
выдачи run_avito_city_sweep выключает фильтр для таких
записей (AvitoScraper(filter_cards_by_city_slug=False)).
"""
avito_slug: str | None
cian_region_id: int
yandex_rgid: int | None
cian_host: str = CIAN_SVERDLOVSK_HOST
avito_slug_is_region: bool = False
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
@ -469,14 +652,41 @@ CITY_LOCATIONS: dict[str, CityLocation] = {
"verkhoturye": CityLocation(None, 174499, 328251),
# ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
"mikhaylovsk": CityLocation(None, 175332, None),
# ── Москва (77) и Московская область (50) ──────────────────────────────
# Значения проверены живьём 10-12.09.2026 (scripts/local-avito-msk/collect.py):
# • avito — слаг ОДИН на оба региона (moskva_i_mo), отдельного слага у области
# нет; деление на 77/50 происходит позже, на импорте, по полигонам
# (app.services.regions.region_for_point), а не по слагу.
# • cian — region=1 Москва (92 817 лотов), 4593 область (60 231), -1 обе
# вместе (153 049). Два параметра region НЕ объединяются (побеждает
# последний), поэтому «Москва+область» — это именно -1, а не пара значений.
# • yandex — rgid 587795 Москва (18 705), 587654 область (14 242), 741964 обе
# вместе (31 074); контроль ЕКБ 559132 (4 060).
# Объединённый скоуп безопасен именно потому, что region_code лота считается по
# полигонам, а не по слагу развёртки: штамп региона не соврёт, даже когда в одном
# прогоне приезжают лоты обоих регионов.
"moskva": CityLocation("moskva_i_mo", 1, 587795, CIAN_MOSCOW_HOST, True),
"moskovskaya_oblast": CityLocation("moskva_i_mo", 4593, 587654, CIAN_MOSCOW_HOST, True),
"moskva_i_mo": CityLocation("moskva_i_mo", -1, 741964, CIAN_MOSCOW_HOST, True),
}
def get_city_location(city_slug: str | None) -> CityLocation | None:
"""CityLocation по slug (CITY_LOCATIONS). Неизвестный slug/None → None (=ЕКБ-дефолт)."""
"""CityLocation по slug (CITY_LOCATIONS). None → None (=ЕКБ-дефолт).
Неизвестный slug ValueError, НЕ ЕКБ-дефолт: тихий откат означал бы, что
развёртка с опечаткой в `default_params["city"]` собирает Екатеринбург под
чужим именем города, со штатными счётчиками и без аномалий в логах.
"""
if city_slug is None:
return None
return CITY_LOCATIONS.get(city_slug)
try:
return CITY_LOCATIONS[city_slug]
except KeyError:
raise ValueError(
f"get_city_location: неизвестный city_slug={city_slug!r} — развёртка "
"откатилась бы на ЕКБ. Заведи запись в CITY_LOCATIONS."
) from None
# Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
@ -538,11 +748,19 @@ CITY_DISPLAY_NAMES: dict[str, str] = {
"verkhoturye": "Верхотурье",
"talitsa": "Талица",
"novaya_lyalya": "Новая Ляля",
"moskva": "Москва",
}
EKATERINBURG_CITY_NAME = "Екатеринбург"
# Скоупы, внутри которых лоты принадлежат РАЗНЫМ городам: один штамп city на такой
# batch был бы ложью (у области это Химки/Балашиха/Подольск/…, у объединённого
# скоупа — ещё и Москва). resolve_city_name возвращает для них None, save_listings
# пишет city=NULL, и город доезжает позже из адреса (геокодер), а не из слага
# развёртки. Регион определяется полигонами, поэтому region_code от этого не страдает.
MULTI_CITY_SLUGS: frozenset[str] = frozenset({"moskovskaya_oblast", "moskva_i_mo"})
def resolve_city_name(city_slug: str | None) -> str:
def resolve_city_name(city_slug: str | None) -> str | None:
"""Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
city_slug=None Екатеринбург. Это НЕ заглушка «не знаем» это симметрия с
@ -551,11 +769,26 @@ def resolve_city_name(city_slug: str | None) -> str:
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург"
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ нет».
Неизвестный slug (не в CITY_DISPLAY_NAMES) тоже ЕКБ-дефолт, тем же путём, что и
get_city_location/get_city_anchors для неизвестных slug'ов.
get_city_location/get_city_anchors (оба теперь падают на неизвестном slug'е).
Слаг из MULTI_CITY_SLUGS None: в таком batch'е городов несколько, и любой
один штамп был бы ложью (save_listings при city=None просто не трогает поле, а
гео-гард штампа там инертен см. `_geo_guard_active` в scraper_kit.base).
Неизвестный slug ValueError, а не «Екатеринбург»: молчаливый ЕКБ-штамп на
лотах чужого города ровно тот дефект, из-за которого сломанную развёртку вне
ЕКБ было не отличить от исправной по логам.
"""
if city_slug is None:
return EKATERINBURG_CITY_NAME
return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME)
if city_slug in MULTI_CITY_SLUGS:
return None
try:
return CITY_DISPLAY_NAMES[city_slug]
except KeyError:
raise ValueError(
f"resolve_city_name: неизвестный city_slug={city_slug!r} — раньше здесь "
"молча возвращался 'Екатеринбург'. Заведи имя в CITY_DISPLAY_NAMES."
) from None
# Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами
@ -582,6 +815,19 @@ def resolve_city_name(city_slug: str | None) -> str:
_DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
_CITY_STAMP_RADIUS_KM: dict[str, float] = {
"verkhnyaya_pyshma": 8.0,
# Москва: гард меряет от ЦЕНТРА ядра (MOSCOW_ANCHORS[0] = 55.75/37.60 — центр
# стоит первым специально, см. _build_moscow_anchor_grid). Дальний угол ядра —
# hypot(22.25, 18.78) = 29.1 км, поэтому дефолтные 15 км срезали бы city у всего
# за МКАД и части внутри неё (Бутово, Митино, Некрасовка, Солнцево — легитимная
# Москва, ~20-27 км от центра). 30 км = 29.1 + ~0.9 км запаса на адреса, чьи
# координаты чуть выходят за рамку окна.
# Цена решения: в 30 км попадают и ближние города области (Химки ~20, Красногорск
# ~16, Люберцы ~14, Реутов ~12 км) — их лоты, если их захватит московская выдача,
# получат city="Москва". РЕГИОН при этом не врёт: region_code считается по
# полигонам (app.services.regions.region_for_point), а не по штампу города.
# Развёрток "moskovskaya_oblast"/"moskva_i_mo" это не касается вовсе: у них
# city=None (MULTI_CITY_SLUGS), а при city=None гард в save_listings инертен.
"moskva": 30.0,
}
@ -1259,6 +1505,9 @@ async def run_avito_city_sweep(
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
_avito_slug = _loc.avito_slug if _loc else city_slug
# Слаг региона (moskva_i_mo) в ссылках карточек не встречается — per-card фильтр
# по нему выбросил бы всю выдачу. См. CityLocation.avito_slug_is_region.
_avito_card_filter = not (_loc.avito_slug_is_region if _loc is not None else False)
# #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(city_slug)
@ -1406,7 +1655,10 @@ async def run_avito_city_sweep(
# shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
# переопределяет scraper._browser напрямую, минуя __aenter__.
scraper = AvitoScraper(
config, target_city_slug=_avito_slug, proxy_provider=proxy_provider
config,
target_city_slug=_avito_slug,
proxy_provider=proxy_provider,
filter_cards_by_city_slug=_avito_card_filter,
)
if browser_mode:
scraper._browser = shared_bf
@ -3078,6 +3330,7 @@ async def run_cian_city_sweep(
config,
proxy_provider=proxy_provider,
city_region_id=(_loc.cian_region_id if _loc else None),
city_host=(_loc.cian_host if _loc else None),
) as scraper:
anchor_lots = await scraper.fetch_around_multi_room(
_a_lat,
@ -4555,19 +4808,13 @@ async def run_domclick_city_sweep(
Возвращает DomClickCitySweepCounters.
"""
# Гео-скоуп этого свипа ЗАШИТ в ЕКБ: providers/domclick/serp.py строит BFF-URL с
# _EKB_ADDRESS_GUID/_EKB_AREA_ID и прогревает контекст на _SWEEP_ORIGIN
# (ekaterinburg.domclick.ru), а city_id — vestigial, fetch_city его игнорирует.
# Строка расписания с region_code 77/50 поэтому собрала бы ЕКБ и проштамповала его
# чужим регионом — молча, со штатными счётчиками. Падаем до первого запроса; гард
# снимается вместе с параметризацией GUID/aids/origin по региону (GUID Москвы и МО
# известны — scripts/local-avito-msk/collect.py MSK_DOMCLICK_GUID/MO_DOMCLICK_GUID).
if region_code != DEFAULT_REGION_CODE:
raise ValueError(
f"domclick_city_sweep: region_code={region_code} не поддержан — гео-скоуп "
"BFF-запроса зашит в ЕКБ (address GUID / aids / origin прогрева), прогон "
"собрал бы ЕКБ под чужим регионом"
)
# Гео-скоуп свипа больше не зашит в ЕКБ: его задаёт профиль региона (address
# GUID, aids, страница прогрева QRATOR, bbox гарда, имя города) — см.
# providers/domclick/serp.py DOMCLICK_GEO_PROFILES. На неизвестном коде региона
# падаем ДО первого запроса, а не откатываемся на ЕКБ: тихий fallback и есть тот
# баг, ради которого гео-скоуп стал параметром — прогон собрал бы ЕКБ и записал
# его под чужим region_code молча, со штатными счётчиками.
_geo_profile = geo_profile_for_region(region_code)
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
counters = DomClickCitySweepCounters()
@ -4660,7 +4907,10 @@ async def run_domclick_city_sweep(
"""Единственная citywide-фаза: fetch_city + save."""
nonlocal lots
async with DomClickScraper(
config, proxy_provider=proxy_provider, cookies=cookies
config,
proxy_provider=proxy_provider,
cookies=cookies,
geo_profile=_geo_profile,
) as _scraper:
_scraper_ref.append(_scraper)
if request_delay_sec is not None:
@ -4687,11 +4937,10 @@ async def run_domclick_city_sweep(
)
counters.lots_fetched += len(lots)
if lots:
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не
# угадывается.
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
# Имя города — из профиля региона, а не из сравнения с vestigial
# city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
# У области (50) city_name=None — одного города нет, угадывать нечего.
_dc_city = _geo_profile.city_name
inserted, updated = save_listings(
db,
lots,

View file

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

View file

@ -1,23 +1,63 @@
"""scraper_kit.pricing — общие примитивы ценового партиционирования SERP-обхода.
Единый движок адаптивной бисекции «комнатность × цена» для всех провайдеров
(avito / cian / yandex / domclick). См. `bisection.py`.
Два слоя, намеренно разделённые:
* `bisection.py` боевой async-движок обхода `walk_price_range`: ходит в сеть
через provider-callback'и, пагинирует листья. `BisectionStats` — счётчики
усечения для уже существующих свипов.
* `planner.py` СИНХРОННЫЙ ЧИСТЫЙ планировщик `plan_price_corridors`: на вход
«сколько позиций в коридоре», на выход список коридоров + `PlanStats`.
Геометрическая середина `sqrt(lo*hi)` (цены логнормальны) и предохранители
`MAX_DEPTH` / `MIN_WIDTH_RATIO`; перенесён из обкатанного живьём
`scripts/local-avito-msk/collect.py`.
"""
from __future__ import annotations
from scraper_kit.pricing.bisection import (
BisectionConfig,
BisectionStats,
ProbeFailPolicy,
ProbeResult,
done_range_skipper,
walk_price_range,
)
from scraper_kit.pricing.planner import (
EKB_REGION_CODE,
MAX_DEPTH,
MIN_WIDTH_RATIO,
PRICE_CEIL,
PRICE_FLOOR,
PRICE_PROBE_START,
Corridor,
CountFn,
PlanStats,
PricePlan,
geometric_mid,
plan_price_corridors,
price_bisection_enabled,
width_ratio,
)
__all__ = [
"EKB_REGION_CODE",
"MAX_DEPTH",
"MIN_WIDTH_RATIO",
"PRICE_CEIL",
"PRICE_FLOOR",
"PRICE_PROBE_START",
"BisectionConfig",
"BisectionStats",
"Corridor",
"CountFn",
"PlanStats",
"PricePlan",
"ProbeFailPolicy",
"ProbeResult",
"done_range_skipper",
"geometric_mid",
"plan_price_corridors",
"price_bisection_enabled",
"walk_price_range",
"width_ratio",
]

View file

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

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,
target_city_slug: str | None = None,
proxy_provider: ProxyProvider | None = None,
filter_cards_by_city_slug: bool = True,
) -> None:
super().__init__()
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
@ -420,6 +421,14 @@ class AvitoScraper(BaseScraper):
# None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only,
# чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/.
self._target_city_slug = target_city_slug
# Слаг РЕГИОНА (moskva_i_mo покрывает Москву и всю область одним скоупом) в
# ссылках карточек не встречается — там /moskva/, /himki/, /balashiha/… .
# Поэтому per-card фильтр по слагу города-цели на таком скоупе выбросил бы
# 100% выдачи (замер 10-12.09.2026). False — фильтр выключен ДЛЯ ЭТОГО
# прогона, независимо от глобального config.avito_serp_ekb_only: флаг
# настройки говорит «отбрасывать добивку по всей России», а не «собирать
# только Екатеринбург», и на региональном слаге он неприменим физически.
self._filter_cards_by_city_slug = filter_cards_by_city_slug
# #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время
# fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы
# остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча.
@ -2111,7 +2120,7 @@ class AvitoScraper(BaseScraper):
ts_map = _build_sort_timestamp_map(html)
cards = tree.css('[data-marker="item"]')
lots: list[ScrapedLot] = []
ekb_only = self._config.avito_serp_ekb_only
ekb_only = self._config.avito_serp_ekb_only and self._filter_cards_by_city_slug
# #2487: слаг города, карточки которого оставляем. Дефолт "ekaterinburg" —
# ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load).
kept_slug = self._target_city_slug or "ekaterinburg"

View file

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

View file

@ -32,6 +32,7 @@ import hashlib
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
@ -49,8 +50,6 @@ logger = logging.getLogger(__name__)
# ── API constants ─────────────────────────────────────────────────────────────
_BFF_BASE = "https://bff-search-web.domclick.ru"
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
_EKB_AREA_ID = "20561"
# Buckets to sweep — порядок влияет на логи.
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
@ -81,13 +80,123 @@ _DOMCLICK_BISECTION = BisectionConfig(
open_split_floor=0,
)
# ── EKB geo guard ─────────────────────────────────────────────────────────────
# ── Гео-профиль региона ───────────────────────────────────────────────────────
_EKB_LAT_MIN: float = 56.6
_EKB_LAT_MAX: float = 57.0
_EKB_LON_MIN: float = 60.2
_EKB_LON_MAX: float = 60.9
_EKB_REGION_NAME: str = "Екатеринбург"
@dataclass(frozen=True)
class DomClickGeoProfile:
"""Всё, что отличает свип одного региона от свипа другого.
Раньше эти величины были модульными константами `_EKB_*`, и прогон с
region_code != 66 собрал бы Екатеринбург, проштамповав его чужим регионом.
Поля:
address_guid параметр `address` BFF-запроса (регион/локалити DomClick).
aids параметр `aids` (сужение выдачи). ``None`` = параметр НЕ
добавлять: у Москвы и области счётчик без него совпадает
с сайтом (замер 12.09.2026), у ЕКБ aids=20561 обязателен.
origin_url городская страница выдачи, на которой прогревается
контекст перед запросом к BFF (#3264). BFF — не страница,
а JSON-ручка SPA: навигацией на неё приходит только
ChallengeTimeout, а подзапросом из прогретой выдачи
HTTP 200. Прогревать нужно СВОЙ город.
bbox (lat_min, lat_max, lon_min, lon_max) гео-гарда выдачи.
region_name ожидаемый ``item["offerRegionName"]``. ``None`` = НЕ
сверять имя, гард только по bbox: вне ЕКБ поле ненадёжно,
офферы Новой Москвы приходят как «г. Говорово», а не
«Москва» (замер 12.09.2026).
city_name что писать в listings.city. ``None`` = не угадывать
(у области нет одного города).
"""
region_code: int
address_guid: str
aids: str | None
origin_url: str
bbox: tuple[float, float, float, float]
region_name: str | None
city_name: str | None
# Реестр профилей по коду региона.
#
# bbox для 77 и 50 — ДУБЛЬ значений backend/app/services/regions.py
# REGIONS[code].bbox_region (порядок тот же: lat_min, lat_max, lon_min, lon_max).
# Дубль намеренный и неустранимый: kit не имеет права импортировать app.* (это
# гейтится тестом), а общего места у kit и backend нет. Правишь bbox_region
# там — поправь здесь.
#
# bbox для 66 — НЕ bbox_region Свердловской области, а исторический ГОРОДСКОЙ
# bbox ЕКБ-гарда: свип 66 citywide по Екатеринбургу, а не по всей области.
# Значения 66 (GUID, aids, origin, bbox, имя региона) сохранены байт в байт,
# чтобы параметризация не изменила поведение прод-прогонов.
DOMCLICK_GEO_PROFILES: dict[int, DomClickGeoProfile] = {
66: DomClickGeoProfile(
region_code=66,
address_guid="0d475b79-88de-4054-818c-37d8f9d0d440",
aids="20561",
origin_url="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
bbox=(56.6, 57.0, 60.2, 60.9),
region_name="Екатеринбург",
city_name="Екатеринбург",
),
# GUID Москвы и области проверены живым запросом 12.09.2026 (у Москвы region и
# locality — один и тот же GUID), см. scripts/local-avito-msk/collect.py
# MSK_DOMCLICK_GUID / MO_DOMCLICK_GUID. aids для обоих НЕ нужен: без него
# счётчик листинга совпадает с сайтом.
#
# Страница прогрева 77/50 — апексный домен, проверено запросом 15.09.2026:
# msk.domclick.ru -> 301 https://domclick.ru/...
# moskovskaya-oblast.domclick.ru-> 301 https://domclick.ru/...
# domclick.ru -> 401 (QRATOR-челлендж, как у ЕКБ)
# ekaterinburg.domclick.ru -> 401 (контроль)
# Схема `<город>.domclick.ru` работает НЕ для всех городов: у Москвы своего
# субдомена нет, апекс и есть московская выдача. 301 увёл бы прогрев на
# другой URL и с большой вероятностью потерял бы куку QRATOR.
77: DomClickGeoProfile(
region_code=77,
address_guid="1d1463ae-c80f-4d19-9331-a1b68a85b553",
aids=None,
origin_url="https://domclick.ru/pokupka/kvartiry/vtorichka",
bbox=(55.10, 56.10, 36.80, 38.10),
region_name=None,
city_name="Москва",
),
50: DomClickGeoProfile(
region_code=50,
address_guid="9930cc20-32c6-4f6f-a55e-cd67086c5171",
aids=None,
# Область выдаётся с того же апексного домена; см. оговорку выше.
origin_url="https://domclick.ru/pokupka/kvartiry/vtorichka",
bbox=(54.20, 56.96, 35.14, 40.21),
region_name=None,
city_name=None,
),
}
DEFAULT_GEO_PROFILE: DomClickGeoProfile = DOMCLICK_GEO_PROFILES[66]
# Обратная совместимость: страница прогрева ЕКБ как модульная константа
# (импортируется тестами subresource-режима).
_SWEEP_ORIGIN = DEFAULT_GEO_PROFILE.origin_url
def geo_profile_for_region(region_code: int) -> DomClickGeoProfile:
"""Профиль по коду региона. ValueError на неизвестном коде.
Молчаливый fallback на ЕКБ здесь недопустим: он и есть тот баг, ради
которого гео-скоуп стал параметром прогон собрал бы ЕКБ и записал его
под чужим region_code со штатными счётчиками.
"""
profile = DOMCLICK_GEO_PROFILES.get(region_code)
if profile is None:
_known = ", ".join(str(code) for code in sorted(DOMCLICK_GEO_PROFILES))
raise ValueError(
f"domclick: нет гео-профиля для region_code={region_code} "
f"(известны: {_known}); заведи запись в DOMCLICK_GEO_PROFILES "
"(address GUID, aids, страница прогрева, bbox) перед прогоном"
)
return profile
# ── JSON extraction ───────────────────────────────────────────────────────────
@ -152,14 +261,19 @@ def _build_offers_url(
price_gte: int | None,
price_lte: int | None,
offset: int,
*,
address_guid: str,
aids: str | None,
) -> str:
"""Строит URL для GET /api/offers/v1 с пагинацией.
urlencode кодирует "5+" "5%2B" (literal '+' в query string = space reject).
aids=None параметр не добавляется (Москва и область, см. DomClickGeoProfile).
"""
params: list[tuple[str, str]] = [
("address", _EKB_ADDRESS_GUID),
("aids", _EKB_AREA_ID),
params: list[tuple[str, str]] = [("address", address_guid)]
if aids is not None:
params.append(("aids", aids))
params += [
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
@ -176,25 +290,19 @@ def _build_offers_url(
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
# Страница, на которой прогревается контекст перед запросом к BFF (#3264).
# BFF — не страница, а JSON-ручка SPA; навигацией браузера на неё через мобильные узлы
# пула приходит только ChallengeTimeout (прод, прогоны 5330 и 5351 — 9/9 и 6/6 запросов
# зависли), а подзапросом из прогретой выдачи — HTTP 200 за 6 секунд на всех четырёх
# узлах и всех шести комнатных корзинах (замер 30.08.2026, суммарно 6359 офферов против
# 6367, снятых напрямую с резидентного IP). Прогрев обязателен: рукопожатие QRATOR
# попадает в куки контекста именно при заходе на страницу.
_SWEEP_ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
def _build_count_url(
rooms: str,
price_gte: int | None,
price_lte: int | None,
*,
address_guid: str,
aids: str | None,
) -> str:
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
params: list[tuple[str, str]] = [
("address", _EKB_ADDRESS_GUID),
("aids", _EKB_AREA_ID),
params: list[tuple[str, str]] = [("address", address_guid)]
if aids is not None:
params.append(("aids", aids))
params += [
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
@ -261,7 +369,7 @@ class DomClickScraper(BaseScraper):
Counters (публичные после fetch_city):
parse_failures офферы с ошибкой маппинга
geo_filtered офферы вне ЕКБ bbox или с неверным offerRegionName
geo_filtered офферы вне bbox профиля или с неверным offerRegionName
blocked True если sweep был прерван QRATOR-блоком
fetch_errors не-block ошибки извлечения JSON (truncated/garbled/bad shape)
buckets_total сколько комнатных бакетов прогон собирался пройти
@ -282,8 +390,12 @@ class DomClickScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None,
cookies: dict[str, str] | None = None,
geo_profile: DomClickGeoProfile | None = None,
) -> None:
super().__init__()
# Гео-скоп прогона: GUID/aids в URL, страница прогрева и bbox гарда.
# Дефолт — ЕКБ (прежнее поведение всех вызовов без явного профиля).
self._geo = geo_profile or DEFAULT_GEO_PROFILE
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
# BrowserFetcher, delay_provider заменяет прямой импорт
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
@ -352,11 +464,12 @@ class DomClickScraper(BaseScraper):
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
вызывающим кодом (run_domclick_city_sweep), но:
- city_id vestigial (EКБ захардкожен через GUID).
- city_id vestigial: гео-скоп берётся из geo_profile (GUID/aids/bbox),
а не из city_id.
- rooms игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
Args:
city_id: игнорируется (EKB захардкожен).
city_id: игнорируется (гео-скоп в geo_profile скрейпера).
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
pages: максимальное число страниц на бакет (safety cap).
start_bucket_index: с какой позиции ROOM_BUCKETS начинать обход (#2854).
@ -508,12 +621,18 @@ class DomClickScraper(BaseScraper):
извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и return 0
(бакет пропускается, sweep продолжается).
"""
url = _build_count_url(rooms, price_gte, price_lte)
url = _build_count_url(
rooms,
price_gte,
price_lte,
address_guid=self._geo.address_guid,
aids=self._geo.aids,
)
logger.debug("domklik: count url=%s", url)
html = await fetcher.fetch(
url,
origin=_SWEEP_ORIGIN,
referer=_SWEEP_ORIGIN,
origin=self._geo.origin_url,
referer=self._geo.origin_url,
cookies=self._cookies,
fetch_mode="subresource",
)
@ -603,15 +722,22 @@ class DomClickScraper(BaseScraper):
offset = page_idx * PAGE_SIZE
if offset >= OFFSET_CAP:
break
url = _build_offers_url(rooms, price_gte, price_lte, offset)
url = _build_offers_url(
rooms,
price_gte,
price_lte,
offset,
address_guid=self._geo.address_guid,
aids=self._geo.aids,
)
logger.debug("domklik: offers url=%s", url)
html = await fetcher.fetch(
url,
origin=_SWEEP_ORIGIN,
referer=_SWEEP_ORIGIN,
cookies=self._cookies,
fetch_mode="subresource",
)
url,
origin=self._geo.origin_url,
referer=self._geo.origin_url,
cookies=self._cookies,
fetch_mode="subresource",
)
try:
data = _extract_json(html)
except DomClickBlockedError:
@ -652,22 +778,23 @@ class DomClickScraper(BaseScraper):
break
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
"""Гео-гард: пропускает только листинги ЕКБ в bbox.
"""Гео-гард по bbox профиля (и по имени региона, если оно задано).
aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
У ЕКБ aids=20561 и так даёт чистую выдачу, а сверка имени остаётся
defensive-проверкой. Вне 66 profile.region_name обязан быть None:
offerRegionName там ненадёжен офферы Новой Москвы приходят как
«г. Говорово», и сверка по имени вырезала бы законную выдачу.
"""
region = item.get("offerRegionName", "")
if region != _EKB_REGION_NAME:
expected_region = self._geo.region_name
if expected_region is not None and item.get("offerRegionName", "") != expected_region:
return False
loc = item.get("location") or {}
lat = loc.get("lat")
lon = loc.get("lon")
if lat is None or lon is None:
return False
return (
_EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX
and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
)
lat_min, lat_max, lon_min, lon_max = self._geo.bbox
return lat_min <= float(lat) <= lat_max and lon_min <= float(lon) <= lon_max
def _map_item(
self, item: dict[str, Any], *, force_rooms: int | None = None
@ -844,6 +971,7 @@ async def scrape_domclick_city(
pages: int = 100,
*,
delay_provider: Callable[[str], float] | None = None,
geo_profile: DomClickGeoProfile | None = None,
) -> list[ScrapedLot]:
"""Удобная точка входа для вызова из Celery tasks.
@ -852,5 +980,7 @@ async def scrape_domclick_city(
import asyncio
lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None))
"""
async with DomClickScraper(config, delay_provider=delay_provider) as scraper:
async with DomClickScraper(
config, delay_provider=delay_provider, geo_profile=geo_profile
) as scraper:
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)