From 52fc920507f8645f8c551c65833081d1ff47f50a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 27 Jun 2026 13:57:07 +0300 Subject: [PATCH] fix(tradein/domclick): rewrite dead scraper to BFF JSON API + honest status (#1846, #1968) Old HTML scraper dead: /search?city_id retired in DomClick redesign + VPS IP QRATOR-banned (prod domclick_city_sweep reported done/0 every run). New path: - GET bff-search-web.domclick.ru/api/offers/v1 + count/v1 via BrowserFetcher(source="domclick") -> generic provider -> shared mobile proxy -> bypasses QRATOR (prod-verified live: 20 items returned, no block). - Sweeps 6 room buckets (st/1/2/3/4/5+); recursive binary price-split for oversized buckets (rooms=2 ~2215 > offset cap 2000); count/v1 sizes buckets. - Maps SERP JSON -> ScrapedLot with lat/lon at ~100% (closes avito 54% NULL-lat gap); per-card EKB geo-guard. - Honest status (#1968): QRATOR block OR fetch errors with 0 lots -> mark_failed, not mark_done. Parse failures are per-page resilient (break bucket, keep partial lots) so a transient bad page never aborts the whole sweep. - Schedule shipped DORMANT (enabled=false, pages=100) via 138_domclick_bff_rewrite_schedule.sql; operator enables after prod smoke. 42 scraper tests (price-split boundaries, parse-error resilience, honest status); full backend suite green; ruff clean. Layer B (detail renovation/wallType/ priceHistory) deferred to #1846 follow-up. --- .../backend/app/services/scrape_pipeline.py | 114 ++- .../backend/app/services/scrapers/domclick.py | 715 +++++++++++------- .../services/scrapers/domclick_exceptions.py | 10 +- .../sql/138_domclick_bff_rewrite_schedule.sql | 33 + .../backend/tests/scrapers/test_domclick.py | 664 +++++++++++----- .../backend/tests/test_domclick_sweep.py | 133 +++- 6 files changed, 1153 insertions(+), 516 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/138_domclick_bff_rewrite_schedule.sql diff --git a/tradein-mvp/backend/app/services/scrape_pipeline.py b/tradein-mvp/backend/app/services/scrape_pipeline.py index aa38b5ff..90aa11d7 100644 --- a/tradein-mvp/backend/app/services/scrape_pipeline.py +++ b/tradein-mvp/backend/app/services/scrape_pipeline.py @@ -3087,10 +3087,10 @@ async def run_avito_full_load( class DomClickCitySweepCounters: """Aggregate counters для DomClick citywide sweep run. - DomClick SERP — citywide (city_id), не geo-bbox: anchor-loop отсутствует. - Один проход fetch_city перебирает rooms × pages внутри scraper'а. - Поля симметричны sibling-counters, но без detail_*/houses_* (DomClick SERP-only) - и без anchors_* (нет anchor-фазы). pages_fetched = rooms × pages worst-case. + DomClick BFF — citywide (city_id), не geo-bbox: anchor-loop отсутствует. + Один проход fetch_city перебирает ROOM_BUCKETS × pages внутри scraper'а. + Поля симметричны sibling-counters, но без detail_*/houses_* (SERP-only) + и без anchors_* (нет anchor-фазы). pages_fetched = 6 buckets × pages worst-case. """ lots_fetched: int = 0 @@ -3098,17 +3098,21 @@ class DomClickCitySweepCounters: lots_updated: int = 0 pages_fetched: int = 0 errors_count: int = 0 + blocked: int = 0 # 1 если QRATOR-блок был во время sweep + geo_filtered: int = 0 # число офферов отфильтрованных geo-guard def to_dict(self) -> dict[str, int]: return {f.name: getattr(self, f.name) for f in fields(self)} -# Дефолтные параметры sweep'а (EKB city_id=4, все комнатности). +# Дефолтные параметры sweep'а (EKB city_id=4). DOMCLICK_DEFAULT_CITY_ID: int = 4 -DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] -# Оценка времени одного fetch'а (network + camoufox render + parse) для watchdog. +DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] # vestigial; scraper sweeps all buckets +# Число BFF-бакетов (st/1/2/3/4/5+) — фиксировано; используется для watchdog. +_DOMCLICK_NUM_BUCKETS: int = 6 +# Оценка времени одного fetch'а (network + parse) для watchdog. _DOMCLICK_PER_FETCH_S: float = 12.0 -# Буфер сверху расчётного бюджета (cold browser start, save-фаза). +# Буфер сверху расчётного бюджета (cold browser start, save-фаза, price-splits). _DOMCLICK_SWEEP_BUDGET_S: float = 300.0 @@ -3118,46 +3122,51 @@ async def run_domclick_city_sweep( run_id: int, city_id: int = DOMCLICK_DEFAULT_CITY_ID, rooms: list[int] | None = None, - pages: int = 5, + pages: int = 100, request_delay_sec: float | None = None, ) -> DomClickCitySweepCounters: - """DomClick citywide sweep: SERP по city_id × rooms × pages → save → house-match. + """DomClick citywide sweep через BFF JSON API. Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE: - DomClick SERP не поддерживает geo-radius (fetch_around → NotImplementedError), - поэтому anchor-loop отсутствует. DomClickScraper.fetch_city уже перебирает - rooms × pages через внутренний BrowserFetcher (camoufox headless) и - дедуплицирует по source_id. + DomClick не поддерживает geo-radius (fetch_around → NotImplementedError), + поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все + ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher → shared mobile proxy → + обходит QRATOR. Фазы (единственный citywide проход): 1. SERP: DomClickScraper().fetch_city(city_id, rooms, pages). - 2. SAVE: save_listings(db, lots, run_id=run_id) — BaseScraper save path, - триггерит существующий house-match hook (адрес-fingerprint tier, т.к. - DomClick SERP не отдаёт lat/lon). + 2. SAVE: save_listings(db, lots, run_id=run_id) — house-match по lat/lon и + address-fingerprint (BFF отдаёт координаты). Watchdog: весь fetch_city оборачивается в asyncio.wait_for. Таймаут считается - из rooms × pages × per-fetch + buffer (минимум ANCHOR_TIMEOUT_SEC). + из 6 buckets × pages × per-fetch + buffer (минимум ANCHOR_TIMEOUT_SEC). + Реальный sweep ≈ 330 страниц (rooms=2 требует price-split). Cooperative cancel: is_cancelled(db, run_id) проверяется перед SERP-фазой. - mark_done вызывается ВСЕГДА (кроме cancel / fatal). lat=lon=None у всех lots → - house-match использует только address_fingerprint tier. + + ЧЕСТНЫЙ СТАТУС (#1968): если scraper сообщил QRATOR-блок И lots == 0 → + mark_failed с объяснением. Иначе mark_done (в т.ч. при lots==0 без блока: + genuinely empty run). Возвращает DomClickCitySweepCounters. """ - from app.services.scrapers.domclick import DomClickScraper + from app.services.scrapers.domclick import ROOM_BUCKETS, DomClickScraper - _rooms = rooms if rooms is not None else list(DOMCLICK_DEFAULT_ROOMS) - _resolved_delay = request_delay_sec if request_delay_sec is not None else 8.0 + _resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0 counters = DomClickCitySweepCounters() - # Watchdog-таймаут: число fetch'ей = len(rooms) × pages (worst-case, без - # early-break на пустой странице). Каждый fetch ≈ delay + render/parse overhead. - _num_fetches = max(1, len(_rooms)) * max(1, pages) + # Watchdog: 6 buckets × pages × per_fetch + budget. + # Реальный worst-case (rooms=2 price-split) ≈ 330 страниц × (delay+12s). + # pages=100 → 600 fetch'ей × ~18s + 300 ≈ 11100s (~3ч) — намеренно generous. + _num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages) _sweep_timeout = max( ANCHOR_TIMEOUT_SEC, int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S), ) + # Мутируемый контейнер для захвата scraper-ссылки из замыкания. + _scraper_ref: list[DomClickScraper] = [] + try: # ── Cooperative cancel перед SERP-фазой ────────────────────────────── if scrape_runs.is_cancelled(db, run_id): @@ -3166,10 +3175,11 @@ async def run_domclick_city_sweep( return counters logger.info( - "domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d (watchdog %ds)", + "domclick-sweep run_id=%d: BFF citywide sweep city_id=%d " + "buckets=%d pages_cap=%d (watchdog %ds)", run_id, city_id, - _rooms, + len(ROOM_BUCKETS), pages, _sweep_timeout, ) @@ -3179,10 +3189,11 @@ async def run_domclick_city_sweep( async def _domclick_phase() -> None: """Единственная citywide-фаза: fetch_city + save.""" nonlocal lots - async with DomClickScraper() as scraper: + async with DomClickScraper() as _scraper: + _scraper_ref.append(_scraper) if request_delay_sec is not None: - scraper.request_delay_sec = _resolved_delay - lots = await scraper.fetch_city(city_id=city_id, rooms=_rooms, pages=pages) + _scraper.request_delay_sec = _resolved_delay + lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages) counters.lots_fetched += len(lots) if lots: inserted, updated = save_listings(db, lots, run_id=run_id) @@ -3202,18 +3213,53 @@ async def run_domclick_city_sweep( logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id) counters.errors_count += 1 - # pages_fetched: worst-case число запрошенных страниц (rooms × pages). + # Перенести счётчики scraper'а в counters (если scraper был создан). + if _scraper_ref: + _s = _scraper_ref[0] + counters.blocked = 1 if _s.blocked else 0 + counters.geo_filtered = _s.geo_filtered + # Не-block fetch-ошибки скрейпера учитываем в errors_count: они должны + # влиять на honest-status (0 lots + fetch_errors → НЕ ложный mark_done). + counters.errors_count += _s.fetch_errors + + # pages_fetched: worst-case число страниц (buckets × pages cap). counters.pages_fetched = _num_fetches scrape_runs.update_heartbeat(db, run_id, counters.to_dict()) - scrape_runs.mark_done(db, run_id, counters.to_dict()) + + # ── ЧЕСТНЫЙ СТАТУС (#1968) ──────────────────────────────────────────── + # 0 lots + (block ИЛИ любая ошибка) → failed: run без результата по + # внешней причине не должен маскироваться как 'done'. Нераспознанный + # block-вариант (нет литерал-маркера) приходит как fetch_error, не blocked — + # поэтому errors_count тоже триггерит failed. Genuinely empty 0-error run + # → done. Partial run с lots>0 остаётся done даже если часть бакетов упала. + if counters.lots_fetched == 0 and (counters.blocked or counters.errors_count > 0): + logger.error( + "domclick-sweep run_id=%d: 0 listings with blocked=%d errors=%d " + "— marking failed", + run_id, + counters.blocked, + counters.errors_count, + ) + scrape_runs.mark_failed( + db, + run_id, + "QRATOR block or fetch errors — 0 listings", + counters.to_dict(), + ) + else: + scrape_runs.mark_done(db, run_id, counters.to_dict()) + logger.info( - "domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) pages=%d errors=%d", + "domclick-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) " + "pages=%d errors=%d blocked=%d geo_filtered=%d", run_id, counters.lots_fetched, counters.lots_inserted, counters.lots_updated, counters.pages_fetched, counters.errors_count, + counters.blocked, + counters.geo_filtered, ) return counters diff --git a/tradein-mvp/backend/app/services/scrapers/domclick.py b/tradein-mvp/backend/app/services/scrapers/domclick.py index ccb71cdb..b6f3087b 100644 --- a/tradein-mvp/backend/app/services/scrapers/domclick.py +++ b/tradein-mvp/backend/app/services/scrapers/domclick.py @@ -1,28 +1,32 @@ -"""DomClick.ru scraper — вторичка через headless BrowserFetcher (#796). +"""DomClick.ru scraper — вторичка через JSON BFF API (#1968). -Стратегия: HTML scrape через camoufox (AsyncCamoufox) — единственный -рабочий путь: DataDome пропускает headless Playwright, curl_cffi -> 401. +Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через +BrowserFetcher(source="domclick") (generic provider → shared mobile proxy). +QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy. -URL шаблон: - https://domclick.ru/search?deal_type=sale&category=living&offer_type=flat - &city_id={city_id}&rooms={r}&p={page} +Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (
 или bare body).
