diff --git a/tradein-mvp/backend/scripts/README.md b/tradein-mvp/backend/scripts/README.md index 54d88559..854a8ed3 100644 --- a/tradein-mvp/backend/scripts/README.md +++ b/tradein-mvp/backend/scripts/README.md @@ -271,3 +271,48 @@ Caveats (also printed): CURRENT listings vs PAST deals (not point-in-time — needs `listing_source_snapshots` #570); asking-median + IQR core only; ДКП = registered price. Pure metric/ratio helpers are unit-tested in `tests/test_backtest_estimator.py` (no DB). + +--- + +## `domclick_local_runner.py` — DomClick EKB вторичка с домашнего IP (offline) + +**SELF-CONTAINED** (без `app.*` импортов, stdlib + `playwright.async_api`). +Запускается ОПЕРАТОРОМ на его машине с домашнего/резидентного IP — DomClick +фронтит QRATOR, который банит datacenter/mobile-proxy, но пропускает домашний. +Пишет JSONL (НЕ в БД); заливка в trade-in БД — отдельным ingest-шагом. + +Собирает Layer A (BFF search: `/api/offers/v1` + `/api/offers/count/v1`, +room×price-бакетинг под cap offset<2000) + Layer B (detail-карточка, +`window.__SSR_STATE__`: renovation, living/kitchen, priceHistory, egrnData +owners/collateral, sale_type, views/calls, wall/floor type, AVM). Логика +скопирована инлайн из `app/services/scrapers/domclick.py` + `domclick_detail.py`. + +### Режим окна (verified live 2026-06-27 на домашнем IP) + +QRATOR ловит СТАРЫЙ headless (`launch(headless=True)`) → `403 | Домклик` даже +с домашнего IP. NEW-headless (`--headless=new`) И headed → `200` + полный +`__SSR_STATE__`. Поэтому дефолт = new-headless (окно скрыто, детект проходит); +`--headed` = видимое окно (debug). BFF-enumerate проходит в любом режиме. + +### Темп (явное требование — НЕ спалить домашний IP) + +- пауза между карточками `--min-delay` 12с / `--max-delay` 30с (`random.uniform`); +- render-wait после goto `--render-min` 6с / `--render-max` 10с; +- «человеческий перерыв» каждые 25-40 карточек → 60-180с; +- на 403 — один ретрай через 45-90с, потом `status="blocked"` (не долбит). + +Дефолтный темп ≈ 100-150 карточек/час → полный свод ЕКБ (~6400) ≈ 45-65 ч +(несколько ночей). Скрипт РЕЗЮМИРУЕМ: при старте читает JSONL и пропускает +id с терминальным статусом (ok/blocked/parse_fail). + +```bash +cd tradein-mvp/backend +# боевой прогон (дефолтный медленный темп): +python scripts/domclick_local_runner.py --out domclick_ekb.jsonl +# быстрый smoke (маленькие задержки ТОЛЬКО для теста): +python scripts/domclick_local_runner.py --limit 2 --min-delay 3 --max-delay 5 --out dc_test.jsonl +# только перечислить (без detail): +python scripts/domclick_local_runner.py --enumerate-only --out enum.jsonl +``` + +Запускать python'ом, где установлен playwright (`python -c "import playwright"`). diff --git a/tradein-mvp/backend/scripts/domclick_local_runner.py b/tradein-mvp/backend/scripts/domclick_local_runner.py new file mode 100644 index 00000000..12213417 --- /dev/null +++ b/tradein-mvp/backend/scripts/domclick_local_runner.py @@ -0,0 +1,1194 @@ +#!/usr/bin/env python3 +"""DomClick EKB вторичка — SELF-CONTAINED локальный сборщик (search + detail). + +ЗАЧЕМ ОТДЕЛЬНЫЙ СКРИПТ +===================== +DomClick фронтит QRATOR anti-bot. Проверено вживую (2026-06-27): + • запрос с ДОМАШНЕГО (резидентного) IP → отдаёт полную карточку с + ``window.__SSR_STATE__`` (renovation / priceHistory / egrnData / AVM); + • запрос с datacenter / mobile-proxy IP → 403 | Домклик (bot_mitigation). +Поэтому продовый pipeline (BrowserFetcher через shared proxy) карточки НЕ +вытягивает. Этот скрипт запускается ОПЕРАТОРОМ на его собственной машине с +домашнего IP — без прокси — и собирает данные в JSONL, который потом +заливается в trade-in БД ОТДЕЛЬНЫМ шагом (этот скрипт в БД НЕ пишет). + +САМОДОСТАТОЧНОСТЬ +================= +Никаких ``app.*`` импортов: вся логика Layer A (BFF enumerate) и Layer B +(detail ``__SSR_STATE__`` parse) скопирована ИНЛАЙН из +``app/services/scrapers/domclick.py`` + ``domclick_detail.py`` + +``repair_state_normalizer.py``. Зависимости: stdlib + ``playwright.async_api``. + +ТРАНСПОРТ +========= +Playwright Chromium, БЕЗ прокси (домашний IP): + • enumerate (BFF JSON) — ``context.request.get(url)`` (тот же сетевой + стек/IP/куки браузера) → парсим первый ``{`` … последний ``}``; + • detail — ``page.goto(card_url)`` → jittered wait → ``page.evaluate`` с + ``fetch(location.href)`` чтобы получить СЫРОЙ HTML (SPA удаляет + ``window.__SSR_STATE__`` после гидрации, поэтому нужен повторный fetch). + +ВАЖНО — РЕЖИМ ОКНА (verified 2026-06-27 на домашнем IP этой машины) +------------------------------------------------------------------ +QRATOR ДЕТЕКТИТ СТАРЫЙ HEADLESS-РЕЖИМ: ``launch(headless=True)`` → даже с +домашнего IP карточка отдаёт ``401 / 403 | Домклик`` (нет ``__SSR_STATE__``). +А вот НОВЫЙ headless (``--headless=new``) И обычный headed → ``200`` + полный +``__SSR_STATE__`` (~330 КБ). Поэтому по умолчанию запускаемся в new-headless +(окно НЕ видно, но детект проходит). ``--headed`` → видимое окно (debug). +BFF-enumerate проходит в ЛЮБОМ режиме (это не браузерный рендер). + +ВАЖНО — ЧЕЛОВЕКОПОДОБНЫЙ ТЕМП (явное требование оператора) +========================================================= +Домашний IP — расходник: спалим частотой → бан и потеря единственного +рабочего канала. Поэтому темп НАМЕРЕННО медленный и настраиваемый: + • случайная пауза МЕЖДУ карточками: ``--min-delay`` (12с) / ``--max-delay`` + (30с), ``random.uniform``; + • jittered render-wait после goto: ``--render-min`` (6с) / ``--render-max`` + (10с); + • «человеческий перерыв»: каждые 25-40 карточек → сон 60-180с; + • на 403 — ОДИН ретрай через 45-90с; всё ещё блок → status="blocked" и + идём дальше (НЕ долбим). +При дефолтных задержках темп ≈ 100-150 карточек/час. Полный свод ЕКБ-вторички +(~6400 карточек) → ≈ 45-65 часов реального времени, т.е. несколько ночей. +Скрипт РЕЗЮМИРУЕМ: при старте читает уже собранный JSONL и пропускает готовые +id (status ok/blocked/parse_fail), так что прогон можно дробить на сессии. + +ЗАПУСК +====== +Нужен python с установленным playwright (``python -c "import playwright"``). +:: + + cd .../tradein-mvp/backend + # боевой прогон (дефолтный медленный темп), дописывает в JSONL: + python scripts/domclick_local_runner.py --out domclick_ekb.jsonl + # быстрый smoke (только для теста — маленькие задержки!): + python scripts/domclick_local_runner.py --limit 2 --min-delay 3 --max-delay 5 \ + --out /tmp/dc_test.jsonl + # только перечислить карточки, без detail: + python scripts/domclick_local_runner.py --enumerate-only --out enum.jsonl + +OUTPUT +====== +JSONL, по одной записи на карточку (append). Поля см. ``build_record``. +Файл потребляется ОТДЕЛЬНЫМ ingest-шагом в trade-in БД (listings + +offer_price_history); этот скрипт сам в БД ничего не пишет. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import random +import re +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +from playwright.async_api import async_playwright + +logger = logging.getLogger("domclick_local") + +# ── API / гео константы (ground truth, verified 2026-06-27) ────────────────── + +_BFF_BASE = "https://bff-search-web.domclick.ru" +_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440" +_EKB_AREA_ID = "20561" +_CARD_HOST_FALLBACK = "https://ekaterinburg.domclick.ru" +_WARMUP_URL = "https://ekaterinburg.domclick.ru/" + +# Порядок бакетов комнат влияет на логи. urlencode("5+") → "5%2B". +ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+") + +OFFSET_CAP: int = 2000 # макс. offset, принимаемый BFF +PAGE_SIZE: int = 20 # items на страницу (жёстко задан API) +LTE_MAX: int = 1_000_000_000 # потолок цены для binary-split unbounded бакета +MIN_PRICE_SPAN: int = 100_000 # ниже этого span деление прекращаем +MAX_SPLIT_DEPTH: int = 30 # guard рекурсии binary-split (log2(1e9/1e5) ≈ 13) + +# Гео-guard ЕКБ (bbox). aids=20561 уже даёт чистый ЕКБ; bbox — defensive. +# Deviation от app: ослаблено строгое равенство offerRegionName до bbox-only +# (устойчивее к дрейфу имени региона; aids уже ограничивает выборку ЕКБ). +_EKB_LAT_MIN, _EKB_LAT_MAX = 56.6, 57.0 +_EKB_LON_MIN, _EKB_LON_MAX = 60.2, 60.9 + +# Маркеры QRATOR / anti-bot challenge. +_BLOCK_MARKERS: tuple[str, ...] = ( + "qrator", + "bot_mitigation", + "система защиты", + "403 | домклик", + "captcha", + "datadome", + "access denied", +) + +# Реалистичный desktop-Chrome UA (headless-UA палится anti-bot'ами). +_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +) + +# SSR-стейт встроен как JS-литерал (НЕ чистый JSON: bare `undefined`). +_SSR_ASSIGN_RE = re.compile(r"window\.__SSR_STATE__\s*=\s*") +_UNDEFINED_RE = re.compile(r"(?<=[:\[,\s])undefined(?=[\s,\]\}])") +_ITEM_ID_RE = re.compile(r"sale__flat__(\d+)") + +# JS для повторного fetch сырого HTML карточки из контекста страницы. +_FETCH_RAW_JS = ( + "async () => { const r = await fetch(window.location.href, " + "{credentials: 'include'}); return await r.text(); }" +) + + +# ── Исключения ─────────────────────────────────────────────────────────────── + + +class DomClickBlockedError(Exception): + """Anti-bot challenge (QRATOR/DataDome/403) — НЕ parse-failure.""" + + +class DomClickParseError(Exception): + """HTTP 200, но __SSR_STATE__ не найден / не парсится (дрейф схемы).""" + + +class _EnumLimitReachedError(Exception): + """Внутренний сигнал: набрали target новых карточек — стоп enumerate.""" + + +# ── repair_state normalizer (инлайн из repair_state_normalizer.py) ─────────── + +_RAW_TO_ENUM: dict[str, str] = { + "required": "needs_repair", + "without": "needs_repair", + "no": "needs_repair", + "rough": "needs_repair", + "prefine": "needs_repair", + "cosmetic": "standard", + "euro": "good", + "fine": "good", + "designer": "excellent", + "design": "excellent", +} +_VALID_ENUM: frozenset[str] = frozenset({"needs_repair", "standard", "good", "excellent"}) + +# renovation (RU) → каноничный enum (локальная карта Layer B). +# Расширено относительно app-версии РЕАЛЬНЫМИ короткими токенами DomClick, +# наблюдёнными вживую 2026-06-27 ("Евро", "Косметический"): SSR-поле renovation +# отдаёт КОРОТКУЮ форму ("Евро"), а не "евроремонт" — иначе good терялся в None. +_RENOVATION_MAP: dict[str, str] = { + "евроремонт": "good", + "евро": "good", + "косметический": "standard", + "косметический ремонт": "standard", + "дизайнерский": "excellent", + "дизайнерский ремонт": "excellent", + "черновая": "needs_repair", + "черновая отделка": "needs_repair", + "без отделки": "needs_repair", + "без ремонта": "needs_repair", + "требует ремонта": "needs_repair", + "предчистовая": "needs_repair", + "предчистовая отделка": "needs_repair", +} + +_TEXT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile( + r"дизайнерск(?:ий|ого|ом|ая)\s+ремонт" + r"|дизайнерск(?:ая|ой)\s+отделк" + r"|премиальн\w*\s+ремонт" + r"|эксклюзивн\w*\s+ремонт" + r"|авторск\w*\s+ремонт", + re.IGNORECASE, + ), + "excellent", + ), + ( + re.compile( + r"евроремонт" + r"|евро[\s-]?ремонт" + r"|отличн\w*\s+ремонт" + r"|свеж\w*\s+ремонт" + r"|современн\w*\s+ремонт" + r"|качественн\w*\s+ремонт" + r"|ремонт\s+в\s+отличн", + re.IGNORECASE, + ), + "good", + ), + ( + re.compile( + r"без\s+отделк" + r"|без\s+ремонт" + r"|черновая\s+отделк" + r"|черновой\s+ремонт" + r"|требует(?:ся)?\s+ремонт" + r"|требует(?:ся)?\s+космет" + r"|нужен\s+ремонт" + r"|под\s+ремонт" + r"|под\s+чистов\w+\s+отделк" + r"|предчистов", + re.IGNORECASE, + ), + "needs_repair", + ), + ( + re.compile( + r"косметическ\w*\s+ремонт" + r"|косметическ\w*\s+отделк" + r"|обычн\w*\s+ремонт" + r"|жил(?:ое|ом)\s+состоян" + r"|хорош\w*\s+состоян", + re.IGNORECASE, + ), + "standard", + ), +) + + +def normalize_repair_state(raw: str | None) -> str | None: + """raw repair → каноничный enum (идемпотентно). Неизвестное → None.""" + if raw is None: + return None + if raw in _VALID_ENUM: + return raw + result = _RAW_TO_ENUM.get(raw) or _RAW_TO_ENUM.get(raw.lower()) + if result is None: + logger.debug("repair_state: unknown raw %r — stored NULL", raw) + return result + + +def infer_repair_state_from_text(description: str | None) -> str | None: + """repair_state из текста описания (fallback). Первое совпадение по силе.""" + if not description: + return None + for pattern, enum_value in _TEXT_PATTERNS: + if pattern.search(description): + return enum_value + return None + + +def _map_repair_state(renovation: str | None) -> str | None: + """RU renovation → enum; нет совпадения в карте → инференс из текста.""" + if not renovation: + return None + mapped = _RENOVATION_MAP.get(renovation.strip().lower()) + if mapped is not None: + return normalize_repair_state(mapped) + return infer_repair_state_from_text(renovation) + + +# ── value coercion (инлайн из domclick_detail.py) ──────────────────────────── + + +def _to_float(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (ValueError, TypeError): + return None + + +def _to_int(value: Any) -> int | None: + if value is None or isinstance(value, bool): + return None + try: + return int(value) + except (ValueError, TypeError): + return None + + +def _pos_float(value: Any) -> float | None: + """float > 0, иначе None. DomClick эмитит 0 как sentinel «неизвестно» + для living/kitchen area (особенно у студий) — 0.0 ввело бы в заблуждение.""" + f = _to_float(value) + if f is None or f <= 0: + return None + return f + + +def _parse_change_time(value: Any) -> datetime | None: + """date изменения цены → tz-aware datetime. epoch(сек/мс)/ISO8601/num-str.""" + if value is None or isinstance(value, bool): + return None + if isinstance(value, int | float): + ts = float(value) + if ts > 1e12: + ts /= 1000.0 + try: + return datetime.fromtimestamp(ts, tz=UTC) + except (ValueError, OSError, OverflowError): + return None + s = str(value).strip() + if not s: + return None + if s.isdigit(): + ts = float(s) + if ts > 1e12: + ts /= 1000.0 + try: + return datetime.fromtimestamp(ts, tz=UTC) + except (ValueError, OSError, OverflowError): + return None + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + except (ValueError, TypeError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt + + +def _compact(d: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in d.items() if v is not None} + + +def _pick(d: dict[str, Any], *keys: str) -> Any: + for k in keys: + v = d.get(k) + if v is not None: + return v + return None + + +def _extract_item_id(url: str) -> str | None: + m = _ITEM_ID_RE.search(url or "") + return m.group(1) if m else None + + +# ── BFF JSON extraction (инлайн из domclick.py) ────────────────────────────── + + +def _is_block_text(text: str) -> bool: + low = text.lower() + return any(m in low for m in _BLOCK_MARKERS) + + +def _extract_json(body: str) -> dict[str, Any]: + """Первый ``{`` … последний ``}`` из ответа BFF (raw JSON или
-wrap).
+
+    Raises:
+        DomClickBlockedError: QRATOR/captcha маркеры в теле.
+        ValueError: JSON не найден / не dict.
+    """
+    if _is_block_text(body):
+        raise DomClickBlockedError("BFF: QRATOR block page detected")
+    start = body.find("{")
+    end = body.rfind("}")
+    if start == -1 or end == -1 or end <= start:
+        raise ValueError(f"No JSON object in BFF response (len={len(body)})")
+    data = json.loads(body[start : end + 1])
+    if not isinstance(data, dict):
+        raise ValueError(f"BFF JSON is not a dict: {type(data)}")
+    return data
+
+
+def _build_offers_url(rooms: str, gte: int | None, lte: int | None, offset: int) -> str:
+    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 gte is not None:
+        params.append(("sale_price__gte", str(gte)))
+    if lte is not None:
+        params.append(("sale_price__lte", str(lte)))
+    return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"
+
+
+def _build_count_url(rooms: str, gte: int | None, lte: int | None) -> str:
+    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 gte is not None:
+        params.append(("sale_price__gte", str(gte)))
+    if lte is not None:
+        params.append(("sale_price__lte", str(lte)))
+    return f"{_BFF_BASE}/api/offers/count/v1?{urlencode(params)}"
+
+
+def _is_geo_ok(item: dict[str, Any]) -> bool:
+    """bbox-guard ЕКБ. lat/lon обязательны и в пределах bbox."""
+    loc = item.get("location") or {}
+    lat = loc.get("lat")
+    lon = loc.get("lon")
+    if lat is None or lon is None:
+        return False
+    try:
+        latf, lonf = float(lat), float(lon)
+    except (ValueError, TypeError):
+        return False
+    return _EKB_LAT_MIN <= latf <= _EKB_LAT_MAX and _EKB_LON_MIN <= lonf <= _EKB_LON_MAX
+
+
+# ── SERP item → плоский dict (инлайн из domclick.py _map_item) ────────────────
+
+
+def map_serp_item(item: dict[str, Any], force_rooms: int | None) -> dict[str, Any] | None:
+    """offer-item из BFF → SERP-dict. None при price<=0 / без id / ошибке."""
+    try:
+        price = item.get("price") or 0
+        if price <= 0:
+            return None
+        item_id = item.get("id")
+        if item_id is None:
+            return None
+        sid = str(item_id)
+
+        path = item.get("path") or ""
+        if path.startswith("/"):
+            path = _CARD_HOST_FALLBACK + path
+
+        loc = item.get("location") or {}
+        oi = item.get("objectInfo") or {}
+        house = item.get("house") or {}
+        addr = item.get("address") or {}
+
+        area_raw = oi.get("area")
+        floor_raw = oi.get("floor")
+        floors_raw = house.get("floors")
+        year_raw = house.get("buildYear")
+        sq_raw = item.get("squarePrice")
+
+        if force_rooms is not None:
+            rooms: int | None = force_rooms
+        else:
+            rr = oi.get("rooms")
+            rooms = int(rr) if rr is not None else None
+
+        return {
+            "id": sid,
+            "source_url": path,
+            "price_rub": int(price),
+            "square_price": int(sq_raw) if sq_raw else None,
+            "area_m2": float(area_raw) if area_raw else None,
+            "rooms": rooms,
+            "floor": int(floor_raw) if floor_raw else None,
+            "total_floors": int(floors_raw) if floors_raw else None,
+            "year_built": int(year_raw) if year_raw else None,
+            "lat": loc.get("lat"),
+            "lon": loc.get("lon"),
+            "address": addr.get("displayName"),
+            "is_apartment": oi.get("isApartment"),
+            "offer_region_name": item.get("offerRegionName"),
+            "description": item.get("description"),
+            "is_sber_collateral": item.get("isSberCollateral"),
+            "published_date": item.get("publishedDate"),
+        }
+    except Exception:
+        logger.warning("map_serp_item failed id=%r", item.get("id"), exc_info=True)
+        return None
+
+
+# ── SSR state extraction (инлайн из domclick_detail.py) ──────────────────────
+
+
+def _scan_balanced_object(s: str, start: int) -> str:
+    """От '{' по индексу start до парной '}', игнорируя скобки внутри строк."""
+    depth = 0
+    in_string = False
+    quote_char = ""
+    escaped = False
+    for i in range(start, len(s)):
+        ch = s[i]
+        if in_string:
+            if escaped:
+                escaped = False
+            elif ch == "\\":
+                escaped = True
+            elif ch == quote_char:
+                in_string = False
+            continue
+        if ch in ('"', "'"):
+            in_string = True
+            quote_char = ch
+        elif ch == "{":
+            depth += 1
+        elif ch == "}":
+            depth -= 1
+            if depth == 0:
+                return s[start : i + 1]
+    raise DomClickParseError("__SSR_STATE__ object not balanced (no matching brace)")
+
+
+def _extract_ssr_state(html: str) -> dict[str, Any]:
+    """window.__SSR_STATE__ object-литерал из HTML карточки → dict.
+
+    Raises:
+        DomClickBlockedError: нет стейта + anti-bot маркеры.
+        DomClickParseError: стейт не найден / не сбалансирован / json.loads упал.
+    """
+    match = _SSR_ASSIGN_RE.search(html)
+    if match is None:
+        if _is_block_text(html):
+            raise DomClickBlockedError("detail: challenge page (no __SSR_STATE__)")
+        raise DomClickParseError("__SSR_STATE__ not found")
+    brace_start = html.find("{", match.end())
+    if brace_start == -1:
+        raise DomClickParseError("__SSR_STATE__ assignment has no opening '{'")
+    literal = _scan_balanced_object(html, brace_start)
+    sanitized = _UNDEFINED_RE.sub("null", literal)
+    try:
+        data = json.loads(sanitized)
+    except json.JSONDecodeError as exc:
+        raise DomClickParseError(
+            f"__SSR_STATE__ json.loads failed: {exc} (head={sanitized[:160]!r})"
+        ) from exc
+    if not isinstance(data, dict):
+        raise DomClickParseError(f"__SSR_STATE__ is not an object: {type(data)}")
+    return data
+
+
+def _extract_owners_count(egrn: dict[str, Any]) -> int | None:
+    """egrnData.owners_count. Ground truth: {status, value}; fallback на скаляр."""
+    oc = egrn.get("owners_count")
+    if oc is None:
+        oc = egrn.get("ownersCount")
+    if isinstance(oc, dict):
+        return _to_int(oc.get("value"))
+    return _to_int(oc)
+
+
+def parse_detail_html(html: str, source_url: str) -> dict[str, Any]:
+    """Парсер detail-карточки → dict обогащения. _extract_ssr_state может бросить
+    Blocked/ParseError (propagate); навигация по полям обёрнута → минимум при сюрпризе.
+    """
+    state = _extract_ssr_state(html)
+    try:
+        pc = state.get("productCard") or {}
+        oi = pc.get("objectInfo") or {}
+        hi = (state.get("houseInfo") or {}).get("info") or {}
+        egrn = pc.get("egrnData") or {}
+
+        renovation = oi.get("renovation")
+        repair_raw = renovation if isinstance(renovation, str) else None
+        repair_state = _map_repair_state(repair_raw)
+
+        living = _pos_float(oi.get("livingArea"))
+        kitchen = _pos_float(oi.get("kitchenArea"))
+
+        sale_type = (pc.get("legalOptions") or {}).get("saleType")
+        owners_count = _extract_owners_count(egrn)
+
+        collateral = egrn.get("collateral")
+        collateral_sber = egrn.get("collateral_sber")
+        if egrn:
+            encumbrances_clean: bool | None = not bool(collateral or collateral_sber)
+        else:
+            encumbrances_clean = None
+
+        wall_type = hi.get("wallType") or (pc.get("house") or {}).get("wallType")
+        floor_type = hi.get("floorType")
+        year_built = _to_int(hi.get("buildYear"))
+        views = _to_int(pc.get("viewsCount"))
+        calls = _to_int(pc.get("callsCount"))
+
+        # priceHistory → нормализованные записи (skip при битой дате/без цены).
+        raw_history = (pc.get("priceInfo") or {}).get("priceHistory") or []
+        price_changes: list[dict[str, Any]] = []
+        if isinstance(raw_history, list):
+            for entry in raw_history:
+                if not isinstance(entry, dict):
+                    continue
+                ct = _parse_change_time(entry.get("date"))
+                price = _to_int(entry.get("price"))
+                if ct is None or price is None:
+                    continue
+                diff = entry.get("diff")
+                diff_pct = (
+                    diff if isinstance(diff, int | float) and not isinstance(diff, bool) else None
+                )
+                price_changes.append(
+                    {
+                        "change_time": ct.isoformat(),
+                        "price_rub": price,
+                        "diff_percent": diff_pct,
+                        "state": entry.get("state"),
+                    }
+                )
+
+        # AVM — top-level pricePrediction (часто пусто); defensively разворачиваем.
+        avm_root = state.get("pricePrediction") or {}
+        avm_src: Any = avm_root
+        if isinstance(avm_root, dict):
+            avm_src = avm_root.get("result") or avm_root.get("data") or avm_root
+        if not isinstance(avm_src, dict):
+            avm_src = {}
+        avm = _compact(
+            {
+                "market_price": _pick(avm_src, "market_price", "marketPrice"),
+                "min": _pick(avm_src, "min_market_price", "minMarketPrice"),
+                "max": _pick(avm_src, "max_market_price", "maxMarketPrice"),
+                "repair_quality": _pick(avm_src, "repair_quality", "repairQuality"),
+            }
+        )
+
+        demand = _compact(
+            {
+                "calls": calls,
+                "favorites": pc.get("favoriteOfferUsersCount"),
+                "duplicates": pc.get("duplicatesOfferCount"),
+            }
+        )
+        raw_extra = _compact(
+            {
+                "wall_type": wall_type,
+                "floor_type": floor_type,
+                "entrance_count": hi.get("entranceCount"),
+                "quarters_count": hi.get("quartersCount"),
+                "energy_efficiency": hi.get("energyEfficiency"),
+                "building_series": hi.get("buildingSeries"),
+                "domclick_building_guid": (pc.get("address") or {}).get("guid"),
+                "demand": demand or None,
+                "collateral": collateral,
+                "collateral_sber": collateral_sber,
+            }
+        )
+
+        return {
+            "repair_state": repair_state,
+            "repair_raw": repair_raw,
+            "living_area_m2": living,
+            "kitchen_area_m2": kitchen,
+            "sale_type": sale_type if isinstance(sale_type, str) else None,
+            "owners_count": owners_count,
+            "encumbrances_clean": encumbrances_clean,
+            "wall_type": wall_type,
+            "floor_type": floor_type,
+            "views": views,
+            "calls": calls,
+            "price_changes": price_changes,
+            "collateral_sber": bool(collateral_sber) if collateral_sber is not None else None,
+            "year_built": year_built,
+            "avm": avm or None,
+            "raw_extra": raw_extra,
+        }
+    except (DomClickBlockedError, DomClickParseError):
+        raise
+    except Exception:
+        logger.warning("detail field navigation failed for %s — minimal", source_url, exc_info=True)
+        return {}
+
+
+# ── Output record ────────────────────────────────────────────────────────────
+
+
+def build_record(
+    serp: dict[str, Any], detail: dict[str, Any] | None, status: str
+) -> dict[str, Any]:
+    """SERP + detail → итоговая JSONL-запись."""
+    d = detail or {}
+    repair_state = d.get("repair_state")
+    if not repair_state:
+        # fallback-инференс из SERP-описания (как в проде для пустого структурного)
+        repair_state = infer_repair_state_from_text(serp.get("description"))
+
+    is_sber = serp.get("is_sber_collateral")
+    if not is_sber and d.get("collateral_sber"):
+        is_sber = True
+
+    return {
+        "id": serp.get("id"),
+        "source_url": serp.get("source_url"),
+        "status": status,
+        "fetched_at": datetime.now(UTC).isoformat(),
+        "price_rub": serp.get("price_rub"),
+        "area_m2": serp.get("area_m2"),
+        "rooms": serp.get("rooms"),
+        "floor": serp.get("floor"),
+        "total_floors": serp.get("total_floors"),
+        "year_built": d.get("year_built") or serp.get("year_built"),
+        "lat": serp.get("lat"),
+        "lon": serp.get("lon"),
+        "address": serp.get("address"),
+        "repair_state": repair_state,
+        "repair_raw": d.get("repair_raw"),
+        "living_area_m2": d.get("living_area_m2"),
+        "kitchen_area_m2": d.get("kitchen_area_m2"),
+        "sale_type": d.get("sale_type"),
+        "owners_count": d.get("owners_count"),
+        "encumbrances_clean": d.get("encumbrances_clean"),
+        "wall_type": d.get("wall_type"),
+        "floor_type": d.get("floor_type"),
+        "views": d.get("views"),
+        "calls": d.get("calls"),
+        "price_changes": d.get("price_changes") or [],
+        "is_sber_collateral": is_sber,
+        "avm": d.get("avm") or {},
+        "raw_extra": {
+            "square_price": serp.get("square_price"),
+            "is_apartment": serp.get("is_apartment"),
+            "offer_region_name": serp.get("offer_region_name"),
+            "published_date": serp.get("published_date"),
+            **(d.get("raw_extra") or {}),
+        },
+    }
+
+
+# ── Resume / output IO ───────────────────────────────────────────────────────
+
+_TERMINAL_STATUSES = frozenset({"ok", "blocked", "parse_fail"})
+
+
+def load_done_ids(path: Path) -> set[str]:
+    """id с терминальным detail-статусом из существующего JSONL (для resume)."""
+    done: set[str] = set()
+    if not path.exists():
+        return done
+    with path.open("r", encoding="utf-8") as fh:
+        for line in fh:
+            line = line.strip()
+            if not line:
+                continue
+            try:
+                rec = json.loads(line)
+            except json.JSONDecodeError:
+                continue
+            rid = rec.get("id")
+            if rid and rec.get("status") in _TERMINAL_STATUSES:
+                done.add(str(rid))
+    return done
+
+
+def append_record(path: Path, rec: dict[str, Any]) -> None:
+    with path.open("a", encoding="utf-8") as fh:
+        fh.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
+        fh.flush()
+
+
+# ── BFF transport (playwright APIRequestContext) ─────────────────────────────
+
+
+async def fetch_bff_json(context: Any, url: str) -> dict[str, Any]:
+    """GET BFF через context.request (тот же IP/куки браузера) → dict."""
+    resp = await context.request.get(
+        url,
+        headers={
+            "Accept": "application/json, text/plain, */*",
+            "Referer": "https://ekaterinburg.domclick.ru/search?deal_type=sale",
+            "Accept-Language": "ru-RU,ru;q=0.9",
+        },
+        timeout=60000,
+    )
+    body = await resp.text()
+    if resp.status == 403:
+        raise DomClickBlockedError(f"BFF 403 for {url}")
+    return _extract_json(body)
+
+
+async def get_count(context: Any, rooms: str, gte: int | None, lte: int | None) -> int:
+    data = await fetch_bff_json(context, _build_count_url(rooms, gte, lte))
+    res = data.get("result") or {}
+    raw = res.get("snippetsCount")
+    return int(raw) if raw else 0
+
+
+# ── Enumerate (mirror domclick.py _sweep_bucket / _paginate) ─────────────────
+
+
+async def _paginate(
+    context: Any,
+    rooms: str,
+    gte: int | None,
+    lte: int | None,
+    seen: set[str],
+    done: set[str],
+    out: list[dict[str, Any]],
+    limit_pages: int,
+    target_new: int | None,
+    enum_jitter: tuple[float, float],
+) -> None:
+    force_rooms = 0 if rooms == "st" else None
+    page_idx = 0
+    while page_idx < limit_pages:
+        offset = page_idx * PAGE_SIZE
+        if offset >= OFFSET_CAP:
+            break
+        data = await fetch_bff_json(context, _build_offers_url(rooms, gte, lte, offset))
+        items = (data.get("result") or {}).get("items") or []
+        if not items:
+            break
+        for item in items:
+            serp = map_serp_item(item, force_rooms)
+            if serp is None:
+                continue
+            if not _is_geo_ok(item):
+                continue
+            sid = serp["id"]
+            if sid in seen or sid in done:
+                continue
+            seen.add(sid)
+            out.append(serp)
+            if target_new is not None and len(out) >= target_new:
+                raise _EnumLimitReachedError
+        page_idx += 1
+        if len(items) < PAGE_SIZE:
+            break
+        await asyncio.sleep(random.uniform(*enum_jitter))
+
+
+async def _sweep_bucket(
+    context: Any,
+    rooms: str,
+    gte: int | None,
+    lte: int | None,
+    seen: set[str],
+    done: set[str],
+    out: list[dict[str, Any]],
+    limit_pages: int,
+    target_new: int | None,
+    enum_jitter: tuple[float, float],
+    depth: int,
+) -> None:
+    count = await get_count(context, rooms, gte, lte)
+    if count == 0:
+        return
+    if count > OFFSET_CAP and depth < MAX_SPLIT_DEPTH:
+        _gte = gte if gte is not None else 0
+        _lte = lte if lte is not None else LTE_MAX
+        span = _lte - _gte
+        if span <= MIN_PRICE_SPAN:
+            logger.warning(
+                "rooms=%r price=[%s,%s] count=%d > cap but span<=%d — paginating as-is",
+                rooms,
+                gte,
+                lte,
+                count,
+                MIN_PRICE_SPAN,
+            )
+            await _paginate(
+                context, rooms, gte, lte, seen, done, out, limit_pages, target_new, enum_jitter
+            )
+            return
+        mid = (_gte + _lte) // 2
+        logger.info(
+            "rooms=%r count=%d > cap — split [%d,%d]+[%d,%d]",
+            rooms,
+            count,
+            _gte,
+            mid,
+            mid + 1,
+            _lte,
+        )
+        await _sweep_bucket(
+            context,
+            rooms,
+            _gte,
+            mid,
+            seen,
+            done,
+            out,
+            limit_pages,
+            target_new,
+            enum_jitter,
+            depth + 1,
+        )
+        await _sweep_bucket(
+            context,
+            rooms,
+            mid + 1,
+            _lte,
+            seen,
+            done,
+            out,
+            limit_pages,
+            target_new,
+            enum_jitter,
+            depth + 1,
+        )
+    else:
+        await _paginate(
+            context, rooms, gte, lte, seen, done, out, limit_pages, target_new, enum_jitter
+        )
+
+
+async def enumerate_cards(
+    context: Any,
+    done: set[str],
+    limit_pages: int,
+    target_new: int | None,
+    enum_jitter: tuple[float, float],
+) -> list[dict[str, Any]]:
+    """Свод ЕКБ-вторички через BFF: все room-бакеты, dedup, skip done."""
+    out: list[dict[str, Any]] = []
+    seen: set[str] = set()
+    try:
+        for bucket in ROOM_BUCKETS:
+            logger.info("enumerate: bucket rooms=%r (collected=%d)", bucket, len(out))
+            await _sweep_bucket(
+                context,
+                bucket,
+                None,
+                None,
+                seen,
+                done,
+                out,
+                limit_pages,
+                target_new,
+                enum_jitter,
+                0,
+            )
+    except _EnumLimitReachedError:
+        logger.info("enumerate: reached target=%d new cards — stop sweep", target_new)
+    except DomClickBlockedError as exc:
+        logger.error(
+            "enumerate: QRATOR block (%s) — aborting sweep, keeping %d collected", exc, len(out)
+        )
+    logger.info("enumerate: done, %d new cards collected", len(out))
+    return out
+
+
+# ── Detail transport ─────────────────────────────────────────────────────────
+
+
+async def fetch_card_html(
+    page: Any, url: str, render_wait: tuple[float, float]
+) -> tuple[str, str, int]:
+    """goto → jittered render-wait → re-fetch сырого HTML. (html, title, status)."""
+    resp = await page.goto(url, wait_until="domcontentloaded", timeout=70000)
+    await asyncio.sleep(random.uniform(*render_wait))
+    title = ""
+    try:
+        title = (await page.title()) or ""
+    except Exception:
+        title = ""
+    html = await page.evaluate(_FETCH_RAW_JS)
+    status = resp.status if resp else 0
+    return html, title, status
+
+
+async def process_card(
+    page: Any,
+    serp: dict[str, Any],
+    render_wait: tuple[float, float],
+) -> tuple[str, dict[str, Any] | None]:
+    """Один detail-fetch с одним ретраем на блок. → (status, detail|None)."""
+    url = serp["source_url"]
+    for attempt in (1, 2):
+        try:
+            html, title, http_status = await fetch_card_html(page, url, render_wait)
+            if http_status == 403 or _is_block_text(title) or _is_block_text(html[:4096]):
+                raise DomClickBlockedError(f"403/challenge title={title!r}")
+            detail = parse_detail_html(html, url)
+            return "ok", detail
+        except DomClickBlockedError as exc:
+            if attempt == 1:
+                wait = random.uniform(45, 90)
+                logger.warning(
+                    "card %s blocked (%s) — retry once after %.0fs", serp["id"], exc, wait
+                )
+                await asyncio.sleep(wait)
+                continue
+            logger.warning("card %s still blocked after retry — status=blocked", serp["id"])
+            return "blocked", None
+        except DomClickParseError as exc:
+            logger.warning("card %s parse_fail: %s", serp["id"], exc)
+            return "parse_fail", None
+        except Exception as exc:
+            if attempt == 1:
+                logger.warning("card %s fetch error (%s) — retry once", serp["id"], exc)
+                await asyncio.sleep(random.uniform(8, 15))
+                continue
+            logger.warning("card %s fetch error after retry — parse_fail", serp["id"])
+            return "parse_fail", None
+    return "parse_fail", None
+
+
+# ── Orchestration ────────────────────────────────────────────────────────────
+
+
+class Summary:
+    """Счётчики прогона (для финального лога + KeyboardInterrupt flush)."""
+
+    def __init__(self) -> None:
+        self.enumerated = 0
+        self.ok = 0
+        self.blocked = 0
+        self.parse_fail = 0
+        self.skipped_done = 0
+        self.started = datetime.now(UTC)
+
+    def log_final(self) -> None:
+        elapsed = (datetime.now(UTC) - self.started).total_seconds()
+        logger.info(
+            "SUMMARY enumerated=%d ok=%d blocked=%d parse_fail=%d skipped_done=%d elapsed=%.0fs",
+            self.enumerated,
+            self.ok,
+            self.blocked,
+            self.parse_fail,
+            self.skipped_done,
+            elapsed,
+        )
+
+
+async def run(args: argparse.Namespace) -> None:
+    out_path = Path(args.out)
+    out_path.parent.mkdir(parents=True, exist_ok=True)
+
+    done = load_done_ids(out_path)
+    logger.info("resume: %d ids already done in %s", len(done), out_path)
+
+    summary = Summary()
+    render_wait = (args.render_min, args.render_max)
+    target_new = args.limit if args.limit and args.limit > 0 else None
+    enum_jitter = (1.5, 4.0)
+
+    # ВАЖНО: launch(headless=True) = СТАРЫЙ headless → QRATOR 403 даже с дом. IP.
+    # Запускаемся с headless=False, но добавляем --headless=new (новый headless,
+    # окно не видно, детект проходит). --headed → без флага = видимое окно.
+    launch_args = ["--disable-blink-features=AutomationControlled"]
+    if not args.headed:
+        launch_args.append("--headless=new")
+
+    async with async_playwright() as pw:
+        browser = await pw.chromium.launch(headless=False, args=launch_args)
+        context = await browser.new_context(
+            user_agent=_USER_AGENT,
+            locale="ru-RU",
+            timezone_id="Asia/Yekaterinburg",
+            viewport={"width": 1366, "height": 768},
+            extra_http_headers={"Accept-Language": "ru-RU,ru;q=0.9"},
+        )
+        page = await context.new_page()
+
+        # Warm-up: устанавливаем QRATOR-куки .domclick.ru перед BFF-запросами.
+        try:
+            await page.goto(_WARMUP_URL, wait_until="domcontentloaded", timeout=70000)
+            await asyncio.sleep(random.uniform(2.0, 4.0))
+            logger.info("warmup: loaded %s", _WARMUP_URL)
+        except Exception as exc:
+            logger.warning("warmup navigation failed (%s) — continuing", exc)
+
+        # 1. Enumerate.
+        cards = await enumerate_cards(context, done, args.limit_pages, target_new, enum_jitter)
+        summary.enumerated = len(cards)
+
+        if args.enumerate_only:
+            logger.info("enumerate-only: writing %d SERP records (status=enumerated)", len(cards))
+            for serp in cards:
+                append_record(out_path, build_record(serp, None, "enumerated"))
+            summary.log_final()
+            await context.close()
+            await browser.close()
+            return
+
+        # 2. Detail.
+        next_break_at = random.randint(25, 40)
+        processed_since_break = 0
+        try:
+            for idx, serp in enumerate(cards, start=1):
+                if args.limit and (summary.ok + summary.blocked + summary.parse_fail) >= args.limit:
+                    break
+                sid = serp["id"]
+                if sid in done:
+                    summary.skipped_done += 1
+                    continue
+
+                status, detail = await process_card(page, serp, render_wait)
+                rec = build_record(serp, detail, status)
+                append_record(out_path, rec)
+                done.add(sid)
+
+                if status == "ok":
+                    summary.ok += 1
+                elif status == "blocked":
+                    summary.blocked += 1
+                else:
+                    summary.parse_fail += 1
+
+                logger.info(
+                    "[%d/%d] id=%s status=%s repair=%s living=%s owners=%s price_changes=%d "
+                    "| totals ok=%d blocked=%d fail=%d",
+                    idx,
+                    len(cards),
+                    sid,
+                    status,
+                    rec.get("repair_state"),
+                    rec.get("living_area_m2"),
+                    rec.get("owners_count"),
+                    len(rec.get("price_changes") or []),
+                    summary.ok,
+                    summary.blocked,
+                    summary.parse_fail,
+                )
+
+                processed_since_break += 1
+                if processed_since_break >= next_break_at:
+                    brk = random.uniform(60, 180)
+                    logger.info(
+                        "human break: sleeping %.0fs after %d cards", brk, processed_since_break
+                    )
+                    await asyncio.sleep(brk)
+                    processed_since_break = 0
+                    next_break_at = random.randint(25, 40)
+
+                # Пауза между карточками (главный темп-контроль).
+                await asyncio.sleep(random.uniform(args.min_delay, args.max_delay))
+        except (KeyboardInterrupt, asyncio.CancelledError):
+            logger.warning("interrupted — flushing summary and exiting gracefully")
+        finally:
+            summary.log_final()
+            await context.close()
+            await browser.close()
+
+
+def build_arg_parser() -> argparse.ArgumentParser:
+    p = argparse.ArgumentParser(
+        description="DomClick EKB вторичка local runner (home IP, human-paced).",
+    )
+    p.add_argument("--out", default="domclick_ekb.jsonl", help="JSONL output (append, resumable)")
+    p.add_argument("--limit", type=int, default=None, help="cap total cards (для теста)")
+    p.add_argument("--min-delay", type=float, default=12.0, help="мин. пауза между карточками, с")
+    p.add_argument("--max-delay", type=float, default=30.0, help="макс. пауза между карточками, с")
+    p.add_argument("--render-min", type=float, default=6.0, help="мин. render-wait после goto, с")
+    p.add_argument("--render-max", type=float, default=10.0, help="макс. render-wait после goto, с")
+    p.add_argument("--limit-pages", type=int, default=100, help="макс. страниц на room-бакет")
+    p.add_argument("--enumerate-only", action="store_true", help="только перечислить, без detail")
+    p.add_argument(
+        "--headed",
+        action="store_true",
+        help="видимое окно браузера (debug); по умолчанию new-headless (окно скрыто)",
+    )
+    return p
+
+
+def main() -> None:
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s %(levelname)s %(message)s",
+        datefmt="%H:%M:%S",
+    )
+    args = build_arg_parser().parse_args()
+    if args.max_delay < args.min_delay:
+        args.max_delay = args.min_delay
+    if args.render_max < args.render_min:
+        args.render_max = args.render_min
+
+    # Graceful Ctrl+C: asyncio.run превратит KeyboardInterrupt в CancelledError
+    # внутри run(); финальный flush — в finally.
+    try:
+        asyncio.run(run(args))
+    except KeyboardInterrupt:
+        logger.warning("KeyboardInterrupt at top level — exiting")
+
+
+if __name__ == "__main__":
+    main()