feat(domclick): гео-скоуп свипа — параметр региона вместо зашитого ЕКБ

Черновик регионального свипа: 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) — единственное
поле профилей, взятое по схеме субдомена, а не замером.
This commit is contained in:
bot-backend 2026-09-15 19:35:28 +03:00
parent dbf46228fb
commit 660aced334
4 changed files with 324 additions and 70 deletions

View file

@ -0,0 +1,115 @@
"""Гео-скоуп DomClick — параметр региона, а не зашитый Екатеринбург.
До этого GUID/aids/страница прогрева/bbox жили модульными константами `_EKB_*`,
и прогон с region_code 77/50 собрал бы ЕКБ под чужим регионом. Тесты стерегут
две вещи: профиль 66 не изменил НИ БАЙТА в запросах (прод-поведение прежнее),
а профили Москвы и области действительно меняют скоуп без aids и без сверки
имени региона, которое вне ЕКБ ненадёжно.
"""
from types import SimpleNamespace
import pytest
from scraper_kit.providers.domclick.serp import (
DOMCLICK_GEO_PROFILES,
DomClickScraper,
_build_count_url,
_build_offers_url,
geo_profile_for_region,
)
# Литералы снятые с кода ДО параметризации: любое расхождение = смена прод-запроса.
_EKB_OFFERS_URL_BEFORE = (
"https://bff-search-web.domclick.ru/api/offers/v1"
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
"&deal_type=sale&category=living&offer_type=flat&rooms=5%2B"
"&sort=qi&sort_dir=desc&offset=40&limit=20"
"&sale_price__gte=1000000&sale_price__lte=5000000"
)
_EKB_COUNT_URL_BEFORE = (
"https://bff-search-web.domclick.ru/api/offers/count/v1"
"?address=0d475b79-88de-4054-818c-37d8f9d0d440&aids=20561"
"&deal_type=sale&category=living&offer_type=flat&rooms=2"
"&sort=qi&sort_dir=desc"
)
def _url_for(region_code: int, *, count: bool) -> str:
profile = geo_profile_for_region(region_code)
if count:
return _build_count_url(
"2", None, None, address_guid=profile.address_guid, aids=profile.aids
)
return _build_offers_url(
"5+",
1_000_000,
5_000_000,
40,
address_guid=profile.address_guid,
aids=profile.aids,
)
def test_ekb_offers_url_unchanged_byte_for_byte() -> None:
assert _url_for(66, count=False) == _EKB_OFFERS_URL_BEFORE
def test_ekb_count_url_unchanged_byte_for_byte() -> None:
assert _url_for(66, count=True) == _EKB_COUNT_URL_BEFORE
@pytest.mark.parametrize("region_code", [77, 50])
@pytest.mark.parametrize("count", [False, True])
def test_msk_and_oblast_urls_carry_no_aids(region_code: int, count: bool) -> None:
"""aids сужает выдачу до агрегатора; у Москвы и области счётчик без него
совпадает с сайтом (замер 12.09.2026), поэтому параметра быть не должно."""
url = _url_for(region_code, count=count)
assert "aids=" not in url
assert f"address={geo_profile_for_region(region_code).address_guid}" in url
def test_profiles_are_distinct_and_ekb_keeps_aids() -> None:
guids = {code: p.address_guid for code, p in DOMCLICK_GEO_PROFILES.items()}
assert len(set(guids.values())) == len(guids)
assert DOMCLICK_GEO_PROFILES[66].aids == "20561"
# Имя региона сверяется ТОЛЬКО у ЕКБ.
assert DOMCLICK_GEO_PROFILES[66].region_name == "Екатеринбург"
assert DOMCLICK_GEO_PROFILES[77].region_name is None
assert DOMCLICK_GEO_PROFILES[50].region_name is None
# Имя города для listings.city: у области его нет и угадывать нечего.
assert DOMCLICK_GEO_PROFILES[66].city_name == "Екатеринбург"
assert DOMCLICK_GEO_PROFILES[77].city_name == "Москва"
assert DOMCLICK_GEO_PROFILES[50].city_name is None
def _geo_ok(region_code: int, item: dict[str, object]) -> bool:
"""_is_geo_ok без конструирования скрейпера: метод читает только self._geo."""
fake = SimpleNamespace(_geo=geo_profile_for_region(region_code))
return DomClickScraper._is_geo_ok(fake, item) # type: ignore[arg-type]
def test_msk_accepts_novaya_moskva_with_foreign_region_name() -> None:
"""Новая Москва приходит как «г. Говорово» — сверка по имени вырезала бы
законную выдачу, поэтому вне 66 гард только по bbox."""
item = {"offerRegionName": "г. Говорово", "location": {"lat": 55.62, "lon": 37.35}}
assert _geo_ok(77, item) is True
def test_msk_rejects_point_outside_bbox() -> None:
ekb_point = {"offerRegionName": "Москва", "location": {"lat": 56.83, "lon": 60.60}}
assert _geo_ok(77, ekb_point) is False
def test_ekb_still_rejects_foreign_region_name() -> None:
item = {"offerRegionName": "г. Говорово", "location": {"lat": 56.83, "lon": 60.60}}
assert _geo_ok(66, item) is False
def test_oblast_bbox_accepts_podmoskovie_point() -> None:
item = {"offerRegionName": "г. Подольск", "location": {"lat": 55.43, "lon": 37.55}}
assert _geo_ok(50, item) is True
def test_unknown_region_code_raises() -> None:
with pytest.raises(ValueError, match="region_code=99"):
geo_profile_for_region(99)