+Парсинг: _extract_json() вытаскивает первый {...} из ответа.
 
-Оффер-карточки: `a[href*="/card/"]` с href-паттерном `/card/sale__flat__`.
-Координаты SERP не отдаёт (lat = lon = None).
+Стратегия нумерации комнат (ROOM_BUCKETS):
+  "st"  — студии (force rooms=0 в ScrapedLot)
+  "1"–"4" — соответственно
+  "5+"  — 5 и более комнат
 
-Важно: selectolax `.text()` объединяет все дочерние текстовые узлы без
-разделителей. Числа из адреса (номер дома) могут слипнуться с ценой.
-Поэтому цену извлекаем из каждого дочернего элемента отдельно (а не из
-суммарного card_text), где элемент содержит символ рубля.
+Пагинация: 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 json
 import logging
-import re
-from urllib.parse import urljoin
-
-from selectolax.parser import HTMLParser, Node
+from datetime import date
+from typing import Any
+from urllib.parse import urlencode
 
 from app.services.scraper_settings import get_scraper_delay
 from app.services.scrapers.base import BaseScraper, ScrapedLot
@@ -30,112 +34,179 @@ from app.services.scrapers.domclick_exceptions import DomClickBlockedError
 
 logger = logging.getLogger(__name__)
 
-# ── DataDome block detection ─────────────────────────────────────────────────
-_DATADOME_MARKERS = ("datadome", "blocked", "access denied", "bot detected")
+# ── API constants ─────────────────────────────────────────────────────────────
 
+_BFF_BASE = "https://bff-search-web.domclick.ru"
+_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
+_EKB_AREA_ID = "20561"
 
-def _is_blocked_page(html: str) -> bool:
-    head = html[:2048].lower()
-    return any(m in head for m in _DATADOME_MARKERS)
+# 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 прекращаем делить
 
-# ── Regex helpers ────────────────────────────────────────────────────────────
-# _RE_ROOMS расширен относительно avito.py: DomClick пишет «2-комн. квартира»
-# (с «комн.») — avito.py-вариант ловит только «N-к. кв.» / «N-к квартира».
-_RE_ROOMS = re.compile(
-    r"(\d)-(?:комн\.?\s*(?:квартира|кв\.?)?|к\.?\s*(?:квартира|кв\.?))",
-    re.IGNORECASE,
-)
-_RE_STUDIO = re.compile(r"\bстуди[яиюей]\b", re.IGNORECASE)
-_RE_AREA = re.compile(r"(\d+[.,]?\d*)\s*м[2²]", re.IGNORECASE)
-_RE_FLOOR = re.compile(r"(\d+)\s*/\s*(\d+)\s*эт\.?", re.IGNORECASE)
+# ── QRATOR block detection ────────────────────────────────────────────────────
 
