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

View file

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

View file

@ -32,6 +32,7 @@ import hashlib
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
@ -49,8 +50,6 @@ logger = logging.getLogger(__name__)
# ── API constants ─────────────────────────────────────────────────────────────
_BFF_BASE = "https://bff-search-web.domclick.ru"
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
_EKB_AREA_ID = "20561"
# Buckets to sweep — порядок влияет на логи.
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
@ -81,13 +80,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)