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.
This commit is contained in:
bot-backend 2026-06-27 13:57:07 +03:00
parent b60df61f6a
commit 52fc920507
6 changed files with 1153 additions and 516 deletions

View file

@ -3087,10 +3087,10 @@ async def run_avito_full_load(
class DomClickCitySweepCounters: class DomClickCitySweepCounters:
"""Aggregate counters для DomClick citywide sweep run. """Aggregate counters для DomClick citywide sweep run.
DomClick SERP citywide (city_id), не geo-bbox: anchor-loop отсутствует. DomClick BFF citywide (city_id), не geo-bbox: anchor-loop отсутствует.
Один проход fetch_city перебирает rooms × pages внутри scraper'а. Один проход fetch_city перебирает ROOM_BUCKETS × pages внутри scraper'а.
Поля симметричны sibling-counters, но без detail_*/houses_* (DomClick SERP-only) Поля симметричны sibling-counters, но без detail_*/houses_* (SERP-only)
и без anchors_* (нет anchor-фазы). pages_fetched = rooms × pages worst-case. и без anchors_* (нет anchor-фазы). pages_fetched = 6 buckets × pages worst-case.
""" """
lots_fetched: int = 0 lots_fetched: int = 0
@ -3098,17 +3098,21 @@ class DomClickCitySweepCounters:
lots_updated: int = 0 lots_updated: int = 0
pages_fetched: int = 0 pages_fetched: int = 0
errors_count: 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]: def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)} 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_CITY_ID: int = 4
DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] DOMCLICK_DEFAULT_ROOMS: list[int] = [0, 1, 2, 3, 4] # vestigial; scraper sweeps all buckets
# Оценка времени одного fetch'а (network + camoufox render + parse) для watchdog. # Число BFF-бакетов (st/1/2/3/4/5+) — фиксировано; используется для watchdog.
_DOMCLICK_NUM_BUCKETS: int = 6
# Оценка времени одного fetch'а (network + parse) для watchdog.
_DOMCLICK_PER_FETCH_S: float = 12.0 _DOMCLICK_PER_FETCH_S: float = 12.0
# Буфер сверху расчётного бюджета (cold browser start, save-фаза). # Буфер сверху расчётного бюджета (cold browser start, save-фаза, price-splits).
_DOMCLICK_SWEEP_BUDGET_S: float = 300.0 _DOMCLICK_SWEEP_BUDGET_S: float = 300.0
@ -3118,46 +3122,51 @@ async def run_domclick_city_sweep(
run_id: int, run_id: int,
city_id: int = DOMCLICK_DEFAULT_CITY_ID, city_id: int = DOMCLICK_DEFAULT_CITY_ID,
rooms: list[int] | None = None, rooms: list[int] | None = None,
pages: int = 5, pages: int = 100,
request_delay_sec: float | None = None, request_delay_sec: float | None = None,
) -> DomClickCitySweepCounters: ) -> 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: Структурно зеркалит run_cian_city_sweep / run_yandex_city_sweep, но CITYWIDE:
DomClick SERP не поддерживает geo-radius (fetch_around NotImplementedError), DomClick не поддерживает geo-radius (fetch_around NotImplementedError),
поэтому anchor-loop отсутствует. DomClickScraper.fetch_city уже перебирает поэтому anchor-loop отсутствует. DomClickScraper.fetch_city перебирает все
rooms × pages через внутренний BrowserFetcher (camoufox headless) и ROOM_BUCKETS (st/1/2/3/4/5+) через BrowserFetcher shared mobile proxy
дедуплицирует по source_id. обходит QRATOR.
Фазы (единственный citywide проход): Фазы (единственный citywide проход):
1. SERP: DomClickScraper().fetch_city(city_id, rooms, pages). 1. SERP: DomClickScraper().fetch_city(city_id, rooms, pages).
2. SAVE: save_listings(db, lots, run_id=run_id) BaseScraper save path, 2. SAVE: save_listings(db, lots, run_id=run_id) house-match по lat/lon и
триггерит существующий house-match hook (адрес-fingerprint tier, т.к. address-fingerprint (BFF отдаёт координаты).
DomClick SERP не отдаёт lat/lon).
Watchdog: весь fetch_city оборачивается в asyncio.wait_for. Таймаут считается 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-фазой. 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. Возвращает 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 6.0
_resolved_delay = request_delay_sec if request_delay_sec is not None else 8.0
counters = DomClickCitySweepCounters() counters = DomClickCitySweepCounters()
# Watchdog-таймаут: число fetch'ей = len(rooms) × pages (worst-case, без # Watchdog: 6 buckets × pages × per_fetch + budget.
# early-break на пустой странице). Каждый fetch ≈ delay + render/parse overhead. # Реальный worst-case (rooms=2 price-split) ≈ 330 страниц × (delay+12s).
_num_fetches = max(1, len(_rooms)) * max(1, pages) # pages=100 → 600 fetch'ей × ~18s + 300 ≈ 11100s (~3ч) — намеренно generous.
_num_fetches = _DOMCLICK_NUM_BUCKETS * max(1, pages)
_sweep_timeout = max( _sweep_timeout = max(
ANCHOR_TIMEOUT_SEC, ANCHOR_TIMEOUT_SEC,
int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S), int(_num_fetches * (_resolved_delay + _DOMCLICK_PER_FETCH_S) + _DOMCLICK_SWEEP_BUDGET_S),
) )
# Мутируемый контейнер для захвата scraper-ссылки из замыкания.
_scraper_ref: list[DomClickScraper] = []
try: try:
# ── Cooperative cancel перед SERP-фазой ────────────────────────────── # ── Cooperative cancel перед SERP-фазой ──────────────────────────────
if scrape_runs.is_cancelled(db, run_id): if scrape_runs.is_cancelled(db, run_id):
@ -3166,10 +3175,11 @@ async def run_domclick_city_sweep(
return counters return counters
logger.info( 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, run_id,
city_id, city_id,
_rooms, len(ROOM_BUCKETS),
pages, pages,
_sweep_timeout, _sweep_timeout,
) )
@ -3179,10 +3189,11 @@ async def run_domclick_city_sweep(
async def _domclick_phase() -> None: async def _domclick_phase() -> None:
"""Единственная citywide-фаза: fetch_city + save.""" """Единственная citywide-фаза: fetch_city + save."""
nonlocal lots nonlocal lots
async with DomClickScraper() as scraper: async with DomClickScraper() as _scraper:
_scraper_ref.append(_scraper)
if request_delay_sec is not None: if request_delay_sec is not None:
scraper.request_delay_sec = _resolved_delay _scraper.request_delay_sec = _resolved_delay
lots = await scraper.fetch_city(city_id=city_id, rooms=_rooms, pages=pages) lots = await _scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
counters.lots_fetched += len(lots) counters.lots_fetched += len(lots)
if lots: if lots:
inserted, updated = save_listings(db, lots, run_id=run_id) 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) logger.exception("domclick-sweep run_id=%d: SERP phase failed", run_id)
counters.errors_count += 1 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 counters.pages_fetched = _num_fetches
scrape_runs.update_heartbeat(db, run_id, counters.to_dict()) 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( 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, run_id,
counters.lots_fetched, counters.lots_fetched,
counters.lots_inserted, counters.lots_inserted,
counters.lots_updated, counters.lots_updated,
counters.pages_fetched, counters.pages_fetched,
counters.errors_count, counters.errors_count,
counters.blocked,
counters.geo_filtered,
) )
return counters return counters

