diff --git a/tradein-mvp/backend/tests/fixtures/domclick_bff_offers_sample.json b/tradein-mvp/backend/tests/fixtures/domclick_bff_offers_sample.json new file mode 100644 index 00000000..99bed4aa --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/domclick_bff_offers_sample.json @@ -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" + } + ] + } +} diff --git a/tradein-mvp/backend/tests/test_scraper_kit_domclick_golden_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_domclick_golden_parity.py new file mode 100644 index 00000000..312fc866 --- /dev/null +++ b/tradein-mvp/backend/tests/test_scraper_kit_domclick_golden_parity.py @@ -0,0 +1,183 @@ +"""Golden-parity: `scraper_kit.providers.domclick.serp` ≡ `app.services.scrapers.domclick`. + +Strangler-инвариант (#2133): новая scraper_kit-копия domclick-провайдера должна +давать БАЙТ-ИДЕНТИЧНЫЙ результат парсинга старому боевому коду на одинаковом входе. +Развязка (get_scraper_delay→delay_provider, base/exceptions/repair-normalizer→ +scraper_kit.*) НЕ меняет распарсенные данные. + +Гоняем ОБА модуля на одной BFF-JSON-фикстуре и сравниваем: + - `_extract_json` (JSON из HTML-обёртки + QRATOR-block detection); + - `_map_item` (offer → ScrapedLot): студия rooms=0, sentinel 0→None, 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 на
-обёрнутом JSON идентичен (реальная форма ответа)."""
+    wrapped = f"
{_BFF_JSON}
" + 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 = "Система защиты QRATOR: captcha" + 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}" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/__init__.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/__init__.py new file mode 100644 index 00000000..a95ad960 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/__init__.py @@ -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). +""" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py new file mode 100644 index 00000000..9525d3a1 --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py @@ -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 (
 или 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": ...}`` напрямую в .
+    2. 
-wrapped: ``
{"result": ...}
``. + + Стратегия: найти первый '{' и последний '}' — работает для обоих вариантов. + + 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)