From 660aced33492b90d3eaefb890c767b0a73db6447 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 15 Sep 2026 19:35:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(domclick):=20=D0=B3=D0=B5=D0=BE-=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D1=83=D0=BF=20=D1=81=D0=B2=D0=B8=D0=BF=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=D0=B0=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D0=B7=D0=B0=D1=88=D0=B8=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=95=D0=9A=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Черновик регионального свипа: GUID, aids, страница прогрева QRATOR, bbox гарда и имя города собраны в DomClickGeoProfile и реестр DOMCLICK_GEO_PROFILES (66/77/50). Раньше это были модульные константы _EKB_*, поэтому строка расписания с region_code 77/50 собрала бы Екатеринбург и записала его под чужим регионом молча — pipeline отбивал такой прогон ValueError'ом; теперь вместо гарда резолвится профиль, а падение остаётся только на коде без профиля. Значения 66 сохранены байт в байт (тест сверяет offers- и count-URL с литералами, снятыми до правки). У Москвы и области aids не добавляется: без него счётчик совпадает с сайтом (замер 12.09.2026). Имя региона сверяется только там, где задано: offerRegionName вне ЕКБ ненадёжен — офферы Новой Москвы приходят как «г. Говорово», поэтому для 77/50 гард только по bbox. bbox 77/50 продублированы из app.services.regions REGIONS[code].bbox_region — кит не имеет права импортировать app.*. Не проверено живьём: страница прогрева для 77/50 (msk.domclick.ru) — единственное поле профилей, взятое по схеме субдомена, а не замером. --- .../tests/test_domclick_geo_profile.py | 115 +++++++++ .../tests/test_msk_region_scope_guard.py | 21 +- .../src/scraper_kit/orchestration/pipeline.py | 40 ++-- .../scraper_kit/providers/domclick/serp.py | 218 ++++++++++++++---- 4 files changed, 324 insertions(+), 70 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_domclick_geo_profile.py 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_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/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 245c952a..f7317d43 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, @@ -4555,19 +4559,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 +4658,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 +4688,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/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py index 441ba041..6059505d 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,119 @@ _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, НЕ подтверждённое живым + # прогоном, — страница прогрева. Субдомен взят по схеме `<город>.domclick.ru` + # (ekaterinburg/spb уже встречаются в detail-пути). Первый живой прогон обязан + # проверить, что прогрев отдаёт 200, иначе придёт ChallengeTimeout. + 77: DomClickGeoProfile( + region_code=77, + address_guid="1d1463ae-c80f-4d19-9331-a1b68a85b553", + aids=None, + origin_url="https://msk.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://msk.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 +257,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 +286,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 +365,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 +386,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 +460,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 +617,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 +718,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 +774,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 +967,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 +976,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)