View file

@ -1,28 +1,32 @@
"""DomClick.ru scraper — вторичка через headless BrowserFetcher (#796). """DomClick.ru scraper — вторичка через JSON BFF API (#1968).
Стратегия: HTML scrape через camoufox (AsyncCamoufox) единственный Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
рабочий путь: DataDome пропускает headless Playwright, curl_cffi -> 401. BrowserFetcher(source="domclick") (generic provider shared mobile proxy).
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.
URL шаблон: Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
https://domclick.ru/search?deal_type=sale&category=living&offer_type=flat Парсинг: _extract_json() вытаскивает первый {...} из ответа.
&city_id={city_id}&rooms={r}&p={page}
Оффер-карточки: `a[href*="/card/"]` с href-паттерном `/card/sale__flat__<id>`. Стратегия нумерации комнат (ROOM_BUCKETS):
Координаты SERP не отдаёт (lat = lon = None). "st" студии (force rooms=0 в ScrapedLot)
"1""4" соответственно
"5+" 5 и более комнат
Важно: selectolax `.text()` объединяет все дочерние текстовые узлы без Пагинация: offset 0, 20, 40, , cap=2000. Если snippetsCount > 2000
разделителей. Числа из адреса (номер дома) могут слипнуться с ценой. для бакета, рекурсивно делим по цене (binary split) до тех пор пока
Поэтому цену извлекаем из каждого дочернего элемента отдельно (а не из каждая ветка укладывается в cap.
суммарного card_text), где элемент содержит символ рубля.
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
через detail-эндпоинт после первоначального сбора.
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re from datetime import date
from urllib.parse import urljoin from typing import Any
from urllib.parse import urlencode
from selectolax.parser import HTMLParser, Node
from app.services.scraper_settings import get_scraper_delay from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.base import BaseScraper, ScrapedLot from app.services.scrapers.base import BaseScraper, ScrapedLot
@ -30,112 +34,179 @@ from app.services.scrapers.domclick_exceptions import DomClickBlockedError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── DataDome block detection ───────────────────────────────────────────────── # ── API constants ─────────────────────────────────────────────────────────────
_DATADOME_MARKERS = ("datadome", "blocked", "access denied", "bot detected")
_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: # Buckets to sweep — порядок влияет на логи.
head = html[:2048].lower() ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")
return any(m in head for m in _DATADOME_MARKERS)
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 ──────────────────────────────────────────────────────────── # ── QRATOR block detection ────────────────────────────────────────────────────
# _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)
# Цена: парсим из одного элемента DOM (не из суммарного card_text) — _QRATOR_MARKERS: tuple[str, ...] = (
# иначе число дома может слипнуться с ценой. Цена всегда в элементе с «₽». "qrator",
_RE_PRICE_EL = re.compile(r"([\d\s ]+)\s*[₽р](?:уб\.?)?", re.IGNORECASE) "bot_mitigation",
"система защиты",
# source_id из href вида /card/sale__flat__2075671636 "403 | домклик",
_RE_SOURCE_ID = re.compile(r"sale__flat__(\d+)") "captcha",
"access denied",
# Адресные ключевые слова (эвристика)
_RE_ADDR_KW = re.compile(
r"ул\.|улица|пер\.|переулок|пр-т|проспект"
r"|бул\.|бульвар|шоссе|наб\.|набережная"
r"|пл\.|площадь|тракт|д\.\s*\d",
re.IGNORECASE,
) )
# ── 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: # ── JSON extraction ───────────────────────────────────────────────────────────
if _RE_STUDIO.search(text):
return 0
m = _RE_ROOMS.search(text)
return int(m.group(1)) if m else None
def _extract_area(text: str) -> float | None: def _extract_json(html: str) -> dict[str, Any]:
m = _RE_AREA.search(text) """Извлекает JSON-объект из ответа BrowserFetcher.
return float(m.group(1).replace(",", ".")) if m else None
BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
1. Bare body: ``{"result": ...}`` напрямую в <body>.
2. <pre>-wrapped: ``<html><body><pre>{"result": ...}</pre></body></html>``.
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
Raises:
def _extract_price_from_element(text: str) -> int | None: DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
"""Извлекаем цену из текста ОДНОГО элемента DOM. ValueError: если JSON не найден или не является dict.
Парсим из одного span/div не из суммарного card_text. Это исключает
случай, когда адресный номер дома стоит вплотную перед ценой в
объединённом тексте карточки («Ленина, 503 100 000 руб.»).
""" """
m = _RE_PRICE_EL.search(text) # Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
if m: # стоять за пределами head в крупных challenge-страницах.
# Нормализуем пробелы любого вида (обычный, неразрывный, узкий) html_lower = html.lower()
raw = re.sub(r"[\s ]", "", m.group(1)) if any(m in html_lower for m in _QRATOR_MARKERS):
if raw.isdigit(): raise DomClickBlockedError(
val = int(raw) f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})"
if val > 0: )
return val
return None 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: # ── URL builders ──────────────────────────────────────────────────────────────
"""Числовой ID из href типа /card/sale__flat__2075671636."""
m = _RE_SOURCE_ID.search(href)
return m.group(1) if m else None 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 ────────────────────────────────────────────────────────── # ── DomClickScraper ──────────────────────────────────────────────────────────
class DomClickScraper(BaseScraper): class DomClickScraper(BaseScraper):
"""DomClick вторичка parser. Источник = 'domklik'. """DomClick вторичка через JSON BFF API. Источник = 'domklik'.
Использует BrowserFetcher (camoufox headless Firefox) DataDome пропускает Использует BrowserFetcher(source="domclick") (generic provider shared mobile
его без дополнительной stealth-настройки. curl_cffi -> 401 (DataDome блок). proxy), который обходит QRATOR. Прямые curl/httpx-запросы с datacenter-IP
блокируются QRATOR.
Основной метод: fetch_city(city_id, rooms, pages). Основной метод: fetch_city(city_id, rooms, pages).
fetch_around() не реализован: DomClick не поддерживает geo-radius в URL. 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" name = "domklik"
source = "domklik" source = "domklik"
base_url = "https://domclick.ru" base_url = "https://domclick.ru"
# DataDome — консервативная задержка между страницами (fallback до init) # Консервативная задержка между страницами (fallback до init)
request_delay_sec = 8.0 request_delay_sec = 8.0
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.request_delay_sec = get_scraper_delay(self.name) self.request_delay_sec = get_scraper_delay(self.name)
# Счётчик карточек с неожиданной DOM-структурой (для observability)
self.parse_failures: int = 0 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: async def __aenter__(self) -> DomClickScraper:
await super().__aenter__() await super().__aenter__()
@ -155,237 +226,361 @@ class DomClickScraper(BaseScraper):
self, self,
city_id: int, city_id: int,
rooms: list[int] | None = None, rooms: list[int] | None = None,
pages: int = 20, pages: int = 100,
) -> list[ScrapedLot]: ) -> 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: Args:
city_id: числовой ID города в DomClick (например, 4 = Екатеринбург). city_id: игнорируется (EKB захардкожен).
rooms: список значений комнатности (0=студия, 1, 2, 3, 4, ...). rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
None -> без фильтра комнатности (все квартиры сразу). pages: максимальное число страниц на бакет (safety cap).
pages: максимальное число страниц на один sweep. Break on empty.
Returns: Returns:
Дедуплицированный по source_id список ScrapedLot. Дедуплицированный по source_id список ScrapedLot.
""" """
# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
from app.services.scrapers.browser_fetcher import BrowserFetcher from app.services.scrapers.browser_fetcher import BrowserFetcher
all_lots: list[ScrapedLot] = [] out_lots: list[ScrapedLot] = []
seen_ids: set[str] = set() 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: async with BrowserFetcher(source="domclick") as fetcher:
for room_val in room_values: for bucket in ROOM_BUCKETS:
room_label = f"rooms={room_val}" if room_val is not None else "all_rooms"
logger.info( 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,
) )
try:
for page_num in range(1, pages + 1): await self._sweep_bucket(
url = self._build_url(city_id, room_val, page_num) fetcher=fetcher,
logger.debug("domklik: fetch %s", url) rooms=bucket,
price_gte=None,
try: price_lte=None,
html = await fetcher.fetch(url) seen_ids=seen_ids,
except Exception as exc: out_lots=out_lots,
logger.error( pages=pages,
"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),
) )
except DomClickBlockedError:
# Пауза между страницами (anti-DataDome) self.blocked = True
if page_num < pages: logger.error(
await self.sleep_between_requests() "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( 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, city_id,
len(all_lots), len(out_lots),
self.parse_failures, 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: async def _count(
"""Строит URL SERP DomClick.""" self,
url = ( fetcher: Any,
f"{self.base_url}/search" rooms: str,
f"?deal_type=sale&category=living&offer_type=flat" price_gte: int | None,
f"&city_id={city_id}" price_lte: int | None,
) ) -> int:
if rooms is not None: """Запрашивает snippetsCount для данного бакета (rooms + price range).
url += f"&rooms={rooms}"
url += f"&p={page}"
return url
# ── HTML parsing ────────────────────────────────────────────────────────── DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и return 0
def _parse_html(self, html: str) -> list[ScrapedLot]: (бакет пропускается, sweep продолжается).
"""Парсим карточки из HTML через selectolax.
Селектор: `a[href*="/card/"]` с паттерном `/card/sale__flat__<id>`.
""" """
if _is_blocked_page(html): url = _build_count_url(rooms, price_gte, price_lte)
logger.warning("domklik: DataDome block page detected, 0 cards returned") logger.debug("domklik: count url=%s", url)
raise DomClickBlockedError("DomClick returned DataDome block page") html = await fetcher.fetch(url)
tree = HTMLParser(html) try:
card_links = tree.css('a[href*="/card/"]') 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() async def _sweep_bucket(
lots: list[ScrapedLot] = [] 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: Если snippetsCount > OFFSET_CAP делим диапазон цен пополам и
href = link.attributes.get("href", "") рекурсируем в каждую половину. Остановка рекурсии:
# Только карточки вторичного жилья - span <= MIN_PRICE_SPAN пагинируем как есть (с предупреждением о truncation)
if "sale__flat__" not in href: - snippetsCount == 0 пропускаем
continue - snippetsCount <= OFFSET_CAP пагинируем
if href in seen_hrefs:
continue
seen_hrefs.add(href)
lot = self._card_link_to_lot(link, href) Raises:
if lot is not None: DomClickBlockedError: если QRATOR-блок propagate наверх.
lots.append(lot) """
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: async def _paginate(
"""Парсинг одной карточки-ссылки -> ScrapedLot. 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 оборачивает карточку в `<a href="/card/...">` вся DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
информация (title, цена, адрес) внутри этого элемента. извлечения JSON (truncated/garbled/bad shape) fetch_errors++ и break
(трактуем как конец бакета уже собранные lots сохраняются).
Цену извлекаем из отдельных дочерних элементов (не из объединённого Raises:
card_text) чтобы исключить слипание числа дома с ценой. 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: try:
source_id = _extract_source_id(href) price = item.get("price", 0) or 0
source_url = urljoin(self.base_url, href) if price <= 0:
# Суммарный текст — для rooms/area/floor (эти поля не подвержены
# проблеме слипания, т.к. используют специфичные маркеры: «м²», «эт.»)
card_text = link.text(strip=True)
if not card_text:
return None return None
rooms = _extract_rooms(card_text) item_id = item.get("id")
area = _extract_area(card_text) source_id = str(item_id) if item_id is not None else None
floor, total_floors = _extract_floor(card_text) source_url: str = item.get("path", "") or ""
# Цена: ищем в каждом дочернем элементе отдельно чтобы не слипались loc = item.get("location") or {}
# числа дома с ценой при конкатенации card_text lat: float | None = loc.get("lat")
price = self._extract_price_from_children(link) lon: float | None = loc.get("lon")
if not price or price <= 0:
return None
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( return ScrapedLot(
source="domklik", source="domklik",
source_url=source_url, source_url=source_url,
source_id=source_id, source_id=source_id,
address=address, address=address,
lat=None, # DomClick SERP координаты не отдаёт lat=lat,
lon=None, lon=lon,
rooms=rooms, rooms=rooms,
area_m2=area, area_m2=area_m2,
floor=floor, floor=floor,
total_floors=total_floors, total_floors=total_floors,
price_rub=price, year_built=year_built,
price_rub=int(price),
price_per_m2=price_per_m2,
listing_segment="vtorichka", listing_segment="vtorichka",
raw_payload={"card_text": card_text[:500]}, publish_date=publish_date,
raw_payload=raw_payload,
) )
except Exception: except Exception:
self.parse_failures += 1 self.parse_failures += 1
logger.warning( logger.warning(
"domclick _card_link_to_lot: parse failed href=%r (parse_failures=%d)", "domklik: _map_item failed for item id=%r (parse_failures=%d)",
href, item.get("id"),
self.parse_failures, self.parse_failures,
exc_info=True, exc_info=True,
) )
return None 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) ──────────────────────────────────── # ── Convenience runner (для Celery tasks) ────────────────────────────────────
@ -393,14 +588,14 @@ class DomClickScraper(BaseScraper):
async def scrape_domclick_city( async def scrape_domclick_city(
city_id: int, city_id: int,
rooms: list[int] | None = None, rooms: list[int] | None = None,
pages: int = 20, pages: int = 100,
) -> list[ScrapedLot]: ) -> list[ScrapedLot]:
"""Удобная точка входа для вызова из Celery tasks. """Удобная точка входа для вызова из Celery tasks.
Пример:: Пример::
import asyncio 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: async with DomClickScraper() as scraper:
return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages) return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)

