feat(scraper-kit): copy domclick provider with protocol injection, strangler (#2133 domclick)
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m28s
Deploy Trade-In / build-backend (push) Successful in 1m24s
Deploy Trade-In / deploy (push) Has been cancelled

This commit is contained in:
lekss361 2026-07-02 16:08:09 +00:00
parent df5e9003df
commit b7abaeb569
4 changed files with 993 additions and 0 deletions

View file

@ -0,0 +1,97 @@
{
"result": {
"snippetsCount": 6,
"items": [
{
"id": 1001,
"path": "/card/sale__flat__1001",
"price": 8990000,
"squarePrice": 149833,
"offerRegionName": "Екатеринбург",
"location": {"lat": 56.8386, "lon": 60.6054},
"address": {"displayName": "Екатеринбург, ул. Малышева, 51"},
"objectInfo": {"area": 60.0, "floor": 7, "rooms": 2},
"house": {"floors": 16, "buildYear": 2015},
"description": "Отличная квартира с евроремонтом в центре.",
"seller": {"company": "АН Этажи", "agent": "Иванов И."},
"isRosreestrApproved": true,
"lastPriceHistoryState": "decreased",
"flatComplex": {"name": "ЖК Малышева", "id": 77, "slug": "malysheva"},
"updatedDate": "2026-06-30T10:00:00+05:00",
"isSberCollateral": true,
"hasDiscount": true,
"discountValue": 100000,
"duplicatesOfferCount": 3,
"publishedDate": "2026-06-28T09:15:00+05:00"
},
{
"id": 1002,
"path": "/card/sale__flat__1002",
"price": 4500000,
"squarePrice": null,
"offerRegionName": "Екатеринбург",
"location": {"lat": 56.79, "lon": 60.61},
"address": {"displayName": "Екатеринбург, ул. Луначарского, 240"},
"objectInfo": {"area": 28.5, "floor": 3, "rooms": null},
"house": {"floors": 25, "buildYear": 2020},
"description": "Уютная студия без отделки, свободна.",
"seller": {"company": {"name": "СберРиелт"}},
"publishedDate": "2026-06-27"
},
{
"id": 1003,
"path": "/card/sale__flat__1003",
"price": 6200000,
"squarePrice": 0,
"offerRegionName": "Екатеринбург",
"location": {"lat": 56.85, "lon": 60.55},
"address": {"displayName": "Екатеринбург, ул. Смазчиков, 3"},
"objectInfo": {"area": 0, "floor": 0, "rooms": 0},
"house": {"floors": 0, "buildYear": 0},
"description": null,
"seller": null,
"publishedDate": null
},
{
"id": 1004,
"path": "/card/sale__flat__1004",
"price": 12000000,
"squarePrice": 160000,
"offerRegionName": "Москва",
"location": {"lat": 55.75, "lon": 37.62},
"address": {"displayName": "Москва, ул. Тверская, 1"},
"objectInfo": {"area": 75.0, "floor": 5, "rooms": 3},
"house": {"floors": 20, "buildYear": 2018},
"description": "Квартира в Москве.",
"seller": {"agent": {"fullName": "Петров П."}},
"publishedDate": "2026-06-20T00:00:00+03:00"
},
{
"id": 1005,
"path": "/card/sale__flat__1005",
"price": 5000000,
"squarePrice": 100000,
"offerRegionName": "Екатеринбург",
"location": {"lat": 58.5, "lon": 60.6},
"address": {"displayName": "Севернее ЕКБ (вне bbox)"},
"objectInfo": {"area": 50.0, "floor": 2, "rooms": 1},
"house": {"floors": 9, "buildYear": 1990},
"description": " ",
"seller": {"company": " "},
"publishedDate": "not-a-date"
},
{
"id": 1006,
"path": "/card/sale__flat__1006",
"price": 0,
"squarePrice": 0,
"offerRegionName": "Екатеринбург",
"location": {"lat": 56.8, "lon": 60.6},
"address": {"displayName": "Битый оффер"},
"objectInfo": {"area": 40.0, "floor": 1, "rooms": 1},
"house": {"floors": 5, "buildYear": 1980},
"publishedDate": "2026-06-01"
}
]
}
}

View file

@ -0,0 +1,183 @@
"""Golden-parity: `scraper_kit.providers.domclick.serp` ≡ `app.services.scrapers.domclick`.
Strangler-инвариант (#2133): новая scraper_kit-копия domclick-провайдера должна
давать БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе.
Развязка (get_scraper_delaydelay_provider, base/exceptions/repair-normalizer
scraper_kit.*) НЕ меняет распарсенные данные.
Гоняем ОБА модуля на одной BFF-JSON-фикстуре и сравниваем:
- `_extract_json` (JSON из HTML-обёртки + QRATOR-block detection);
- `_map_item` (offer ScrapedLot): студия rooms=0, sentinel 0None, agency;
- `_is_geo_ok` (bbox + offerRegionName guard);
- url-билдеры и чистые хелперы (_build_offers_url/_build_count_url/
_parse_publish_date/_extract_agency_name).
Идентичность результата = kit-парсер верно скопирован (развязка не задела логику).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
# Старый app.services.scrapers.domclick тянет app.core.config.settings=Settings(),
# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
# НОВЫЙ (scraper_kit) — ruff сортирует scraper_kit (first-party) выше app-импортов.
from scraper_kit.providers.domclick.serp import DomClickScraper as NewDomClickScraper
from scraper_kit.providers.domclick.serp import _build_count_url as new_build_count_url
from scraper_kit.providers.domclick.serp import _build_offers_url as new_build_offers_url
from scraper_kit.providers.domclick.serp import _extract_agency_name as new_extract_agency
from scraper_kit.providers.domclick.serp import _extract_json as new_extract_json
from scraper_kit.providers.domclick.serp import _parse_publish_date as new_parse_publish_date
from app.services.scrapers.domclick import DomClickScraper as OldDomClickScraper
from app.services.scrapers.domclick import _build_count_url as old_build_count_url
from app.services.scrapers.domclick import _build_offers_url as old_build_offers_url
from app.services.scrapers.domclick import _extract_agency_name as old_extract_agency
from app.services.scrapers.domclick import _extract_json as old_extract_json
from app.services.scrapers.domclick import _parse_publish_date as old_parse_publish_date
_FIXTURES = Path(__file__).parent / "fixtures"
_BFF_JSON = (_FIXTURES / "domclick_bff_offers_sample.json").read_text(encoding="utf-8")
_BFF_ITEMS: list[dict[str, Any]] = json.loads(_BFF_JSON)["result"]["items"]
def _make_old() -> OldDomClickScraper:
"""Старый DomClickScraper без DB: patch get_scraper_delay."""
with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=8.0):
return OldDomClickScraper()
def _make_new() -> NewDomClickScraper:
"""Новый DomClickScraper с инжектируемым ScraperConfig + delay_provider.
Парсинг-тесты не дёргают fetch_city browser_http_endpoint не используется,
но конструктор требует config (duck-typed namespace достаточен).
"""
config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")
return NewDomClickScraper(config, delay_provider=lambda _name: 8.0)
# ── _extract_json parity ──────────────────────────────────────────────────────
def test_extract_json_parity_bare() -> None:
"""_extract_json на bare JSON-теле идентичен."""
assert old_extract_json(_BFF_JSON) == new_extract_json(_BFF_JSON)
def test_extract_json_parity_pre_wrapped() -> None:
"""_extract_json на <pre>-обёрнутом JSON идентичен (реальная форма ответа)."""
wrapped = f"<html><body><pre>{_BFF_JSON}</pre></body></html>"
assert old_extract_json(wrapped) == new_extract_json(wrapped)
def test_extract_json_parity_qrator_block() -> None:
"""QRATOR block-страница → DomClickBlockedError в обоих (идентичное поведение)."""
from scraper_kit.domclick_exceptions import DomClickBlockedError as NewBlocked
from app.services.scrapers.domclick_exceptions import DomClickBlockedError as OldBlocked
block_html = "<html><body>Система защиты QRATOR: captcha</body></html>"
old_raised = new_raised = False
try:
old_extract_json(block_html)
except OldBlocked:
old_raised = True
try:
new_extract_json(block_html)
except NewBlocked:
new_raised = True
assert old_raised and new_raised
# ── _map_item parity (offer → ScrapedLot) ─────────────────────────────────────
def test_map_item_parity_all_items() -> None:
"""Каждый offer-item → ScrapedLot идентичен (студия rooms=0, sentinel 0→None)."""
old = _make_old()
new = _make_new()
for item in _BFF_ITEMS:
# force_rooms=0 для студий (st-бакет) — проверяем обе ветки маппинга.
for force_rooms in (None, 0):
old_lot = old._map_item(item, force_rooms=force_rooms)
new_lot = new._map_item(item, force_rooms=force_rooms)
if old_lot is None or new_lot is None:
assert old_lot is None and new_lot is None
continue
assert old_lot.model_dump() == new_lot.model_dump()
# Счётчик parse-ошибок эволюционирует одинаково.
assert old.parse_failures == new.parse_failures
# ── _is_geo_ok parity (bbox + region guard) ───────────────────────────────────
def test_is_geo_ok_parity_all_items() -> None:
"""Гео-гард (offerRegionName + bbox) идентичен на всех items."""
old = _make_old()
new = _make_new()
for item in _BFF_ITEMS:
assert old._is_geo_ok(item) == new._is_geo_ok(item)
# ── url-билдеры + чистые хелперы ──────────────────────────────────────────────
def test_url_builders_parity() -> None:
"""_build_offers_url / _build_count_url идентичны (вкл. '5+''5%2B')."""
cases: list[tuple[str, int | None, int | None]] = [
("st", None, None),
("2", 3_000_000, 8_000_000),
("5+", 15_000_000, None),
("1", None, 5_000_000),
]
for rooms, gte, lte in cases:
assert old_build_count_url(rooms, gte, lte) == new_build_count_url(rooms, gte, lte)
for offset in (0, 20, 1980):
assert old_build_offers_url(rooms, gte, lte, offset) == new_build_offers_url(
rooms, gte, lte, offset
)
def test_parse_publish_date_parity() -> None:
"""_parse_publish_date идентичен на валидных/битых ISO-строках."""
for iso in [None, "", "2026-06-28T09:15:00+05:00", "2026-06-27", "not-a-date", "2026-13-40"]:
assert old_parse_publish_date(iso) == new_parse_publish_date(iso)
def test_extract_agency_name_parity() -> None:
"""_extract_agency_name идентичен на строковых/dict/пустых seller-блоках."""
sellers: list[dict[str, Any]] = [
{},
{"company": "АН Этажи", "agent": "Иванов"},
{"company": " ", "agent": {"name": "Резерв"}},
{"agent": {"fullName": "Петров П."}},
{"company": {"title": "СберРиелт"}},
{"agent": 12345},
]
for seller in sellers:
assert old_extract_agency(seller) == new_extract_agency(seller)
# ── strangler-guard: kit-провайдер не импортирует app.* ───────────────────────
def test_kit_domclick_has_no_app_imports() -> None:
"""Ни один модуль scraper_kit.providers.domclick.* не импортирует app.* (развязка)."""
import inspect
from scraper_kit.providers.domclick import serp
source = inspect.getsource(serp)
for line in source.splitlines():
stripped = line.strip()
assert not stripped.startswith("from app"), f"serp: {line!r}"
assert not stripped.startswith("import app"), f"serp: {line!r}"

View file

@ -0,0 +1,17 @@
"""DomClick-провайдер scraper_kit — strangler-копия `app.services.scrapers.domclick`.
Модули:
- serp BFF JSON API sweep (DomClickScraper, room×price bisection,
offer-item ScrapedLot маппинг)
Развязка от `app.*`:
- `app.core.config.settings.browser_http_endpoint` инжектируемый `ScraperConfig`
- `app.services.scraper_settings.get_scraper_delay` инжектируемый `delay_provider`
- `app.services.scrapers.base` `scraper_kit.base`
- `app.services.scrapers.domclick_exceptions` `scraper_kit.domclick_exceptions`
- `app.services.scrapers.repair_state_normalizer` `scraper_kit.repair_state_normalizer`
- `app.services.scrapers.browser_fetcher` `scraper_kit.browser_fetcher`
DomClick читает из `ScraperConfig` только `browser_http_endpoint` (для BrowserFetcher);
остальные поля настроек ему не нужны (прямой BFF JSON через shared mobile proxy).
"""

View file

@ -0,0 +1,696 @@
"""DomClick.ru scraper — вторичка через JSON BFF API (#1968).
Strangler-копия (#2133) боевого `app.services.scrapers.domclick`, развязанная от
`app.*`: продуктовые зависимости инжектируются снаружи (`delay_provider`), общие
хелперы берутся из `scraper_kit.*`. Логика парсинга идентична оригиналу
(golden-parity tests/test_scraper_kit_domclick_golden_parity.py).
Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
BrowserFetcher(source="domclick") (generic provider shared mobile proxy).
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.
Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
Стратегия нумерации комнат (ROOM_BUCKETS):
"st" студии (force rooms=0 в ScrapedLot)
"1""4" соответственно
"5+" 5 и более комнат
Пагинация: offset 0, 20, 40, , cap=2000. Если snippetsCount > 2000
для бакета, рекурсивно делим по цене (binary split) до тех пор пока
каждая ветка укладывается в cap.
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
через detail-эндпоинт после первоначального сбора.
"""
from __future__ import annotations
import hashlib
import json
import logging
from collections.abc import Callable
from datetime import date
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
from scraper_kit.base import BaseScraper, ScrapedLot
from scraper_kit.domclick_exceptions import DomClickBlockedError
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
if TYPE_CHECKING:
from scraper_kit.contracts import ScraperConfig
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+")
OFFSET_CAP: int = 2000 # max offset принятый BFF API
PAGE_SIZE: int = 20 # items per page (жёстко задан API)
# Потолок цены для binary split. 1 млрд ₽ — безопасно выше любой ЕКБ-квартиры;
# выбран так чтобы НЕ отбрасывать листинги дороже потолка при сплите unbounded-бакета
# (родительский count не ограничен сверху → clamped _lte должен покрывать весь хвост).
LTE_MAX: int = 1_000_000_000
MIN_PRICE_SPAN: int = 100_000 # ниже этого span прекращаем делить
# ── QRATOR block detection ────────────────────────────────────────────────────
_QRATOR_MARKERS: tuple[str, ...] = (
"qrator",
"bot_mitigation",
"система защиты",
"403 | домклик",
"captcha",
"access denied",
)
# ── 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 = "Екатеринбург"
# ── JSON extraction ───────────────────────────────────────────────────────────
def _extract_json(html: str) -> dict[str, Any]:
"""Извлекает JSON-объект из ответа BrowserFetcher.
BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
1. Bare body: ``{"result": ...}`` напрямую в <body>.
2. <pre>-wrapped: ``<html><body><pre>{"result": ...}</pre></body></html>``.
Стратегия: найти первый '{' и последний '}' работает для обоих вариантов.
Raises:
DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
ValueError: если JSON не найден или не является dict.
"""
# Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
# стоять за пределами head в крупных challenge-страницах.
html_lower = html.lower()
if any(m in html_lower for m in _QRATOR_MARKERS):
raise DomClickBlockedError(
f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})"
)
start = html.find("{")
end = html.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"No JSON object found in BFF response (len={len(html)})")
data = json.loads(html[start : end + 1])
if not isinstance(data, dict):
raise ValueError(f"BFF response JSON is not a dict: {type(data)}")
return data # type: ignore[return-value]
# ── URL builders ──────────────────────────────────────────────────────────────
def _build_offers_url(
rooms: str,
price_gte: int | None,
price_lte: int | None,
offset: int,
) -> str:
"""Строит URL для GET /api/offers/v1 с пагинацией.
urlencode кодирует "5+" "5%2B" (literal '+' в query string = space reject).
"""
params: list[tuple[str, str]] = [
("address", _EKB_ADDRESS_GUID),
("aids", _EKB_AREA_ID),
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
("rooms", rooms),
("sort", "qi"),
("sort_dir", "desc"),
("offset", str(offset)),
("limit", str(PAGE_SIZE)),
]
if price_gte is not None:
params.append(("sale_price__gte", str(price_gte)))
if price_lte is not None:
params.append(("sale_price__lte", str(price_lte)))
return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
def _build_count_url(
rooms: str,
price_gte: int | None,
price_lte: int | None,
) -> str:
"""Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
params: list[tuple[str, str]] = [
("address", _EKB_ADDRESS_GUID),
("aids", _EKB_AREA_ID),
("deal_type", "sale"),
("category", "living"),
("offer_type", "flat"),
("rooms", rooms),
("sort", "qi"),
("sort_dir", "desc"),
]
if price_gte is not None:
params.append(("sale_price__gte", str(price_gte)))
if price_lte is not None:
params.append(("sale_price__lte", str(price_lte)))
return f"{_BFF_BASE}/api/offers/count/v1?{urlencode(params)}"
def _parse_publish_date(iso: str | None) -> date | None:
"""Парсит ISO8601 дату публикации → date. None при ошибке."""
if not iso:
return None
try:
return date.fromisoformat(iso[:10])
except (ValueError, TypeError):
return None
def _extract_agency_name(seller: dict[str, Any]) -> str | None:
"""Извлечь читаемое имя агентства/агента из seller-блока BFF offer.
seller.company / seller.agent могут быть строкой ИЛИ dict (defensive
реальная форма поля варьируется). Приоритет: company agent. Для dict
пробуем типичные name-поля. Возвращает None если ничего читаемого нет.
"""
for key in ("company", "agent"):
val = seller.get(key)
if isinstance(val, str):
name = val.strip()
if name:
return name
elif isinstance(val, dict):
for name_key in ("name", "title", "fullName"):
nm = val.get(name_key)
if isinstance(nm, str) and nm.strip():
return nm.strip()
return None
# ── DomClickScraper ──────────────────────────────────────────────────────────
class DomClickScraper(BaseScraper):
"""DomClick вторичка через JSON BFF API. Источник = 'domklik'.
Использует BrowserFetcher(source="domclick") (generic provider shared mobile
proxy), который обходит QRATOR. Прямые curl/httpx-запросы с datacenter-IP
блокируются QRATOR.
Основной метод: fetch_city(city_id, rooms, pages).
fetch_around() не реализован: DomClick не поддерживает geo-radius в URL.
Strangler-инжекция (#2133): продуктовые зависимости приходят снаружи вместо
прямого импорта `app.*`:
- `config: ScraperConfig` источник `browser_http_endpoint` для
BrowserFetcher (kit не читает `app.core.config.settings`);
- `delay_provider` замена прямого импорта get_scraper_delay.
Counters (публичные после fetch_city):
parse_failures офферы с ошибкой маппинга
geo_filtered офферы вне ЕКБ bbox или с неверным offerRegionName
blocked True если sweep был прерван QRATOR-блоком
fetch_errors не-block ошибки извлечения JSON (truncated/garbled/bad shape)
"""
name = "domklik"
source = "domklik"
base_url = "https://domclick.ru"
# Консервативная задержка между страницами (fallback до init)
request_delay_sec = 8.0
def __init__(
self,
config: ScraperConfig,
*,
delay_provider: Callable[[str], float] | None = None,
) -> None:
super().__init__()
# Strangler-инжекция (#2133): config даёт browser_http_endpoint для
# BrowserFetcher, delay_provider заменяет прямой импорт
# app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
self._config = config
if delay_provider is not None:
self.request_delay_sec = delay_provider(self.name)
self.parse_failures: int = 0
self.geo_filtered: int = 0
self.blocked: bool = False
# Не-block ошибки извлечения JSON (truncated/garbled response, неверная
# структура). В отличие от parse_failures (per-item), это per-fetch ошибки,
# которые ограничивают сбор бакета. Учитываются в honest-status pipeline.
self.fetch_errors: int = 0
async def __aenter__(self) -> DomClickScraper:
await super().__aenter__()
return self
# ── fetch_around — stub ───────────────────────────────────────────────────
async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list[ScrapedLot]:
"""DomClick не поддерживает geo-radius. Используй fetch_city()."""
raise NotImplementedError(
"DomClick не поддерживает geo-radius; используй fetch_city(city_id=...)"
)
# ── Основной метод ────────────────────────────────────────────────────────
async def fetch_city(
self,
city_id: int,
rooms: list[int] | None = None,
pages: int = 100,
) -> list[ScrapedLot]:
"""Citywide sweep через BFF JSON API.
Аргументы city_id и rooms принимаются для совместимости сигнатуры с
вызывающим кодом (run_domclick_city_sweep), но:
- city_id vestigial (EКБ захардкожен через GUID).
- rooms игнорируется; всегда обходятся все ROOM_BUCKETS внутри.
Args:
city_id: игнорируется (EKB захардкожен).
rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
pages: максимальное число страниц на бакет (safety cap).
Returns:
Дедуплицированный по source_id список ScrapedLot.
"""
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
from scraper_kit.browser_fetcher import BrowserFetcher
out_lots: list[ScrapedLot] = []
seen_ids: set[str] = set()
async with BrowserFetcher(
source="domclick", endpoint=self._config.browser_http_endpoint
) as fetcher:
for bucket in ROOM_BUCKETS:
logger.info(
"domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
bucket,
city_id,
pages,
)
try:
await self._sweep_bucket(
fetcher=fetcher,
rooms=bucket,
price_gte=None,
price_lte=None,
seen_ids=seen_ids,
out_lots=out_lots,
pages=pages,
)
except DomClickBlockedError:
self.blocked = True
logger.error(
"domklik: QRATOR block during rooms=%r — aborting all buckets",
bucket,
)
break
except (ValueError, TypeError) as exc:
# Defensive: bucket-level ошибка не должна убивать весь sweep.
# _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
# но если что-то всё же всплыло — переходим к следующему бакету.
self.fetch_errors += 1
logger.warning(
"domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
bucket,
exc,
exc_info=True,
)
continue
logger.info(
"domklik: fetch_city done city_id=%d total=%d "
"parse_failures=%d geo_filtered=%d fetch_errors=%d blocked=%s",
city_id,
len(out_lots),
self.parse_failures,
self.geo_filtered,
self.fetch_errors,
self.blocked,
)
return out_lots
# ── Internal sweep helpers ────────────────────────────────────────────────
async def _count(
self,
fetcher: Any,
rooms: str,
price_gte: int | None,
price_lte: int | None,
) -> int:
"""Запрашивает snippetsCount для данного бакета (rooms + price range).
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и return 0
(бакет пропускается, sweep продолжается).
"""
url = _build_count_url(rooms, price_gte, price_lte)
logger.debug("domklik: count url=%s", url)
html = await fetcher.fetch(url)
try:
data = _extract_json(html)
except DomClickBlockedError:
raise
except (ValueError, TypeError, AttributeError):
self.fetch_errors += 1
logger.warning(
"domklik: _count JSON extract failed rooms=%r price=[%s,%s] — skip bucket",
rooms,
price_gte,
price_lte,
exc_info=True,
)
return 0
# {"result": null} → None, {"snippetsCount": null} → None: harden обе.
res = data.get("result") or {}
raw = res.get("snippetsCount")
return int(raw) if raw else 0
async def _sweep_bucket(
self,
fetcher: Any,
rooms: str,
price_gte: int | None,
price_lte: int | None,
seen_ids: set[str],
out_lots: list[ScrapedLot],
pages: int,
) -> None:
"""Рекурсивный sweep бакета (rooms, price_gte, price_lte).
Если snippetsCount > OFFSET_CAP делим диапазон цен пополам и
рекурсируем в каждую половину. Остановка рекурсии:
- span <= MIN_PRICE_SPAN пагинируем как есть (с предупреждением о truncation)
- snippetsCount == 0 пропускаем
- snippetsCount <= OFFSET_CAP пагинируем
Raises:
DomClickBlockedError: если QRATOR-блок propagate наверх.
"""
count = await self._count(fetcher, rooms, price_gte, price_lte)
if count == 0:
return
if count > OFFSET_CAP:
_gte = price_gte if price_gte is not None else 0
_lte = price_lte if price_lte is not None else LTE_MAX
span = _lte - _gte
if span <= MIN_PRICE_SPAN:
logger.warning(
"domklik: rooms=%r price=[%s,%s] count=%d > cap=%d "
"but span=%d <= min=%d — paginating as-is (bucket truncated at %d)",
rooms,
price_gte,
price_lte,
count,
OFFSET_CAP,
span,
MIN_PRICE_SPAN,
OFFSET_CAP,
)
await self._paginate(
fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages
)
return
mid = (_gte + _lte) // 2
logger.debug(
"domklik: rooms=%r count=%d > cap=%d — price-split [%d,%d] → [%d,%d]+[%d,%d]",
rooms,
count,
OFFSET_CAP,
_gte,
_lte,
_gte,
mid,
mid + 1,
_lte,
)
await self._sweep_bucket(fetcher, rooms, _gte, mid, seen_ids, out_lots, pages)
await self._sweep_bucket(fetcher, rooms, mid + 1, _lte, seen_ids, out_lots, pages)
else:
await self._paginate(fetcher, rooms, price_gte, price_lte, seen_ids, out_lots, pages)
async def _paginate(
self,
fetcher: Any,
rooms: str,
price_gte: int | None,
price_lte: int | None,
seen_ids: set[str],
out_lots: list[ScrapedLot],
pages: int,
) -> None:
"""Пагинирует один бакет (rooms, price range) до исчерпания или cap.
DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и break
(трактуем как конец бакета уже собранные lots сохраняются).
Raises:
DomClickBlockedError: propagate из _extract_json при QRATOR-блоке.
"""
force_rooms = 0 if rooms == "st" else None
page_idx = 0
while page_idx < pages:
offset = page_idx * PAGE_SIZE
if offset >= OFFSET_CAP:
break
url = _build_offers_url(rooms, price_gte, price_lte, offset)
logger.debug("domklik: offers url=%s", url)
html = await fetcher.fetch(url)
try:
data = _extract_json(html)
except DomClickBlockedError:
raise
except (ValueError, TypeError, AttributeError):
self.fetch_errors += 1
logger.warning(
"domklik: _paginate JSON extract failed rooms=%r offset=%d "
"— ending bucket (lots so far preserved)",
rooms,
offset,
exc_info=True,
)
break
# {"result": null} → None; harden до .get("items").
items: list[dict[str, Any]] = (data.get("result") or {}).get("items") or []
if not items:
break
for item in items:
lot = self._map_item(item, force_rooms=force_rooms)
if lot is None:
continue
if not self._is_geo_ok(item):
self.geo_filtered += 1
continue
key = lot.source_id or lot.source_url
if key in seen_ids:
continue
seen_ids.add(key)
out_lots.append(lot)
page_idx += 1
if page_idx < pages and len(items) == PAGE_SIZE:
await self.sleep_between_requests()
elif not items or len(items) < PAGE_SIZE:
break
def _is_geo_ok(self, item: dict[str, Any]) -> bool:
"""Гео-гард: пропускает только листинги ЕКБ в bbox.
aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
"""
region = item.get("offerRegionName", "")
if region != _EKB_REGION_NAME:
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
)
def _map_item(
self, item: dict[str, Any], *, force_rooms: int | None = None
) -> ScrapedLot | None:
"""Маппинг offer-item из BFF API → ScrapedLot.
Возвращает None если price <= 0 или при ошибке парсинга.
"""
try:
price = item.get("price", 0) or 0
if price <= 0:
return None
item_id = item.get("id")
source_id = str(item_id) if item_id is not None else None
source_url: str = item.get("path", "") or ""
loc = item.get("location") or {}
lat: float | None = loc.get("lat")
lon: float | None = loc.get("lon")
address_obj = item.get("address") or {}
address: str | None = address_obj.get("displayName")
# 0 — sentinel «неизвестно» для area/floor/total_floors/buildYear
# (DomClick эмитит 0 для under-construction / unknown) → None.
obj_info = item.get("objectInfo") or {}
area_raw = obj_info.get("area")
area_m2: float | None = float(area_raw) if area_raw else None
floor_raw = obj_info.get("floor")
floor: int | None = int(floor_raw) if floor_raw else None
house = item.get("house") or {}
floors_raw = house.get("floors")
total_floors: int | None = int(floors_raw) if floors_raw else None
year_raw = house.get("buildYear")
year_built: int | None = int(year_raw) if year_raw else None
# rooms: 0 — ВАЛИДНОЕ значение (студия), поэтому проверка is not None,
# а не truthy. force_rooms=0 (st-бакет) перебивает objectInfo.
if force_rooms is not None:
rooms: int | None = force_rooms
else:
rooms_raw = obj_info.get("rooms")
rooms = int(rooms_raw) if rooms_raw is not None else None
square_price_raw = item.get("squarePrice")
price_per_m2: int | None = int(square_price_raw) if square_price_raw else None
if price_per_m2 is None and area_m2 and area_m2 > 0:
price_per_m2 = int(price / area_m2)
# ── Описание + repair-инференс + minhash (Layer A enrichment) ─────
# DomClick search не отдаёт renovation-enum: repair_state выводим из
# текста описания (как cian #622 fallback — «евроремонт»/«без отделки»).
# description_minhash — sha1 от описания (mirror cian) для cross-source
# дедупа.
description_raw = item.get("description")
description: str | None = None
if isinstance(description_raw, str):
description = description_raw.strip() or None
repair_state: str | None = None
description_minhash: str | None = None
if description:
repair_state = infer_repair_state_from_text(description)
description_minhash = hashlib.sha1(
description.lower().encode("utf-8", errors="replace")
).hexdigest()[:32]
# ── Продавец / агентство ──────────────────────────────────────────
seller = item.get("seller")
if not isinstance(seller, dict):
seller = {}
agency_name = _extract_agency_name(seller)
flat_complex = item.get("flatComplex") or {}
raw_payload: dict[str, Any] = {
"isRosreestrApproved": item.get("isRosreestrApproved"),
"squarePrice": square_price_raw,
"lastPriceHistoryState": item.get("lastPriceHistoryState"),
"flatComplex": {
"name": flat_complex.get("name"),
"id": flat_complex.get("id"),
"slug": flat_complex.get("slug"),
}
if flat_complex
else None,
"updatedDate": item.get("updatedDate"),
}
# ── Доп. SERP-поля (Layer A enrichment) — мержим в raw_payload, ────
# сбрасывая None-значения чтобы payload оставался компактным; ничего
# из существующих ключей не теряем.
seller_compact = {
k: seller.get(k) for k in ("agent", "company") if seller.get(k) is not None
}
_enrich: dict[str, Any] = {
"is_sber_collateral": item.get("isSberCollateral"),
"has_discount": item.get("hasDiscount"),
"discount_value": item.get("discountValue"),
"duplicates_offer_count": item.get("duplicatesOfferCount"),
"seller": seller_compact or None,
}
raw_payload.update({k: v for k, v in _enrich.items() if v is not None})
publish_date = _parse_publish_date(item.get("publishedDate"))
return ScrapedLot(
source="domklik",
source_url=source_url,
source_id=source_id,
address=address,
lat=lat,
lon=lon,
rooms=rooms,
area_m2=area_m2,
floor=floor,
total_floors=total_floors,
year_built=year_built,
price_rub=int(price),
price_per_m2=price_per_m2,
listing_segment="vtorichka",
publish_date=publish_date,
description=description,
repair_state=repair_state,
description_minhash=description_minhash,
agency_name=agency_name,
raw_payload=raw_payload,
)
except Exception:
self.parse_failures += 1
logger.warning(
"domklik: _map_item failed for item id=%r (parse_failures=%d)",
item.get("id"),
self.parse_failures,
exc_info=True,
)
return None
# ── Convenience runner (для Celery tasks) ────────────────────────────────────
async def scrape_domclick_city(
config: ScraperConfig,
city_id: int,
rooms: list[int] | None = None,
pages: int = 100,
*,
delay_provider: Callable[[str], float] | None = None,
) -> list[ScrapedLot]:
"""Удобная точка входа для вызова из Celery tasks.
Пример::
import asyncio
lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None))
"""
async with DomClickScraper(config, delay_provider=delay_provider) as scraper:
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)