-# Цена: парсим из одного элемента DOM (не из суммарного card_text) —
-# иначе число дома может слипнуться с ценой. Цена всегда в элементе с «₽».
-_RE_PRICE_EL = re.compile(r"([\d\s  ]+)\s*[₽р](?:уб\.?)?", re.IGNORECASE)
-
-# source_id из href вида /card/sale__flat__2075671636
-_RE_SOURCE_ID = re.compile(r"sale__flat__(\d+)")
-
-# Адресные ключевые слова (эвристика)
-_RE_ADDR_KW = re.compile(
-    r"ул\.|улица|пер\.|переулок|пр-т|проспект"
-    r"|бул\.|бульвар|шоссе|наб\.|набережная"
-    r"|пл\.|площадь|тракт|д\.\s*\d",
-    re.IGNORECASE,
+_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 = "Екатеринбург"
 
 
-def _extract_rooms(text: str) -> int | None:
-    if _RE_STUDIO.search(text):
-        return 0
-    m = _RE_ROOMS.search(text)
-    return int(m.group(1)) if m else None
+# ── JSON extraction ───────────────────────────────────────────────────────────
 
 
-def _extract_area(text: str) -> float | None:
-    m = _RE_AREA.search(text)
-    return float(m.group(1).replace(",", ".")) if m else None
+def _extract_json(html: str) -> dict[str, Any]:
+    """Извлекает JSON-объект из ответа BrowserFetcher.
 
+    BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
+    1. Bare body: ``{"result": ...}`` напрямую в .
+    2. 
-wrapped: ``
{"result": ...}
``. -def _extract_floor(text: str) -> tuple[int | None, int | None]: - m = _RE_FLOOR.search(text) - if m: - return int(m.group(1)), int(m.group(2)) - return None, None + Стратегия: найти первый '{' и последний '}' — работает для обоих вариантов. - -def _extract_price_from_element(text: str) -> int | None: - """Извлекаем цену из текста ОДНОГО элемента DOM. - - Парсим из одного span/div — не из суммарного card_text. Это исключает - случай, когда адресный номер дома стоит вплотную перед ценой в - объединённом тексте карточки («Ленина, 503 100 000 руб.»). + Raises: + DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры. + ValueError: если JSON не найден или не является dict. """ - m = _RE_PRICE_EL.search(text) - if m: - # Нормализуем пробелы любого вида (обычный, неразрывный, узкий) - raw = re.sub(r"[\s  ]", "", m.group(1)) - if raw.isdigit(): - val = int(raw) - if val > 0: - return val - return None + # Сканируем ВЕСЬ ответ (а не только первые 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] -def _extract_source_id(href: str) -> str | None: - """Числовой ID из href типа /card/sale__flat__2075671636.""" - m = _RE_SOURCE_ID.search(href) - return m.group(1) if m else None +# ── 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 # ── DomClickScraper ────────────────────────────────────────────────────────── class DomClickScraper(BaseScraper): - """DomClick вторичка parser. Источник = 'domklik'. + """DomClick вторичка через JSON BFF API. Источник = 'domklik'. - Использует BrowserFetcher (camoufox headless Firefox) — DataDome пропускает - его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок). + Использует 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. + + 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" - # DataDome — консервативная задержка между страницами (fallback до init) + # Консервативная задержка между страницами (fallback до init) request_delay_sec = 8.0 def __init__(self) -> None: super().__init__() self.request_delay_sec = get_scraper_delay(self.name) - # Счётчик карточек с неожиданной DOM-структурой (для observability) 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__() @@ -155,237 +226,361 @@ class DomClickScraper(BaseScraper): self, city_id: int, rooms: list[int] | None = None, - pages: int = 20, + pages: int = 100, ) -> list[ScrapedLot]: - """Citywide sweep: все страницы SERP для city_id. + """Citywide sweep через BFF JSON API. + + Аргументы city_id и rooms принимаются для совместимости сигнатуры с + вызывающим кодом (run_domclick_city_sweep), но: + - city_id — vestigial (EКБ захардкожен через GUID). + - rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри. Args: - city_id: числовой ID города в DomClick (например, 4 = Екатеринбург). - rooms: список значений комнатности (0=студия, 1, 2, 3, 4, ...). - None -> без фильтра комнатности (все квартиры сразу). - pages: максимальное число страниц на один sweep. Break on empty. + city_id: игнорируется (EKB захардкожен). + rooms: игнорируется (ROOM_BUCKETS перебирается всегда). + pages: максимальное число страниц на бакет (safety cap). Returns: Дедуплицированный по source_id список ScrapedLot. """ + # TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory + from app.services.scrapers.browser_fetcher import BrowserFetcher - all_lots: list[ScrapedLot] = [] + out_lots: list[ScrapedLot] = [] seen_ids: set[str] = set() - # Формируем список room-значений для sweep'ов - room_values: list[int | None] = [None] if not rooms else list(rooms) - async with BrowserFetcher(source="domclick") as fetcher: - for room_val in room_values: - room_label = f"rooms={room_val}" if room_val is not None else "all_rooms" + for bucket in ROOM_BUCKETS: logger.info( - "domklik: city_id=%d %s sweep (max %d pages)", city_id, room_label, pages + "domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d", + bucket, + city_id, + pages, ) - - for page_num in range(1, pages + 1): - url = self._build_url(city_id, room_val, page_num) - logger.debug("domklik: fetch %s", url) - - try: - html = await fetcher.fetch(url) - except Exception as exc: - logger.error( - "domklik: fetch failed city_id=%d page=%d: %s", - city_id, - page_num, - exc, - ) - break - - try: - lots = self._parse_html(html) - except DomClickBlockedError: - logger.warning( - "domklik: DataDome block at city_id=%d page=%d — stopping sweep", - city_id, - page_num, - ) - break - - if not lots: - logger.info( - "domklik: empty page city_id=%d %s page=%d — stopping sweep", - city_id, - room_label, - page_num, - ) - break - - # Дедупликация по source_id (кросс-sweep и кросс-страница) - new_lots: list[ScrapedLot] = [] - for lot in lots: - key = lot.source_id or lot.source_url - if key not in seen_ids: - seen_ids.add(key) - new_lots.append(lot) - - all_lots.extend(new_lots) - logger.info( - "domklik: city_id=%d %s page=%d -> %d new (total %d)", - city_id, - room_label, - page_num, - len(new_lots), - len(all_lots), + 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, ) - - # Пауза между страницами (anti-DataDome) - if page_num < pages: - await self.sleep_between_requests() + 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", + "domklik: fetch_city done city_id=%d total=%d " + "parse_failures=%d geo_filtered=%d fetch_errors=%d blocked=%s", city_id, - len(all_lots), + len(out_lots), self.parse_failures, + self.geo_filtered, + self.fetch_errors, + self.blocked, ) - return all_lots + return out_lots - # ── URL builder ─────────────────────────────────────────────────────────── + # ── Internal sweep helpers ──────────────────────────────────────────────── - def _build_url(self, city_id: int, rooms: int | None, page: int) -> str: - """Строит URL SERP DomClick.""" - url = ( - f"{self.base_url}/search" - f"?deal_type=sale&category=living&offer_type=flat" - f"&city_id={city_id}" - ) - if rooms is not None: - url += f"&rooms={rooms}" - url += f"&p={page}" - return url + async def _count( + self, + fetcher: Any, + rooms: str, + price_gte: int | None, + price_lte: int | None, + ) -> int: + """Запрашивает snippetsCount для данного бакета (rooms + price range). - # ── HTML parsing ────────────────────────────────────────────────────────── - - def _parse_html(self, html: str) -> list[ScrapedLot]: - """Парсим карточки из HTML через selectolax. - - Селектор: `a[href*="/card/"]` с паттерном `/card/sale__flat__`. + DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки + извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0 + (бакет пропускается, sweep продолжается). """ - if _is_blocked_page(html): - logger.warning("domklik: DataDome block page detected, 0 cards returned") - raise DomClickBlockedError("DomClick returned DataDome block page") - tree = HTMLParser(html) - card_links = tree.css('a[href*="/card/"]') + 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 - seen_hrefs: set[str] = set() - lots: list[ScrapedLot] = [] + 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). - for link in card_links: - href = link.attributes.get("href", "") - # Только карточки вторичного жилья - if "sale__flat__" not in href: - continue - if href in seen_hrefs: - continue - seen_hrefs.add(href) + Если snippetsCount > OFFSET_CAP — делим диапазон цен пополам и + рекурсируем в каждую половину. Остановка рекурсии: + - span <= MIN_PRICE_SPAN → пагинируем как есть (с предупреждением о truncation) + - snippetsCount == 0 → пропускаем + - snippetsCount <= OFFSET_CAP → пагинируем - lot = self._card_link_to_lot(link, href) - if lot is not None: - lots.append(lot) + Raises: + DomClickBlockedError: если QRATOR-блок — propagate наверх. + """ + count = await self._count(fetcher, rooms, price_gte, price_lte) + if count == 0: + return - return lots + 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) - def _card_link_to_lot(self, link: Node, href: str) -> ScrapedLot | None: - """Парсинг одной карточки-ссылки -> ScrapedLot. + 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. - DomClick SERP оборачивает карточку в `` — вся - информация (title, цена, адрес) внутри этого элемента. + DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки + извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и break + (трактуем как конец бакета — уже собранные lots сохраняются). - Цену извлекаем из отдельных дочерних элементов (не из объединённого - card_text) чтобы исключить слипание числа дома с ценой. + 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: - source_id = _extract_source_id(href) - source_url = urljoin(self.base_url, href) - - # Суммарный текст — для rooms/area/floor (эти поля не подвержены - # проблеме слипания, т.к. используют специфичные маркеры: «м²», «эт.») - card_text = link.text(strip=True) - if not card_text: + price = item.get("price", 0) or 0 + if price <= 0: return None - rooms = _extract_rooms(card_text) - area = _extract_area(card_text) - floor, total_floors = _extract_floor(card_text) + 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 "" - # Цена: ищем в каждом дочернем элементе отдельно чтобы не слипались - # числа дома с ценой при конкатенации card_text - price = self._extract_price_from_children(link) - if not price or price <= 0: - return None + loc = item.get("location") or {} + lat: float | None = loc.get("lat") + lon: float | None = loc.get("lon") - address = self._extract_address(link) + 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) + + 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"), + } + + publish_date = _parse_publish_date(item.get("publishedDate")) return ScrapedLot( source="domklik", source_url=source_url, source_id=source_id, address=address, - lat=None, # DomClick SERP координаты не отдаёт - lon=None, + lat=lat, + lon=lon, rooms=rooms, - area_m2=area, + area_m2=area_m2, floor=floor, total_floors=total_floors, - price_rub=price, + year_built=year_built, + price_rub=int(price), + price_per_m2=price_per_m2, listing_segment="vtorichka", - raw_payload={"card_text": card_text[:500]}, + publish_date=publish_date, + raw_payload=raw_payload, ) except Exception: self.parse_failures += 1 logger.warning( - "domclick _card_link_to_lot: parse failed href=%r (parse_failures=%d)", - href, + "domklik: _map_item failed for item id=%r (parse_failures=%d)", + item.get("id"), self.parse_failures, exc_info=True, ) return None - def _extract_price_from_children(self, link: Node) -> int | None: - """Ищем цену в дочерних элементах карточки по символу рубля. - - Итерируем по всем потомкам: первый элемент с рублёвым символом - в тексте — источник цены. - """ - for selector in ("span", "div", "p", "strong", "b"): - for el in link.css(selector): - text = el.text(strip=True) - price = _extract_price_from_element(text) - if price: - return price - # Fallback: суммарный текст карточки (если структура нестандартная) - return _extract_price_from_element(link.text(strip=True)) - - def _extract_address(self, link: Node) -> str | None: - """Эвристика извлечения адреса из DOM карточки. - - DomClick помещает адрес в отдельный текстовый блок внутри карточки. - Обходим вложенные элементы и ищем первый текст с адресными ключевыми - словами, пропуская строки с площадью/этажом/ценой. - - Возвращает None если адрес не найден. - """ - for selector in ("span", "p", "div"): - for el in link.css(selector): - text = el.text(strip=True) - if not text or len(text) < 5 or len(text) > 200: - continue - # Пропускаем блоки с площадью / этажом / ценой - if _RE_AREA.search(text) or _RE_FLOOR.search(text): - continue - if _RE_PRICE_EL.search(text): - continue - if _RE_ADDR_KW.search(text): - return text - return None - # ── Convenience runner (для Celery tasks) ──────────────────────────────────── @@ -393,14 +588,14 @@ class DomClickScraper(BaseScraper): async def scrape_domclick_city( city_id: int, rooms: list[int] | None = None, - pages: int = 20, + pages: int = 100, ) -> list[ScrapedLot]: """Удобная точка входа для вызова из Celery tasks. Пример:: import asyncio - lots = asyncio.run(scrape_domclick_city(city_id=4, rooms=[1, 2, 3])) + lots = asyncio.run(scrape_domclick_city(city_id=4, rooms=None)) """ async with DomClickScraper() as scraper: return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages) diff --git a/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py b/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py index 118adb72..796251cc 100644 --- a/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py +++ b/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py @@ -2,4 +2,12 @@ class DomClickBlockedError(Exception): - """DomClick вернул DataDome block-страницу (HTTP 200 + block HTML).""" + """DomClick BFF вернул QRATOR block-страницу (HTTP 200 + block HTML). + + QRATOR (qrator.net) — WAF/DDoS-защита domclick.ru. Блокирует datacenter-IP + и возвращает HTML с маркерами: "qrator", "bot_mitigation", "система защиты", + "403 | домклик", "captcha", "access denied". + + Обходится через shared mobile proxy (BrowserFetcher(source="domclick") → + generic provider → мобильный egress). + """ diff --git a/tradein-mvp/backend/data/sql/138_domclick_bff_rewrite_schedule.sql b/tradein-mvp/backend/data/sql/138_domclick_bff_rewrite_schedule.sql new file mode 100644 index 00000000..5e053e34 --- /dev/null +++ b/tradein-mvp/backend/data/sql/138_domclick_bff_rewrite_schedule.sql @@ -0,0 +1,33 @@ +-- 138_domclick_bff_rewrite_schedule.sql +-- +-- Обновляет параметры расписания domclick_city_sweep после переписки скрейпера +-- на BFF JSON API (https://bff-search-web.domclick.ru/api/offers/v1). +-- +-- DORMANT: enabled остаётся false до тех пор пока оператор не верифицирует +-- прод-smoke нового BFF-скрейпера вручную: +-- UPDATE scrape_schedules SET enabled = true WHERE source = 'domclick_city_sweep'; +-- +-- Ключевые изменения относительно 122_enable_domclick_city_sweep.sql: +-- - pages_per_anchor поднят до 100 (каждый бакет до 2000 листингов, +-- rooms=2 нуждается в price-split → реально ≈330 страниц) +-- - rooms убран из default_params — скрейпер всегда обходит все ROOM_BUCKETS +-- (st/1/2/3/4/5+) внутри независимо от этого параметра +-- - request_delay_sec оставлен 6 (как в 122; BFF JSON легче HTML-рендера) +-- +-- Зависимости: 052_scrape_schedules.sql (таблица), 122_enable_domclick_city_sweep.sql +-- (строка должна существовать — INSERT ON CONFLICT DO NOTHING в 122). +-- Idempotent: UPDATE не упадёт если строка уже обновлена (runs повторно безопасно). + +BEGIN; + +UPDATE scrape_schedules + SET enabled = false, + default_params = jsonb_build_object( + 'city_id', 4, + 'pages_per_anchor', 100, + 'request_delay_sec', 6 + ), + updated_at = now() + WHERE source = 'domclick_city_sweep'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick.py b/tradein-mvp/backend/tests/scrapers/test_domclick.py index 04d0557d..4653d796 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick.py @@ -1,252 +1,526 @@ -"""Unit-тесты для DomClick scraper (#796). +"""Unit-тесты для DomClick BFF scraper (#1968). Тестируем: -- Парсинг title-паттерна (rooms/area/floor) из текста карточки -- Парсинг цены из отдельного DOM-элемента -- Извлечение source_id из href -- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML +- _extract_json: bare +
-wrapped → dict; QRATOR-маркер → DomClickBlockedError
+- _build_offers_url / _build_count_url: корректные параметры, "5+" → "5%2B",
+  offers имеет offset/limit, count — нет
+- _map_item: маппинг realistic offer dict → ScrapedLot; price=0 → None; studio
+- _is_geo_ok: гео-гард по bbox + offerRegionName
+- _sweep_bucket: price-split при count > OFFSET_CAP
+- DomClickScraper: source/name, fetch_around raises NotImplementedError
 """
 
 from __future__ import annotations
 
 import asyncio
+from typing import Any
 
 import pytest
 
 from app.services.scrapers.domclick import (
+    LTE_MAX,
+    OFFSET_CAP,
     DomClickScraper,
-    _extract_area,
-    _extract_floor,
-    _extract_price_from_element,
-    _extract_rooms,
-    _extract_source_id,
+    _build_count_url,
+    _build_offers_url,
+    _extract_json,
 )
 from app.services.scrapers.domclick_exceptions import DomClickBlockedError
 
-# ── Rooms / studio ───────────────────────────────────────────────────────────
+# ── _extract_json ─────────────────────────────────────────────────────────────
 
 
-@pytest.mark.parametrize(
-    "text, expected",
-    [
-        ("2-комн. квартира 53,5 м² 5/15 эт.", 2),
-        ("1-к. квартира 32,1 м² 3/9 эт.", 1),
-        ("3-комн. кв. 78 м² 10/12 эт.", 3),
-        ("4-комн. квартира 95 м² 2/5 эт.", 4),
-        ("студия 24 м² 1/10 эт.", 0),
-        ("Студия 30,5 м² 8/22 эт.", 0),
-        ("Нет комнат — просто текст", None),
-    ],
-)
-def test_extract_rooms(text: str, expected: int | None) -> None:
-    assert _extract_rooms(text) == expected
+def test_extract_json_bare_body() -> None:
+    """Bare JSON body (без HTML-обёртки) корректно парсится."""
+    html = '{"result": {"items": [], "snippetsCount": 42}}'
+    data = _extract_json(html)
+    assert data["result"]["snippetsCount"] == 42
 
 
-# ── Area ─────────────────────────────────────────────────────────────────────
+def test_extract_json_pre_wrapped() -> None:
+    """JSON в 
-теге корректно парсится."""
+    html = (
+        "DomClick"
+        '
{"result": {"items": [{"id": 1}]}}
' + ) + data = _extract_json(html) + assert data["result"]["items"][0]["id"] == 1 -@pytest.mark.parametrize( - "text, expected", - [ - ("2-комн. квартира 53,5 м² 5/15 эт.", 53.5), - ("студия 24 м² 1/10 эт.", 24.0), - ("78м2 на этаже", 78.0), - ("площадь 101.7 м²", 101.7), - ("Нет площади", None), - ], -) -def test_extract_area(text: str, expected: float | None) -> None: - assert _extract_area(text) == expected +def test_extract_json_qrator_marker_raises() -> None: + """HTML с QRATOR-маркером вызывает DomClickBlockedError.""" + blocked_html = ( + "QRATOR protection" + "Access denied by bot_mitigation system" + ) + with pytest.raises(DomClickBlockedError): + _extract_json(blocked_html) -# ── Floor ───────────────────────────────────────────────────────────────────── +def test_extract_json_captcha_raises() -> None: + """HTML с captcha-маркером вызывает DomClickBlockedError.""" + html = "Please solve the captcha to continue" + with pytest.raises(DomClickBlockedError): + _extract_json(html) -@pytest.mark.parametrize( - "text, floor, total", - [ - ("2-комн. квартира 53,5 м² 5/15 эт.", 5, 15), - ("студия 24 м² 1/10 эт.", 1, 10), - ("12/24 эт.", 12, 24), - ("нет этажа", None, None), - ], -) -def test_extract_floor(text: str, floor: int | None, total: int | None) -> None: - assert _extract_floor(text) == (floor, total) +def test_extract_json_no_json_raises() -> None: + """HTML без JSON-объекта вызывает ValueError.""" + with pytest.raises(ValueError, match="No JSON"): + _extract_json("plain text") -# ── Price (per-element) ─────────────────────────────────────────────────────── -# Цена парсится из отдельного DOM-элемента (не из суммарного card_text), -# поэтому числа адреса не загрязняют парсинг цены. +# ── _build_offers_url ──────────────────────────────────────────────────────── -@pytest.mark.parametrize( - "text, expected", - [ - ("5 200 000 ₽", 5_200_000), - ("12 345 678 ₽", 12_345_678), - ("3 990 000 руб.", 3_990_000), - ("от 4 500 000 ₽", 4_500_000), - ("без цены", None), - ("0 ₽", None), - ], -) -def test_extract_price_from_element(text: str, expected: int | None) -> None: - assert _extract_price_from_element(text) == expected +def test_build_offers_url_basic_params() -> None: + url = _build_offers_url("1", None, None, 0) + assert "deal_type=sale" in url + assert "category=living" in url + assert "offer_type=flat" in url + assert "rooms=1" in url + assert "offset=0" in url + assert "limit=20" in url + assert "sale_price__gte" not in url + assert "sale_price__lte" not in url -# ── source_id from href ─────────────────────────────────────────────────────── +def test_build_offers_url_5plus_encoded() -> None: + """rooms='5+' должен кодироваться как '5%2B' в URL.""" + url = _build_offers_url("5+", None, None, 0) + assert "5%2B" in url + # literal '+' в URL не должно быть для значения rooms + assert "rooms=5+" not in url -@pytest.mark.parametrize( - "href, expected", - [ - ("/card/sale__flat__2075671636", "2075671636"), - ("https://domclick.ru/card/sale__flat__123456789", "123456789"), - ("/card/sale__flat__1", "1"), - ("/card/something_else", None), - ("", None), - ], -) -def test_extract_source_id(href: str, expected: str | None) -> None: - assert _extract_source_id(href) == expected +def test_build_offers_url_with_price_range() -> None: + url = _build_offers_url("2", 3_000_000, 6_000_000, 40) + assert "sale_price__gte=3000000" in url + assert "sale_price__lte=6000000" in url + assert "offset=40" in url -# ── _parse_html -> list[ScrapedLot] ────────────────────────────────────────── -# Цена в каждой карточке вынесена в отдельный с символом рубля, -# чтобы не сливаться с адресным номером дома при конкатенации card_text. - -_MOCK_HTML = """\ - - - - -
- 2-комн. квартира 53,5 м² 5/15 эт. - ул. Малышева, 1 - 5 200 000 ₽ - - - - - Студия 28 м² 3/10 эт. - пр-т Ленина, 50 - 3 100 000 ₽ - - - - - 1-комн. квартира 32 м² 2/9 эт. - - - - - что-то другое - - - - - 2-комн. квартира 53,5 м² 5/15 эт. - 5 200 000 ₽ - - - -""" +def test_build_offers_url_ekb_guid_present() -> None: + url = _build_offers_url("3", None, None, 0) + assert "0d475b79-88de-4054-818c-37d8f9d0d440" in url + assert "aids=20561" in url -def test_parse_html_returns_lots() -> None: - scraper = DomClickScraper() - lots = scraper._parse_html(_MOCK_HTML) - - # Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован - assert len(lots) == 2 +def test_build_offers_url_bff_host() -> None: + url = _build_offers_url("1", None, None, 0) + assert url.startswith("https://bff-search-web.domclick.ru/api/offers/v1?") -def test_parse_html_card1_fields() -> None: - scraper = DomClickScraper() - lots = scraper._parse_html(_MOCK_HTML) - - card1 = next(lot for lot in lots if lot.source_id == "2075671636") - assert card1.source == "domklik" - assert card1.source_url == "https://domclick.ru/card/sale__flat__2075671636" - assert card1.rooms == 2 - assert card1.area_m2 == 53.5 - assert card1.floor == 5 - assert card1.total_floors == 15 - assert card1.price_rub == 5_200_000 - assert card1.lat is None - assert card1.lon is None - assert card1.listing_segment == "vtorichka" +# ── _build_count_url ───────────────────────────────────────────────────────── -def test_parse_html_studio() -> None: - scraper = DomClickScraper() - lots = scraper._parse_html(_MOCK_HTML) - - studio = next(lot for lot in lots if lot.source_id == "1111111111") - assert studio.rooms == 0 # студия - assert studio.area_m2 == 28.0 - assert studio.price_rub == 3_100_000 +def test_build_count_url_no_offset_limit() -> None: + """count url не должен содержать offset и limit.""" + url = _build_count_url("2", None, None) + assert "offset" not in url + assert "limit" not in url -def test_parse_html_no_price_filtered() -> None: - scraper = DomClickScraper() - lots = scraper._parse_html(_MOCK_HTML) - - source_ids = {lot.source_id for lot in lots} - assert "9999999999" not in source_ids +def test_build_count_url_5plus_encoded() -> None: + url = _build_count_url("5+", None, None) + assert "5%2B" in url + assert "rooms=5+" not in url -def test_parse_html_dedup_same_href() -> None: - """Дубликат href на одной странице должен давать одну карточку.""" - scraper = DomClickScraper() - lots = scraper._parse_html(_MOCK_HTML) +def test_build_count_url_with_price() -> None: + url = _build_count_url("1", 1_000_000, 4_000_000) + assert "sale_price__gte=1000000" in url + assert "sale_price__lte=4000000" in url - ids_2075 = [lot for lot in lots if lot.source_id == "2075671636"] - assert len(ids_2075) == 1 + +def test_build_count_url_bff_host() -> None: + url = _build_count_url("1", None, None) + assert url.startswith("https://bff-search-web.domclick.ru/api/offers/count/v1?") + + +# ── _map_item → ScrapedLot ──────────────────────────────────────────────────── + +_REALISTIC_ITEM: dict[str, Any] = { + "id": 2075671636, + "path": "https://domclick.ru/card/sale__flat__2075671636", + "location": {"lat": 56.838, "lon": 60.612}, + "address": {"displayName": "Екатеринбург, улица Малышева, 1"}, + "objectInfo": {"area": 53.5, "rooms": 2, "floor": 5, "isApartment": False}, + "house": {"floors": 15, "buildYear": 2003}, + "price": 5_200_000, + "squarePrice": 97196, + "flatComplex": {"id": 12345, "name": "ЖК Тест", "slug": "zhk-test"}, + "isRosreestrApproved": True, + "publishedDate": "2026-06-01T10:00:00+05:00", + "updatedDate": "2026-06-15T12:00:00+05:00", + "lastPriceHistoryState": {"direction": "DECREASED"}, + "offerRegionName": "Екатеринбург", +} + + +def test_map_item_basic_fields() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + lot = scraper._map_item(_REALISTIC_ITEM) + assert lot is not None + assert lot.source == "domklik" + assert lot.source_id == "2075671636" + assert lot.source_url == "https://domclick.ru/card/sale__flat__2075671636" + assert lot.lat == pytest.approx(56.838) + assert lot.lon == pytest.approx(60.612) + assert lot.address == "Екатеринбург, улица Малышева, 1" + assert lot.rooms == 2 + assert lot.area_m2 == pytest.approx(53.5) + assert lot.floor == 5 + assert lot.total_floors == 15 + assert lot.year_built == 2003 + assert lot.price_rub == 5_200_000 + assert lot.price_per_m2 == 97196 + assert lot.listing_segment == "vtorichka" + + +def test_map_item_publish_date() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + from datetime import date + + lot = scraper._map_item(_REALISTIC_ITEM) + assert lot is not None + assert lot.publish_date == date(2026, 6, 1) + + +def test_map_item_raw_payload() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + lot = scraper._map_item(_REALISTIC_ITEM) + assert lot is not None + assert lot.raw_payload is not None + assert lot.raw_payload["isRosreestrApproved"] is True + assert lot.raw_payload["flatComplex"]["name"] == "ЖК Тест" + assert lot.raw_payload["lastPriceHistoryState"]["direction"] == "DECREASED" + + +def test_map_item_studio_force_rooms_zero() -> None: + """force_rooms=0 (студия-бакет) перебивает objectInfo.rooms.""" + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + item = {**_REALISTIC_ITEM, "objectInfo": {"area": 28.0, "rooms": 1, "floor": 3}} + lot = scraper._map_item(item, force_rooms=0) + assert lot is not None + assert lot.rooms == 0 + + +def test_map_item_zero_price_returns_none() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + item = {**_REALISTIC_ITEM, "price": 0} + assert scraper._map_item(item) is None + + +def test_map_item_zero_sentinels_become_none() -> None: + """buildYear/area/floor/total_floors == 0 (sentinel «неизвестно») → None. + + rooms=0 НЕ затрагивается (валидная студия), но здесь force_rooms не задан и + objectInfo.rooms=2, так что rooms остаётся 2. + """ + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + item = { + **_REALISTIC_ITEM, + "objectInfo": {"area": 0, "rooms": 2, "floor": 0}, + "house": {"floors": 0, "buildYear": 0}, + } + lot = scraper._map_item(item) + assert lot is not None + assert lot.area_m2 is None + assert lot.floor is None + assert lot.total_floors is None + assert lot.year_built is None + assert lot.rooms == 2 # rooms 0-sentinel НЕ применяется + + +def test_map_item_rooms_zero_studio_preserved() -> None: + """force_rooms=0 даёт rooms=0 (студия) — 0 здесь валиден, не sentinel.""" + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + item = {**_REALISTIC_ITEM, "objectInfo": {"area": 24.0, "rooms": 0, "floor": 2}} + lot = scraper._map_item(item, force_rooms=0) + assert lot is not None + assert lot.rooms == 0 + + +def test_map_item_price_per_m2_fallback() -> None: + """Если squarePrice отсутствует, price_per_m2 вычисляется из price / area_m2.""" + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + item = {**_REALISTIC_ITEM, "squarePrice": None} + lot = scraper._map_item(item) + assert lot is not None + assert lot.price_per_m2 == int(5_200_000 / 53.5) + + +# ── _is_geo_ok — гео-гард ──────────────────────────────────────────────────── + + +def test_is_geo_ok_valid_ekb_item() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + assert scraper._is_geo_ok(_REALISTIC_ITEM) is True + + +def test_is_geo_ok_wrong_region_name() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + item = {**_REALISTIC_ITEM, "offerRegionName": "Москва"} + assert scraper._is_geo_ok(item) is False + + +def test_is_geo_ok_out_of_bbox_lat() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + item = {**_REALISTIC_ITEM, "location": {"lat": 55.5, "lon": 60.6}} + assert scraper._is_geo_ok(item) is False + + +def test_is_geo_ok_out_of_bbox_lon() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + item = {**_REALISTIC_ITEM, "location": {"lat": 56.8, "lon": 61.5}} + assert scraper._is_geo_ok(item) is False + + +def test_is_geo_ok_missing_coords() -> None: + scraper = DomClickScraper.__new__(DomClickScraper) + item = {**_REALISTIC_ITEM, "location": {"lat": None, "lon": None}} + assert scraper._is_geo_ok(item) is False + + +# ── _sweep_bucket price-split ──────────────────────────────────────────────── + + +async def test_sweep_bucket_price_split_boundaries(monkeypatch: pytest.MonkeyPatch) -> None: + """_sweep_bucket: top-level unbounded split даёт contiguous non-overlapping ranges. + + rooms="2" count>cap один раз, потом ≤cap → ровно 2 leaf-бакета, покрывающих + [0, LTE_MAX] без перекрытия и без зазора: [(0, mid), (mid+1, LTE_MAX)]. + """ + call_count: dict[str, int] = {"n": 0} + paginate_calls: list[tuple[str, int | None, int | None]] = [] + + async def fake_count( + self: DomClickScraper, + fetcher: object, + rooms: str, + price_gte: int | None, + price_lte: int | None, + ) -> int: + call_count["n"] += 1 + # Первый вызов (unbounded) → over cap → price-split; остальные → под cap + return OFFSET_CAP + 1 if call_count["n"] == 1 else 1 + + async def fake_paginate( + self: DomClickScraper, + fetcher: object, + rooms: str, + price_gte: int | None, + price_lte: int | None, + seen_ids: set[str], + out_lots: list, + pages: int, + ) -> None: + paginate_calls.append((rooms, price_gte, price_lte)) + + monkeypatch.setattr(DomClickScraper, "_count", fake_count) + monkeypatch.setattr(DomClickScraper, "_paginate", fake_paginate) + + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + scraper.geo_filtered = 0 + scraper.blocked = False + scraper.fetch_errors = 0 + scraper.request_delay_sec = 0.0 + + await scraper._sweep_bucket( + fetcher=object(), + rooms="2", + price_gte=None, + price_lte=None, + seen_ids=set(), + out_lots=[], + pages=10, + ) + + # 1 count call (over cap) + 2 recursive count calls (≤ cap) = 3 total + assert call_count["n"] == 3 + # Both sub-buckets paginated, с точными границами split'а unbounded-диапазона. + mid = (0 + LTE_MAX) // 2 + assert paginate_calls == [("2", 0, mid), ("2", mid + 1, LTE_MAX)] + # Sanity: contiguous (нет зазора) + non-overlapping + покрывают весь хвост. + assert paginate_calls[0][2] + 1 == paginate_calls[1][1] # mid + 1 + assert paginate_calls[0][1] == 0 + assert paginate_calls[1][2] == LTE_MAX + + +async def test_sweep_bucket_zero_count_no_paginate(monkeypatch: pytest.MonkeyPatch) -> None: + """_sweep_bucket при count==0 не вызывает _paginate.""" + paginate_called = False + + async def fake_count(self: DomClickScraper, *_args: object, **_kwargs: object) -> int: + return 0 + + async def fake_paginate(self: DomClickScraper, *_args: object, **_kwargs: object) -> None: + nonlocal paginate_called + paginate_called = True + + monkeypatch.setattr(DomClickScraper, "_count", fake_count) + monkeypatch.setattr(DomClickScraper, "_paginate", fake_paginate) + + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + scraper.geo_filtered = 0 + scraper.blocked = False + scraper.fetch_errors = 0 + scraper.request_delay_sec = 0.0 + + await scraper._sweep_bucket( + fetcher=object(), + rooms="3", + price_gte=None, + price_lte=None, + seen_ids=set(), + out_lots=[], + pages=5, + ) + assert paginate_called is False + + +# ── _paginate resilience — mid-bucket parse error ──────────────────────────── + + +class _FakeFetcher: + """Стаб BrowserFetcher: возвращает заранее заданные ответы по порядку вызовов.""" + + def __init__(self, responses: list[str]) -> None: + self._responses = responses + self._idx = 0 + + async def fetch(self, _url: str) -> str: + resp = self._responses[min(self._idx, len(self._responses) - 1)] + self._idx += 1 + return resp + + +async def test_paginate_mid_bucket_parse_error_preserves_lots() -> None: + """Garbled JSON на page 2 → bucket breaks, page-1 lots survive, fetch_errors++. + + Page 1 — валидный JSON с 20 items (PAGE_SIZE) → продолжаем; page 2 — мусор + (ValueError из _extract_json) → fetch_errors++ и break, уже собранные lots целы. + """ + import json + + page1_items = [ + { + "id": 1000 + i, + "path": f"https://domclick.ru/card/sale__flat__{1000 + i}", + "location": {"lat": 56.84, "lon": 60.6}, + "address": {"displayName": "Екатеринбург, тест"}, + "objectInfo": {"area": 40.0, "rooms": 1, "floor": 3}, + "house": {"floors": 9, "buildYear": 2005}, + "price": 4_000_000 + i, + "squarePrice": 100000, + "offerRegionName": "Екатеринбург", + } + for i in range(20) + ] + page1 = json.dumps({"result": {"items": page1_items}}) + garbled = "
{not valid json at all
" + fetcher = _FakeFetcher([page1, garbled]) + + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + scraper.geo_filtered = 0 + scraper.blocked = False + scraper.fetch_errors = 0 + scraper.request_delay_sec = 0.0 + + out_lots: list = [] + await scraper._paginate( + fetcher=fetcher, + rooms="1", + price_gte=None, + price_lte=None, + seen_ids=set(), + out_lots=out_lots, + pages=10, + ) + + assert len(out_lots) == 20 # page-1 lots сохранены + assert scraper.fetch_errors == 1 # page-2 garbled посчитан + + +async def test_fetch_city_bucket_error_continues_to_next_bucket( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ошибка в одном бакете НЕ убивает весь sweep — остальные бакеты отрабатывают.""" + swept: list[str] = [] + + async def fake_sweep_bucket( + self: DomClickScraper, + fetcher: object, + rooms: str, + price_gte: int | None, + price_lte: int | None, + seen_ids: set[str], + out_lots: list, + pages: int, + ) -> None: + swept.append(rooms) + if rooms == "2": + raise ValueError("garbled bucket") + + # Заглушка BrowserFetcher через async context manager + class _BFStub: + async def __aenter__(self) -> _BFStub: + return self + + async def __aexit__(self, *_a: object) -> None: + return None + + async def fetch(self, _url: str) -> str: + return "{}" + + import app.services.scrapers.browser_fetcher as bf_mod + + monkeypatch.setattr(DomClickScraper, "_sweep_bucket", fake_sweep_bucket) + monkeypatch.setattr(bf_mod, "BrowserFetcher", lambda **_k: _BFStub()) + + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + scraper.geo_filtered = 0 + scraper.blocked = False + scraper.fetch_errors = 0 + scraper.request_delay_sec = 0.0 + + lots = await scraper.fetch_city(city_id=4, rooms=None, pages=5) + + # Все 6 бакетов посещены несмотря на ошибку в "2" + assert swept == ["st", "1", "2", "3", "4", "5+"] + assert scraper.fetch_errors == 1 + assert scraper.blocked is False + assert lots == [] + + +# ── DomClickScraper — class attrs ───────────────────────────────────────────── def test_scraper_source_name() -> None: - scraper = DomClickScraper() + scraper = DomClickScraper.__new__(DomClickScraper) + scraper.parse_failures = 0 + scraper.geo_filtered = 0 + scraper.blocked = False + scraper.request_delay_sec = 0.0 assert scraper.name == "domklik" assert scraper.source == "domklik" assert scraper.base_url == "https://domclick.ru" - # request_delay_sec установлен из get_scraper_delay (DB или fallback 8.0) - assert scraper.request_delay_sec >= 0.0 def test_fetch_around_raises() -> None: - scraper = DomClickScraper() + scraper = DomClickScraper.__new__(DomClickScraper) with pytest.raises(NotImplementedError, match="geo-radius"): asyncio.run(scraper.fetch_around(56.8, 60.6)) - - -def test_build_url_no_rooms() -> None: - scraper = DomClickScraper() - url = scraper._build_url(city_id=4, rooms=None, page=1) - assert "city_id=4" in url - assert "rooms" not in url - assert "p=1" in url - - -def test_build_url_with_rooms() -> None: - scraper = DomClickScraper() - url = scraper._build_url(city_id=4, rooms=2, page=3) - assert "rooms=2" in url - assert "p=3" in url - - -def test_parse_failures_counter() -> None: - """parse_failures счётчик начинается с 0.""" - scraper = DomClickScraper() - assert scraper.parse_failures == 0 - - -def test_blocked_page_raises_domclick_blocked_error() -> None: - """HTML с datadome в начале вызывает DomClickBlockedError.""" - scraper = DomClickScraper() - blocked_html = ( - "datadome protectionblocked" - ) - with pytest.raises(DomClickBlockedError): - scraper._parse_html(blocked_html) diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index e6f9200c..d51191d2 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -78,6 +78,9 @@ def _stub_domclick_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: def _init(self: DomClickScraper) -> None: self.request_delay_sec = 0.0 self.parse_failures = 0 + self.geo_filtered = 0 + self.blocked = False + self.fetch_errors = 0 async def _aenter(self: DomClickScraper) -> DomClickScraper: return self @@ -109,6 +112,8 @@ def test_domclick_sweep_counters_defaults() -> None: assert c.lots_updated == 0 assert c.pages_fetched == 0 assert c.errors_count == 0 + assert c.blocked == 0 + assert c.geo_filtered == 0 def test_domclick_sweep_counters_to_dict_all_keys() -> None: @@ -233,8 +238,13 @@ async def test_cancel_before_serp_skips_fetch(monkeypatch: pytest.MonkeyPatch) - assert fake.done is None -async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) -> None: - """Исключение в fetch_city → errors_count++, sweep завершается mark_done (не fatal).""" +async def test_fetch_city_exception_no_reraise(monkeypatch: pytest.MonkeyPatch) -> None: + """Исключение в fetch_city → errors_count++, sweep НЕ ре-райзит (graceful). + + Honest-status (#1968): полный крах SERP-фазы (errors_count=1, 0 lots) → mark_failed, + НЕ mark_done. Run без единого листинга по внешней причине — это реальный failure, + а не успешный пустой прогон. Re-raise при этом НЕ происходит (task завершается чисто). + """ async def fake_fetch_fail( self: DomClickScraper, @@ -242,7 +252,7 @@ async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) -> rooms: list[int] | None = None, pages: int = 20, ) -> list[ScrapedLot]: - raise RuntimeError("camoufox crashed") + raise RuntimeError("browser crashed") monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_fail) monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0)) @@ -256,36 +266,107 @@ async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) -> assert counters.errors_count == 1 assert counters.lots_fetched == 0 - assert fake.done is not None, "graceful: mark_done вызывается несмотря на ошибку SERP" - assert fake.failed is None + # 0 lots + error → honest mark_failed (не маскируем как done) + assert fake.failed is not None + assert fake.done is None -# ── _parse_html parser unit test ──────────────────────────────────────────── +async def test_fetch_errors_and_no_lots_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Scraper fetch_errors>0 + 0 лотов (без block) → mark_failed (FIX 3 / closes #1968). - -def test_parse_html_minimal_card() -> None: - """Минимальная DomClick карточка → ScrapedLot с rooms/area/floor/price/source_id.""" - # __init__ застаблен фикстурой _stub_domclick_lifecycle → нет DB/browser - scraper = DomClickScraper() - html = """ - - -
2-комн. квартира
- 52,3 м² - 5 / 16 эт. - Екатеринбург, улица Ленина, 10 - 6 500 000 ₽ -
- + Нераспознанный block-вариант приходит как fetch_error (не blocked=True), но + honest-status всё равно обязан пометить run failed при пустом результате. """ - lots = scraper._parse_html(html) - assert len(lots) == 1 - lot = lots[0] + + async def fake_fetch_errs( + self: DomClickScraper, + city_id: int, + rooms: list[int] | None = None, + pages: int = 100, + ) -> list[ScrapedLot]: + # Имитируем не-block ошибки извлечения JSON: blocked остаётся False + self.fetch_errors = 3 + return [] + + monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_errs) + monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0)) + + fake = _FakeRuns() + _install_runs(monkeypatch, fake) + + counters = await scrape_pipeline.run_domclick_city_sweep( + db=MagicMock(), run_id=6, request_delay_sec=0.0 + ) + + assert counters.lots_fetched == 0 + assert counters.blocked == 0 + assert counters.errors_count >= 3 # fetch_errors свёрнуты в errors_count + assert fake.failed is not None + assert fake.done is None + + +async def test_blocked_and_no_lots_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """QRATOR-блок + 0 лотов → mark_failed (честный статус, closes #1968).""" + + async def fake_fetch_blocked( + self: DomClickScraper, + city_id: int, + rooms: list[int] | None = None, + pages: int = 100, + ) -> list[ScrapedLot]: + # Имитируем QRATOR-блок: установим флаг на scraper и вернём пустой список + self.blocked = True + return [] + + monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_blocked) + monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0)) + + fake = _FakeRuns() + _install_runs(monkeypatch, fake) + + counters = await scrape_pipeline.run_domclick_city_sweep( + db=MagicMock(), run_id=5, request_delay_sec=0.0 + ) + + assert counters.lots_fetched == 0 + assert counters.blocked == 1 + # Честный статус: mark_failed должен быть вызван, mark_done — нет + assert fake.failed is not None + assert "QRATOR" in fake.failed[0] + assert fake.done is None + + +# ── _map_item via sweep — quick integration smoke ──────────────────────────── + + +def test_map_item_basic_mapping() -> None: + """DomClickScraper._map_item маппит BFF offer-item → ScrapedLot корректно.""" + scraper = DomClickScraper() # __init__ stubbed + item = { + "id": 111222333, + "path": "https://domclick.ru/card/sale__flat__111222333", + "location": {"lat": 56.838, "lon": 60.612}, + "address": {"displayName": "Екатеринбург, улица Ленина, 10"}, + "objectInfo": {"area": 52.3, "rooms": 2, "floor": 5}, + "house": {"floors": 16, "buildYear": 2010}, + "price": 6_500_000, + "squarePrice": 124282, + "flatComplex": None, + "isRosreestrApproved": False, + "publishedDate": "2026-06-01T10:00:00+05:00", + "updatedDate": None, + "lastPriceHistoryState": None, + "offerRegionName": "Екатеринбург", + } + lot = scraper._map_item(item) + assert lot is not None assert lot.source == "domklik" - assert lot.source_id == "2075671636" + assert lot.source_id == "111222333" assert lot.rooms == 2 assert lot.area_m2 == pytest.approx(52.3) assert lot.floor == 5 assert lot.total_floors == 16 assert lot.price_rub == 6_500_000 - assert lot.lat is None and lot.lon is None + assert lot.listing_segment == "vtorichka" + assert lot.lat == pytest.approx(56.838) + assert lot.lon == pytest.approx(60.612)