View file

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

View file

@ -72,7 +72,11 @@ from scraper_kit.providers.cian.newbuilding import (
save_newbuilding_enrichment, save_newbuilding_enrichment,
) )
from scraper_kit.providers.cian.serp import CianScraper from scraper_kit.providers.cian.serp import CianScraper
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS, DomClickScraper from scraper_kit.providers.domclick.serp import (
ROOM_BUCKETS,
DomClickScraper,
geo_profile_for_region,
)
from scraper_kit.providers.yandex.serp import ( from scraper_kit.providers.yandex.serp import (
DEFAULT_PRICE_RANGES, DEFAULT_PRICE_RANGES,
ROOM_PATH, ROOM_PATH,
@ -4555,19 +4559,13 @@ async def run_domclick_city_sweep(
Возвращает DomClickCitySweepCounters. Возвращает DomClickCitySweepCounters.
""" """
# Гео-скоуп этого свипа ЗАШИТ в ЕКБ: providers/domclick/serp.py строит BFF-URL с # Гео-скоуп свипа больше не зашит в ЕКБ: его задаёт профиль региона (address
# _EKB_ADDRESS_GUID/_EKB_AREA_ID и прогревает контекст на _SWEEP_ORIGIN # GUID, aids, страница прогрева QRATOR, bbox гарда, имя города) — см.
# (ekaterinburg.domclick.ru), а city_id — vestigial, fetch_city его игнорирует. # providers/domclick/serp.py DOMCLICK_GEO_PROFILES. На неизвестном коде региона
# Строка расписания с region_code 77/50 поэтому собрала бы ЕКБ и проштамповала его # падаем ДО первого запроса, а не откатываемся на ЕКБ: тихий fallback и есть тот
# чужим регионом — молча, со штатными счётчиками. Падаем до первого запроса; гард # баг, ради которого гео-скоуп стал параметром — прогон собрал бы ЕКБ и записал
# снимается вместе с параметризацией GUID/aids/origin по региону (GUID Москвы и МО # его под чужим region_code молча, со штатными счётчиками.
# известны — scripts/local-avito-msk/collect.py MSK_DOMCLICK_GUID/MO_DOMCLICK_GUID). _geo_profile = geo_profile_for_region(region_code)
if region_code != DEFAULT_REGION_CODE:
raise ValueError(
f"domclick_city_sweep: region_code={region_code} не поддержан — гео-скоуп "
"BFF-запроса зашит в ЕКБ (address GUID / aids / origin прогрева), прогон "
"собрал бы ЕКБ под чужим регионом"
)
_resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0 _resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0
counters = DomClickCitySweepCounters() counters = DomClickCitySweepCounters()
@ -4660,7 +4658,10 @@ async def run_domclick_city_sweep(
"""Единственная citywide-фаза: fetch_city + save.""" """Единственная citywide-фаза: fetch_city + save."""
nonlocal lots nonlocal lots
async with DomClickScraper( async with DomClickScraper(
config, proxy_provider=proxy_provider, cookies=cookies config,
proxy_provider=proxy_provider,
cookies=cookies,
geo_profile=_geo_profile,
) as _scraper: ) as _scraper:
_scraper_ref.append(_scraper) _scraper_ref.append(_scraper)
if request_delay_sec is not None: if request_delay_sec is not None:
@ -4687,11 +4688,10 @@ async def run_domclick_city_sweep(
) )
counters.lots_fetched += len(lots) counters.lots_fetched += len(lots)
if lots: if lots:
# #2594: domclick oblast-rollout (B2) ещё не wired (нет city_id→slug # Имя города — из профиля региона, а не из сравнения с vestigial
# мэппинга, см. CITY_LOCATIONS) — известный ЕКБ city_id получает # city_id: гео-скоп задаёт регион, он же знает, какой город штамповать.
# "Екатеринбург", любой другой (будущий B2) честно остаётся None, а не # У области (50) city_name=None — одного города нет, угадывать нечего.
# угадывается. _dc_city = _geo_profile.city_name
_dc_city = EKATERINBURG_CITY_NAME if city_id == DOMCLICK_DEFAULT_CITY_ID else None
inserted, updated = save_listings( inserted, updated = save_listings(
db, db,
lots, lots,

View file

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