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:
parent
dbf46228fb
commit
660aced334
4 changed files with 324 additions and 70 deletions
115
tradein-mvp/backend/tests/test_domclick_geo_profile.py
Normal file
115
tradein-mvp/backend/tests/test_domclick_geo_profile.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue