From 6da7de06a4f4402fd1e84c527fbbdd71ce1b717f Mon Sep 17 00:00:00 2001 From: lekss361 Date: Tue, 15 Sep 2026 17:51:30 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=BA=20=D1=80=D0=B5=D0=B3=D1=83=D0=BB=D1=8F=D1=80=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=81=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=9C=D0=BE=D1=81=D0=BA=D0=B2=D0=B5=20=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=82=D0=B8:=20=D1=8F=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D1=8F,=20ID=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?,=20=D0=B3=D0=B5=D0=BE-=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=94=D0=BE=D0=BC=D0=9A=D0=BB=D0=B8=D0=BA=D0=B0=20(#3?= =?UTF-8?q?533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ДомКлик: DomClickGeoProfile вместо зашитых _EKB_ADDRESS_GUID/_EKB_AREA_ID; GUID Москвы и области проверены живьём, aids вне ЕКБ не нужен, гард по bbox профиля. Страница прогрева 77/50 — апексный domclick.ru: субдомены msk./moskovskaya-oblast. отдают 301. Циан/Авито/Яндекс: CityLocation.cian_host, три новых скоупа (moskva, moskovskaya_oblast, moskva_i_mo), avito_slug_is_region, city=NULL у мультигородских скоупов. Неизвестный слаг теперь падает с ValueError вместо молчаливого отката на Екатеринбург. Якоря: Москва — сетка 25 точек под radius_m=8000; область — 22 города-спутника (10 добраны из Nominatim) плюс 22 кластера лот-массы. Замер на проде: города радиусом 10 км дают 73.8% лот-массы области, вместе с кластерами — 95.2%. Ценовые коридоры: планировщик plan_price_corridors со статистикой усечения плюс BisectionStats в живом движке. Провайдеры их пока не передают — отдельный заход. Расписаний scrape_schedules для 77/50 в этом PR нет: они пойдут после первого ручного прогона, подтверждающего живость профилей. --- tradein-mvp/backend/tests/test_city_sweep.py | 22 +- .../tests/test_domclick_geo_profile.py | 115 +++++++ ..._migration_262_oblast_city_sweeps_wave2.py | 19 +- .../tests/test_msk_region_scope_guard.py | 21 +- .../backend/tests/test_msk_sweep_registry.py | 233 ++++++++++++++ .../tests/test_price_corridor_planner.py | 261 +++++++++++++++ .../test_scraper_kit_scheduler_parity.py | 17 +- .../src/scraper_kit/orchestration/pipeline.py | 303 ++++++++++++++++-- .../scraper_kit/orchestration/scheduler.py | 6 +- .../src/scraper_kit/pricing/__init__.py | 44 ++- .../src/scraper_kit/pricing/bisection.py | 85 +++++ .../src/scraper_kit/pricing/planner.py | 284 ++++++++++++++++ .../src/scraper_kit/providers/avito/serp.py | 11 +- .../src/scraper_kit/providers/cian/serp.py | 20 +- .../scraper_kit/providers/domclick/serp.py | 222 ++++++++++--- 15 files changed, 1560 insertions(+), 103 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_domclick_geo_profile.py create mode 100644 tradein-mvp/backend/tests/test_msk_sweep_registry.py create mode 100644 tradein-mvp/backend/tests/test_price_corridor_planner.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/planner.py diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index e138a150..a848c2d5 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -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 (НЕ силентный diff --git a/tradein-mvp/backend/tests/test_domclick_geo_profile.py b/tradein-mvp/backend/tests/test_domclick_geo_profile.py new file mode 100644 index 00000000..6e21f265 --- /dev/null +++ b/tradein-mvp/backend/tests/test_domclick_geo_profile.py @@ -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) diff --git a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py index 492fdb1c..261d6993 100644 --- a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py +++ b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py @@ -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: diff --git a/tradein-mvp/backend/tests/test_msk_region_scope_guard.py b/tradein-mvp/backend/tests/test_msk_region_scope_guard.py index b65522cb..bcd5a9a6 100644 --- a/tradein-mvp/backend/tests/test_msk_region_scope_guard.py +++ b/tradein-mvp/backend/tests/test_msk_region_scope_guard.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_msk_sweep_registry.py b/tradein-mvp/backend/tests/test_msk_sweep_registry.py new file mode 100644 index 00000000..03f9ef2f --- /dev/null +++ b/tradein-mvp/backend/tests/test_msk_sweep_registry.py @@ -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") diff --git a/tradein-mvp/backend/tests/test_price_corridor_planner.py b/tradein-mvp/backend/tests/test_price_corridor_planner.py new file mode 100644 index 00000000..081a019b --- /dev/null +++ b/tradein-mvp/backend/tests/test_price_corridor_planner.py @@ -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) diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index 1480dfcc..b657bf60 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -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 ────── diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 245c952a..f9ad6e60 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -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, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 4e6e1a14..2611fa02 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -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( diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/__init__.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/__init__.py index ad705018..ac51acec 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/__init__.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/__init__.py @@ -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", ] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/bisection.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/bisection.py index 7d637d53..dd023020 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/bisection.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/bisection.py @@ -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) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/planner.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/planner.py new file mode 100644 index 00000000..faf40eda --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/pricing/planner.py @@ -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) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index c31e9c7a..8ef633b2 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -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" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py index 55f28626..3082a8a8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py @@ -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-бакетов. """ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py index 441ba041..5f8bbc97 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py @@ -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)