View file

@ -2,4 +2,12 @@
class DomClickBlockedError(Exception): 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).
"""

View file

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

View file

@ -1,252 +1,526 @@
"""Unit-тесты для DomClick scraper (#796). """Unit-тесты для DomClick BFF scraper (#1968).
Тестируем: Тестируем:
- Парсинг title-паттерна (rooms/area/floor) из текста карточки - _extract_json: bare + <pre>-wrapped dict; QRATOR-маркер DomClickBlockedError
- Парсинг цены из отдельного DOM-элемента - _build_offers_url / _build_count_url: корректные параметры, "5+" "5%2B",
- Извлечение source_id из href offers имеет offset/limit, count нет
- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML - _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 from __future__ import annotations
import asyncio import asyncio
from typing import Any
import pytest import pytest
from app.services.scrapers.domclick import ( from app.services.scrapers.domclick import (
LTE_MAX,
OFFSET_CAP,
DomClickScraper, DomClickScraper,
_extract_area, _build_count_url,
_extract_floor, _build_offers_url,
_extract_price_from_element, _extract_json,
_extract_rooms,
_extract_source_id,
) )
from app.services.scrapers.domclick_exceptions import DomClickBlockedError from app.services.scrapers.domclick_exceptions import DomClickBlockedError
# ── Rooms / studio ─────────────────────────────────────────────────────────── # ── _extract_json ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize( def test_extract_json_bare_body() -> None:
"text, expected", """Bare JSON body (без HTML-обёртки) корректно парсится."""
[ html = '{"result": {"items": [], "snippetsCount": 42}}'
("2-комн. квартира 53,5 м² 5/15 эт.", 2), data = _extract_json(html)
("1-к. квартира 32,1 м² 3/9 эт.", 1), assert data["result"]["snippetsCount"] == 42
("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
# ── Area ───────────────────────────────────────────────────────────────────── def test_extract_json_pre_wrapped() -> None:
"""JSON в <pre>-теге корректно парсится."""
html = (
"<html><head><title>DomClick</title></head>"
'<body><pre>{"result": {"items": [{"id": 1}]}}</pre></body></html>'
)
data = _extract_json(html)
assert data["result"]["items"][0]["id"] == 1
@pytest.mark.parametrize( def test_extract_json_qrator_marker_raises() -> None:
"text, expected", """HTML с QRATOR-маркером вызывает DomClickBlockedError."""
[ blocked_html = (
("2-комн. квартира 53,5 м² 5/15 эт.", 53.5), "<html><head><title>QRATOR protection</title></head>"
("студия 24 м² 1/10 эт.", 24.0), "<body>Access denied by bot_mitigation system</body></html>"
("78м2 на этаже", 78.0), )
("площадь 101.7 м²", 101.7), with pytest.raises(DomClickBlockedError):
("Нет площади", None), _extract_json(blocked_html)
],
)
def test_extract_area(text: str, expected: float | None) -> None:
assert _extract_area(text) == expected
# ── Floor ───────────────────────────────────────────────────────────────────── def test_extract_json_captcha_raises() -> None:
"""HTML с captcha-маркером вызывает DomClickBlockedError."""
html = "<html><body>Please solve the captcha to continue</body></html>"
with pytest.raises(DomClickBlockedError):
_extract_json(html)
@pytest.mark.parametrize( def test_extract_json_no_json_raises() -> None:
"text, floor, total", """HTML без JSON-объекта вызывает ValueError."""
[ with pytest.raises(ValueError, match="No JSON"):
("2-комн. квартира 53,5 м² 5/15 эт.", 5, 15), _extract_json("<html><body>plain text</body></html>")
("студия 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)
# ── Price (per-element) ─────────────────────────────────────────────────────── # ── _build_offers_url ────────────────────────────────────────────────────────
# Цена парсится из отдельного DOM-элемента (не из суммарного card_text),
# поэтому числа адреса не загрязняют парсинг цены.
@pytest.mark.parametrize( def test_build_offers_url_basic_params() -> None:
"text, expected", url = _build_offers_url("1", None, None, 0)
[ assert "deal_type=sale" in url
("5 200 000 ₽", 5_200_000), assert "category=living" in url
("12 345 678 ₽", 12_345_678), assert "offer_type=flat" in url
("3 990 000 руб.", 3_990_000), assert "rooms=1" in url
("от 4 500 000 ₽", 4_500_000), assert "offset=0" in url
("без цены", None), assert "limit=20" in url
("0 ₽", None), assert "sale_price__gte" not in url
], assert "sale_price__lte" not in url
)
def test_extract_price_from_element(text: str, expected: int | None) -> None:
assert _extract_price_from_element(text) == expected
# ── 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( def test_build_offers_url_with_price_range() -> None:
"href, expected", url = _build_offers_url("2", 3_000_000, 6_000_000, 40)
[ assert "sale_price__gte=3000000" in url
("/card/sale__flat__2075671636", "2075671636"), assert "sale_price__lte=6000000" in url
("https://domclick.ru/card/sale__flat__123456789", "123456789"), assert "offset=40" in url
("/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
# ── _parse_html -> list[ScrapedLot] ────────────────────────────────────────── def test_build_offers_url_ekb_guid_present() -> None:
# Цена в каждой карточке вынесена в отдельный <span> с символом рубля, url = _build_offers_url("3", None, None, 0)
# чтобы не сливаться с адресным номером дома при конкатенации card_text. assert "0d475b79-88de-4054-818c-37d8f9d0d440" in url
assert "aids=20561" in url
_MOCK_HTML = """\
<!DOCTYPE html>
<html>
<body>
<!-- Карточка 1: комнатность, площадь, этаж, адрес, цена в отдельных span -->
<a href="/card/sale__flat__2075671636">
<span>2-комн. квартира 53,5 м² 5/15 эт.</span>
<span>ул. Малышева, 1</span>
<span>5 200 000 </span>
</a>
<!-- Карточка 2: студия -->
<a href="/card/sale__flat__1111111111">
<span>Студия 28 м² 3/10 эт.</span>
<span>пр-т Ленина, 50</span>
<span>3 100 000 </span>
</a>
<!-- Карточка 3: без цены -> должна быть отфильтрована -->
<a href="/card/sale__flat__9999999999">
<span>1-комн. квартира 32 м² 2/9 эт.</span>
</a>
<!-- Не карточка (без sale__flat__) -> должна быть отфильтрована -->
<a href="/card/sale__apartment__123">
<span>что-то другое</span>
</a>
<!-- Дубликат первой карточки -> один раз на страницу -->
<a href="/card/sale__flat__2075671636">
<span>2-комн. квартира 53,5 м² 5/15 эт.</span>
<span>5 200 000 </span>
</a>
</body>
</html>
"""
def test_parse_html_returns_lots() -> None: def test_build_offers_url_bff_host() -> None:
scraper = DomClickScraper() url = _build_offers_url("1", None, None, 0)
lots = scraper._parse_html(_MOCK_HTML) assert url.startswith("https://bff-search-web.domclick.ru/api/offers/v1?")
# Карточки 1 и 2 парсятся; карточка 3 (без цены) отфильтрована; дубль — дедуплицирован
assert len(lots) == 2
def test_parse_html_card1_fields() -> None: # ── _build_count_url ─────────────────────────────────────────────────────────
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"
def test_parse_html_studio() -> None: def test_build_count_url_no_offset_limit() -> None:
scraper = DomClickScraper() """count url не должен содержать offset и limit."""
lots = scraper._parse_html(_MOCK_HTML) url = _build_count_url("2", None, None)
assert "offset" not in url
studio = next(lot for lot in lots if lot.source_id == "1111111111") assert "limit" not in url
assert studio.rooms == 0 # студия
assert studio.area_m2 == 28.0
assert studio.price_rub == 3_100_000
def test_parse_html_no_price_filtered() -> None: def test_build_count_url_5plus_encoded() -> None:
scraper = DomClickScraper() url = _build_count_url("5+", None, None)
lots = scraper._parse_html(_MOCK_HTML) assert "5%2B" in url
assert "rooms=5+" not in url
source_ids = {lot.source_id for lot in lots}
assert "9999999999" not in source_ids
def test_parse_html_dedup_same_href() -> None: def test_build_count_url_with_price() -> None:
"""Дубликат href на одной странице должен давать одну карточку.""" url = _build_count_url("1", 1_000_000, 4_000_000)
scraper = DomClickScraper() assert "sale_price__gte=1000000" in url
lots = scraper._parse_html(_MOCK_HTML) 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 = "<html><body><pre>{not valid json at all</pre></body></html>"
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: 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.name == "domklik"
assert scraper.source == "domklik" assert scraper.source == "domklik"
assert scraper.base_url == "https://domclick.ru" 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: def test_fetch_around_raises() -> None:
scraper = DomClickScraper() scraper = DomClickScraper.__new__(DomClickScraper)
with pytest.raises(NotImplementedError, match="geo-radius"): with pytest.raises(NotImplementedError, match="geo-radius"):
asyncio.run(scraper.fetch_around(56.8, 60.6)) 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 = (
"<html><head><title>datadome protection</title></head><body>blocked</body></html>"
)
with pytest.raises(DomClickBlockedError):
scraper._parse_html(blocked_html)

View file

@ -78,6 +78,9 @@ def _stub_domclick_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None:
def _init(self: DomClickScraper) -> None: def _init(self: DomClickScraper) -> None:
self.request_delay_sec = 0.0 self.request_delay_sec = 0.0
self.parse_failures = 0 self.parse_failures = 0
self.geo_filtered = 0
self.blocked = False
self.fetch_errors = 0
async def _aenter(self: DomClickScraper) -> DomClickScraper: async def _aenter(self: DomClickScraper) -> DomClickScraper:
return self return self
@ -109,6 +112,8 @@ def test_domclick_sweep_counters_defaults() -> None:
assert c.lots_updated == 0 assert c.lots_updated == 0
assert c.pages_fetched == 0 assert c.pages_fetched == 0
assert c.errors_count == 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: 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 assert fake.done is None
async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) -> None: async def test_fetch_city_exception_no_reraise(monkeypatch: pytest.MonkeyPatch) -> None:
"""Исключение в fetch_city → errors_count++, sweep завершается mark_done (не fatal).""" """Исключение в 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( async def fake_fetch_fail(
self: DomClickScraper, self: DomClickScraper,
@ -242,7 +252,7 @@ async def test_fetch_city_exception_graceful(monkeypatch: pytest.MonkeyPatch) ->
rooms: list[int] | None = None, rooms: list[int] | None = None,
pages: int = 20, pages: int = 20,
) -> list[ScrapedLot]: ) -> list[ScrapedLot]:
raise RuntimeError("camoufox crashed") raise RuntimeError("browser crashed")
monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_fail) monkeypatch.setattr(DomClickScraper, "fetch_city", fake_fetch_fail)
monkeypatch.setattr(scrape_pipeline, "save_listings", lambda *a, **k: (0, 0)) 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.errors_count == 1
assert counters.lots_fetched == 0 assert counters.lots_fetched == 0
assert fake.done is not None, "graceful: mark_done вызывается несмотря на ошибку SERP" # 0 lots + error → honest mark_failed (не маскируем как done)
assert fake.failed is None 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).
Нераспознанный block-вариант приходит как fetch_error (не blocked=True), но
def test_parse_html_minimal_card() -> None: honest-status всё равно обязан пометить run failed при пустом результате.
"""Минимальная DomClick карточка → ScrapedLot с rooms/area/floor/price/source_id."""
# __init__ застаблен фикстурой _stub_domclick_lifecycle → нет DB/browser
scraper = DomClickScraper()
html = """
<html><body>
<a href="/card/sale__flat__2075671636">
<div>2-комн. квартира</div>
<span>52,3 м²</span>
<span>5 / 16 эт.</span>
<span>Екатеринбург, улица Ленина, 10</span>
<span>6 500 000 </span>
</a>
</body></html>
""" """
lots = scraper._parse_html(html)
assert len(lots) == 1 async def fake_fetch_errs(
lot = lots[0] 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 == "domklik"
assert lot.source_id == "2075671636" assert lot.source_id == "111222333"
assert lot.rooms == 2 assert lot.rooms == 2
assert lot.area_m2 == pytest.approx(52.3) assert lot.area_m2 == pytest.approx(52.3)
assert lot.floor == 5 assert lot.floor == 5
assert lot.total_floors == 16 assert lot.total_floors == 16
assert lot.price_rub == 6_500_000 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)