fix(tradein/domclick): рекан скрейпера на BFF JSON API + честный статус (#1846, #1968) #1979

Merged
lekss361 merged 2 commits from fix/tradein-domclick-bff-rewrite into main 2026-06-27 10:59:48 +00:00
6 changed files with 1153 additions and 516 deletions

View file

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

View file

@ -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 (<pre> или bare body).
Парсинг: _extract_json() вытаскивает первый {...} из ответа.
Оффер-карточки: `a[href*="/card/"]` с href-паттерном `/card/sale__flat__<id>`.
Координаты 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": ...}`` напрямую в <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
Стратегия: найти первый '{' и последний '}' работает для обоих вариантов.
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__<id>`.
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 оборачивает карточку в `<a href="/card/...">` вся
информация (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)

View file

@ -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).
"""

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) из текста карточки
- Парсинг цены из отдельного DOM-элемента
- Извлечение source_id из href
- Полный цикл _parse_html -> list[ScrapedLot] на mocked HTML
- _extract_json: bare + <pre>-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 в <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(
"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 = (
"<html><head><title>QRATOR protection</title></head>"
"<body>Access denied by bot_mitigation system</body></html>"
)
with pytest.raises(DomClickBlockedError):
_extract_json(blocked_html)
# ── 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(
"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("<html><body>plain text</body></html>")
# ── 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] ──────────────────────────────────────────
# Цена в каждой карточке вынесена в отдельный <span> с символом рубля,
# чтобы не сливаться с адресным номером дома при конкатенации card_text.
_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_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 = "<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:
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 = (
"<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:
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 = """
<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>
Нераспознанный 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)