From 43b392b8092bb6ed7a33fd0e469a12cc82fd0f40 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 28 Jun 2026 10:26:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/domclick):=20local-runner=20+=20in?= =?UTF-8?q?gest=20tooling=20=E2=86=92=20main=20(split=20from=20parked=20La?= =?UTF-8?q?yer-B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Выделяет МЕРДЖАБЕЛЬНУЮ часть DomClick-тулинга из запаркованного PR #2005 (BLOCKED #2000 — прод Layer-B скрейпер через прокси режет QRATOR-403): - scripts/domclick_local_runner.py — ручной offline-сборщик с домашнего IP: SERP-enumerate (фронт, BFF-JSON режет QRATOR) + card __SSR_STATE__ + offer-card v3 (AVM price_prediction + sold_similar). --enum-cache (enumerate 1 раз → файл с sentinel → следующий запуск грузит кэш и пропускает enumerate), wait_for_selector (SERP-карточки догидрируются), resume по id. Self-contained. - scripts/ingest_domclick_jsonl.py — JSONL → trade-in БД (save_listings + house-match в SAVEPOINT + save_detail_enrichment, идемпотентно, --limit/--dry-run). - app/services/scrapers/domclick_detail.py + domclick_exceptions.py — card-SSR parse + save_detail_enrichment (нужны ингесту). Импортятся ТОЛЬКО ингестом (operator-run); прод-runtime их не дёргает (dormant в образе). - tests/scrapers/test_domclick_detail.py + обновлённый scripts/README.md. НЕ включает прод Layer-B (tasks/domclick_detail_backfill.py, scheduler-расписание, data/sql/139_*) — остаётся запаркован на PR #2005 до чистого прокси (#2000). БД-схема не меняется (detail-колонки уже в проде, миграции нет). Live-validated: раннер качает ЕКБ-вторичку прямо сейчас (600+ карт, 0 блоков), ингест залил их в прод-БД (house_matched, идемпотентно). --- .../app/services/scrapers/domclick_detail.py | 584 +++++++ .../services/scrapers/domclick_exceptions.py | 14 + tradein-mvp/backend/scripts/README.md | 67 + .../backend/scripts/domclick_local_runner.py | 1430 +++++++++++++++++ .../backend/scripts/ingest_domclick_jsonl.py | 291 ++++ .../tests/scrapers/test_domclick_detail.py | 404 +++++ 6 files changed, 2790 insertions(+) create mode 100644 tradein-mvp/backend/app/services/scrapers/domclick_detail.py create mode 100644 tradein-mvp/backend/scripts/domclick_local_runner.py create mode 100644 tradein-mvp/backend/scripts/ingest_domclick_jsonl.py create mode 100644 tradein-mvp/backend/tests/scrapers/test_domclick_detail.py diff --git a/tradein-mvp/backend/app/services/scrapers/domclick_detail.py b/tradein-mvp/backend/app/services/scrapers/domclick_detail.py new file mode 100644 index 00000000..041df810 --- /dev/null +++ b/tradein-mvp/backend/app/services/scrapers/domclick_detail.py @@ -0,0 +1,584 @@ +"""DomClick.ru Layer B — detail-card enrichment (#1846 follow-up). + +Layer A (domclick.py) собирает базовые поля через BFF JSON API. Layer B дочитывает +страницу карточки (``https://ekaterinburg.domclick.ru/card/sale__flat__``) и +обогащает listings: renovation/repair_state, living/kitchen area, balconies, +sale_type, owners_count, encumbrances, year_built, views и priceHistory. + +Транспорт — BrowserFetcher через QRATOR-clean прокси (source="cian"): card-хосты +DomClick заблокированы QRATOR через domclick/avito-прокси, но чисты через cian +(verified live 2026-06-27). curl_cffi на card-хосте отдаёт 401/стаб. fetch_detail +принимает уже открытый browser_fetcher; жизненным циклом сессии и выбором source +владеет оркестратор. + +SSR-quirk +--------- +HTML карточки встраивает ``window.__SSR_STATE__ = {...}`` — это JS object literal, +а НЕ чистый JSON: в value-позиции встречается bare ``undefined`` (json.loads падает). +_extract_ssr_state делает balanced-brace scan (с пропуском скобок внутри строк) и +заменяет bare ``undefined`` → ``null`` строго в value-позиции, не трогая строки. + +Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27): +productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo. +priceHistory ({date ISO8601+tz, price, diff, state}), productCard.egrnData +(snake_case owners_count/collateral/collateral_sber), productCard.legalOptions. +saleType, productCard.viewsCount/callsCount, houseInfo.info.{wallType,floorType}, +и ТОП-УРОВНЕМ pricePrediction (DomClick AVM → raw_extra.avm). +Schedule для Layer B ships DORMANT — включается вручную после prod-smoke. +""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.services.scrapers.domclick_exceptions import DomClickBlockedError, DomClickParseError +from app.services.scrapers.repair_state_normalizer import ( + infer_repair_state_from_text, + normalize_repair_state, +) + +if TYPE_CHECKING: + from app.services.scrapers.browser_fetcher import BrowserFetcher + +logger = logging.getLogger(__name__) + +# ── Маркеры anti-bot challenge (DataDome / QRATOR) ─────────────────────────── +# Зеркало идеи Layer A: если __SSR_STATE__ отсутствует И страница похожа на +# challenge — это блок, а не parse-failure. +_BLOCK_MARKERS: tuple[str, ...] = ("qrator", "captcha", "datadome", "access denied") + +# ── item_id из URL карточки: .../card/sale__flat__2075729321 ───────────────── +_ITEM_ID_RE = re.compile(r"sale__flat__(\d+)") + +# ── SSR assignment marker ──────────────────────────────────────────────────── +_SSR_ASSIGN_RE = re.compile(r"window\.__SSR_STATE__\s*=\s*") + +# ── bare `undefined` (value-позиция) → null, не трогая строки ───────────────── +_UNDEFINED_RE = re.compile(r"(?<=[:\[,\s])undefined(?=[\s,\]\}])") + +# ── renovation (RU) → каноничный repair_state enum ─────────────────────────── +# ЛОКАЛЬНАЯ карта (не трогаем shared _RAW_TO_ENUM в repair_state_normalizer). +# Значения уже каноничны; прогоняются через normalize_repair_state для валидации. +_RENOVATION_MAP: dict[str, str] = { + "евроремонт": "good", + "косметический": "standard", + "дизайнерский": "excellent", + "черновая": "needs_repair", + "без отделки": "needs_repair", + "требует ремонта": "needs_repair", + "предчистовая": "needs_repair", +} + + +# ── DomClickDetailEnrichment ────────────────────────────────────────────────── +@dataclass +class DomClickDetailEnrichment: + """Результат парсинга detail-карточки DomClick (Layer B). + + save_detail_enrichment() пишет подмножество в listings (+ offer_price_history). + Все поля None-safe: отсутствующее → None (не крэш). + """ + + item_id: str | None = None + source_url: str | None = None + + repair_state: str | None = None # enum: needs_repair/standard/good/excellent + repair_type: str | None = None # сырое RU-значение renovation + living_area_m2: float | None = None + kitchen_area_m2: float | None = None + balconies_count: int | None = None + has_balcony: bool | None = None + sale_type: str | None = None + owners_count: int | None = None + encumbrances_clean: bool | None = None # True=чисто, False=есть обременение + year_built: int | None = None + views_total: int | None = None + + price_changes: list[dict[str, Any]] = field(default_factory=list) + raw_extra: dict[str, Any] = field(default_factory=dict) + + +# ── SSR state extraction ────────────────────────────────────────────────────── + + +def _scan_balanced_object(s: str, start: int) -> str: + """Сканирует от '{' по индексу ``start`` до парной '}'. + + Отслеживает глубину по {/}, ИГНОРИРУЯ скобки внутри JS-строк (одинарные и + двойные кавычки с ``\\``-эскейпами). Возвращает литерал ``{...}`` включительно. + + Raises: + DomClickParseError: если парная скобка не найдена (truncated HTML). + """ + 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 карточки. + + Шаги: + 1. Находим присваивание ``window.__SSR_STATE__ =``. Если нет — различаем + challenge-страницу (→ DomClickBlockedError) и просто отсутствие + стейта (→ DomClickParseError). + 2. Balanced-brace scan от первого '{' → литерал. + 3. Санитайз bare ``undefined`` → ``null`` (только value-позиция). + 4. json.loads. + + Raises: + DomClickBlockedError: страница без стейта + маркеры anti-bot. + DomClickParseError: стейт не найден / не сбалансирован / json.loads упал. + """ + match = _SSR_ASSIGN_RE.search(html) + if match is None: + html_lower = html.lower() + if any(marker in html_lower for marker in _BLOCK_MARKERS): + raise DomClickBlockedError( + "DomClick detail: challenge page detected (no __SSR_STATE__, " + f"markers checked: {_BLOCK_MARKERS})" + ) + 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[:200]!r})" + ) from exc + if not isinstance(data, dict): + raise DomClickParseError(f"__SSR_STATE__ is not an object: {type(data)}") + return data + + +# ── Value coercion helpers ──────────────────────────────────────────────────── + + +def _to_float(value: Any) -> float | None: + """None-safe float. bool/неконвертируемое → 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: + """None-safe int. bool/неконвертируемое → None.""" + if value is None or isinstance(value, bool): + return None + try: + return int(value) + except (ValueError, TypeError): + return None + + +def _parse_change_time(value: Any) -> datetime | None: + """Нормализует дату изменения цены → timezone-aware datetime. + + Адаптация Layer A ``_parse_publish_date`` (которая возвращала date): здесь + поддерживаем epoch (сек/мс), числовую строку и ISO8601. Real priceHistory.date + приходит как ``2026-04-15T09:02:26.548728+03:00`` — fromisoformat СОХРАНЯЕТ + исходный offset (+03:00), НЕ конвертирует в UTC; naive-значения получают UTC. + epoch трактуем как UTC. Невалидное → None (caller пропускает запись). + """ + if value is None or isinstance(value, bool): + return None + # epoch (число): >1e12 трактуем как миллисекунды + 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 + # ISO8601 (поддержим trailing 'Z') + 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 _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) + + +def _compact(d: dict[str, Any]) -> dict[str, Any]: + """Выкидывает None-значения, чтобы raw_extra оставался компактным.""" + return {k: v for k, v in d.items() if v is not None} + + +def _pick(d: dict[str, Any], *keys: str) -> Any: + """Первое не-None значение среди ключей (defensive snake/camel spellings).""" + for k in keys: + v = d.get(k) + if v is not None: + return v + return None + + +# ── item_id ─────────────────────────────────────────────────────────────────── + + +def _extract_item_id(url: str) -> str | None: + """sale__flat__ из URL карточки.""" + m = _ITEM_ID_RE.search(url or "") + return m.group(1) if m else None + + +# ── parse_detail_html ────────────────────────────────────────────────────────── + + +def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment: + """Pure parser detail-карточки DomClick (без сети/БД). + + _extract_ssr_state может бросить DomClickBlockedError/DomClickParseError — + они propagate (оркестратор различает блок и parse-failure). Дальнейшая + навигация по полям обёрнута: per-field сюрприз формы → минимальный + enrichment (item_id+url), а не крэш. + """ + state = _extract_ssr_state(html) + item_id = _extract_item_id(source_url) + try: + pc = state.get("productCard") or {} + oi = pc.get("objectInfo") or {} + hi = (state.get("houseInfo") or {}).get("info") or {} + egrn = pc.get("egrnData") or {} + + # repair: enum + сырое значение + renovation = oi.get("renovation") + repair_type = renovation if isinstance(renovation, str) else None + repair_state = _map_repair_state(repair_type) + + # площади + living_area_m2 = _to_float(oi.get("livingArea")) + kitchen_area_m2 = _to_float(oi.get("kitchenArea")) + + # балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None + balconies = oi.get("balconies") + balconies_count: int | None = None + has_balcony: bool | None = None + balconies_raw: Any = None + if isinstance(balconies, bool): + balconies_raw = balconies + elif isinstance(balconies, int): + balconies_count = balconies + has_balcony = balconies > 0 + elif balconies is not None: + balconies_raw = balconies + + sale_type = (pc.get("legalOptions") or {}).get("saleType") + + # owners_count — защищаемся от двух написаний ключа + owners_count = _to_int(egrn.get("owners_count")) + if owners_count is None: + owners_count = _to_int(egrn.get("ownersCount")) + + # encumbrances_clean (ИНВЕРСИЯ): collateral/collateral_sber обозначают + # НАЛИЧИЕ обременения. Любой truthy → clean=False; оба отсутствуют/false + # при наличии egrnData → True; egrnData нет → None. + 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 + + year_built = _to_int(hi.get("buildYear")) + views_total = _to_int(pc.get("viewsCount")) + + # 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 + change_time = _parse_change_time(entry.get("date")) + price_rub = _to_int(entry.get("price")) + # Skip при нераспарсенной дате ИЛИ отсутствующей/невалидной цене. + if change_time is None or price_rub is None: + continue + diff = entry.get("diff") + diff_percent = ( + diff if isinstance(diff, int | float) and not isinstance(diff, bool) else None + ) + price_changes.append( + { + "change_time": change_time, + "price_rub": price_rub, + "diff_percent": diff_percent, + } + ) + + # AVM (Layer C, тот же fetch) — DomClick price prediction живёт ТОП-УРОВНЕМ + # в SSR-стейте. Может быть плоским или обёрнут в .result/.data — defensively + # разворачиваем. В raw_extra пишем только присутствующие поля. + avm_root = state.get("pricePrediction") or {} + avm_src = 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"), + } + ) + + # raw_extra → merge в listings.raw_payload. wall/floor type ОСТАЮТСЯ тут, + # НЕ пишем в listings.house_type (кросс-источниковый словарь грязный). + # egrn area key — точное написание не подтверждено; пробуем несколько. + egrn_area = egrn.get("area") or egrn.get("rosreestrArea") or egrn.get("object_area") + demand = _compact( + { + "calls": pc.get("callsCount"), + "favorites": pc.get("favoriteOfferUsersCount"), + "duplicates": pc.get("duplicatesOfferCount"), + } + ) + raw_extra = _compact( + { + "wall_type": hi.get("wallType") or (pc.get("house") or {}).get("wallType"), + "floor_type": hi.get("floorType"), + "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"), + "egrn_area": egrn_area, + "demand": demand or None, + "collateral": collateral, + "collateral_sber": collateral_sber, + "balconies_raw": balconies_raw, + "avm": avm or None, + } + ) + + return DomClickDetailEnrichment( + item_id=item_id, + source_url=source_url, + repair_state=repair_state, + repair_type=repair_type, + living_area_m2=living_area_m2, + kitchen_area_m2=kitchen_area_m2, + balconies_count=balconies_count, + has_balcony=has_balcony, + sale_type=sale_type if isinstance(sale_type, str) else None, + owners_count=owners_count, + encumbrances_clean=encumbrances_clean, + year_built=year_built, + views_total=views_total, + price_changes=price_changes, + raw_extra=raw_extra, + ) + except Exception: + logger.warning( + "domclick_detail: field navigation failed for %s — minimal enrichment", + source_url, + exc_info=True, + ) + return DomClickDetailEnrichment(item_id=item_id, source_url=source_url) + + +# ── fetch_detail ──────────────────────────────────────────────────────────────── + + +async def fetch_detail( + card_url: str, + *, + browser_fetcher: BrowserFetcher, +) -> DomClickDetailEnrichment: + """Fetch абсолютного URL карточки через BrowserFetcher → parse_detail_html. + + card_url уже абсолютный (listings.source_url из Layer A), URL не строим. + Сессией владеет оркестратор (передаёт уже открытый browser_fetcher). + + Raises: + DomClickBlockedError: anti-bot challenge ИЛИ ошибка браузерного fetch + (нет curl-fallback — DataDome даёт 401 на curl). Оркестратор считает + это блоком и помечает listing failed. + DomClickParseError: HTTP 200, но SSR-стейт не парсится (дрейф схемы). + """ + try: + html = await browser_fetcher.fetch(card_url) + except DomClickBlockedError: + raise + except Exception as exc: + raise DomClickBlockedError( + f"DomClick detail browser fetch failed for {card_url}: {exc}" + ) from exc + return parse_detail_html(html, card_url) + + +# ── save_detail_enrichment ────────────────────────────────────────────────────── + + +def save_detail_enrichment( + db: Session, + listing_id: int, + e: DomClickDetailEnrichment, +) -> bool: + """Persist enrichment в одной транзакции (commit в конце; rollback — за caller). + + 1. UPDATE listings SET (COALESCE — None не затирает существующее), + raw_payload || raw_extra, detail_enriched_at=NOW() WHERE id=listing_id. + 2. Для каждой записи priceHistory — INSERT INTO offer_price_history + (ON CONFLICT DO NOTHING → идемпотентно). source='domklik'. + + Snapshot (upsert_listing_snapshot) НЕ пишем: enrichment не несёт текущую цену, + а detail-backfill цену не меняет — snapshot остаётся за SERP-путём (note). + + Returns: + True если UPDATE задел строку (listing найден), иначе False. + """ + raw_extra_json = json.dumps(e.raw_extra or {}, ensure_ascii=False, default=str) + + result = db.execute( + text(""" + UPDATE listings SET + repair_state = COALESCE(:repair_state, repair_state), + repair_type = COALESCE(:repair_type, repair_type), + living_area_m2 = COALESCE(:living_area_m2, living_area_m2), + kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2), + balconies_count = COALESCE(:balconies_count, balconies_count), + has_balcony = COALESCE(:has_balcony, has_balcony), + sale_type = COALESCE(:sale_type, sale_type), + owners_count = COALESCE(:owners_count, owners_count), + encumbrances_clean = COALESCE(:encumbrances_clean, encumbrances_clean), + year_built = COALESCE(:year_built, year_built), + views_total = COALESCE(:views_total, views_total), + raw_payload = COALESCE(raw_payload, '{}'::jsonb) || CAST(:raw_extra AS jsonb), + detail_enriched_at = NOW() + WHERE id = CAST(:lid AS bigint) + """), + { + "lid": listing_id, + "repair_state": e.repair_state, + "repair_type": e.repair_type, + "living_area_m2": e.living_area_m2, + "kitchen_area_m2": e.kitchen_area_m2, + "balconies_count": e.balconies_count, + "has_balcony": e.has_balcony, + "sale_type": e.sale_type, + "owners_count": e.owners_count, + "encumbrances_clean": e.encumbrances_clean, + "year_built": e.year_built, + "views_total": e.views_total, + "raw_extra": raw_extra_json, + }, + ) + + # priceHistory — INSERT, де-дуп по (listing_id, change_time). SAVEPOINT per-row + # (backend.md): битый cast одной записи не валит весь UPDATE+commit. + for change in e.price_changes: + ct = change.get("change_time") + price = change.get("price_rub") + if not ct or not price: + continue + try: + with db.begin_nested(): + db.execute( + text(""" + INSERT INTO offer_price_history + (listing_id, change_time, price_rub, diff_percent, source) + VALUES ( + CAST(:lid AS bigint), + CAST(:ct AS timestamptz), + CAST(:price AS numeric), + CAST(:diff AS numeric), + 'domklik' + ) + ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq + DO NOTHING + """), + { + "lid": listing_id, + "ct": ct, + "price": price, + "diff": change.get("diff_percent"), + }, + ) + except Exception as exc: + logger.warning( + "domclick_detail: price-history insert failed listing_id=%s ct=%s: %s", + listing_id, + ct, + exc, + ) + + db.commit() + found = result.rowcount > 0 + if not found: + logger.warning( + "domclick_detail save: listing not found id=%s (item_id=%s)", + listing_id, + e.item_id, + ) + else: + logger.info( + "domclick_detail save: listing id=%s enriched (price_changes=%d)", + listing_id, + len(e.price_changes), + ) + return found diff --git a/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py b/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py index 796251cc..247de7fe 100644 --- a/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py +++ b/tradein-mvp/backend/app/services/scrapers/domclick_exceptions.py @@ -11,3 +11,17 @@ class DomClickBlockedError(Exception): Обходится через shared mobile proxy (BrowserFetcher(source="domclick") → generic provider → мобильный egress). """ + + +class DomClickParseError(Exception): + """DomClick detail-карточка получена (HTTP 200, не block), но SSR-стейт + не извлекается / не парсится. + + Возникает в Layer B (domclick_detail.py) когда: + - `window.__SSR_STATE__` не найден в HTML (но это и не challenge-страница); + - object-литерал не сбалансирован по скобкам; + - json.loads падает после санитайза bare `undefined` → `null`. + + В отличие от DomClickBlockedError (anti-bot), это сигнал дрейфа схемы карточки + или повреждённого ответа — оркестратор считает его parse-failure, а не блоком. + """ diff --git a/tradein-mvp/backend/scripts/README.md b/tradein-mvp/backend/scripts/README.md index 54d88559..85e6450e 100644 --- a/tradein-mvp/backend/scripts/README.md +++ b/tradein-mvp/backend/scripts/README.md @@ -271,3 +271,70 @@ 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_domclick_jsonl.py`. + +**Транспорт — оба слоя через браузер** (`page.goto`, verified live 2026-06-27): +- **enumerate** — фронтовый SERP `ekaterinburg.domclick.ru/search?...` (SSR). Прямой + BFF `bff-search-web/api/offers/v1` режет QRATOR-403 уже с домашнего IP, а фронтовый + SERP грузится чисто. id офферов — из DOM (`a[href*="/card/"]`, ждём через + `wait_for_selector` — карточки догидрируются ~6-12с), total — из ``. Пагинация + `offset` (page-size 20); при total>2000 — price-bisection бакета (границы из title). +- **detail** — `page.goto(card)` → сырой HTML (`__SSR_STATE__`: renovation, living/kitchen, + priceHistory, egrnData owners/collateral, sale_type, views, wall/floor) **+ offer-card v3 + XHR** `price_prediction` (AVM заполненный) и `sold_similar` (`--with-sold-similar`) — + они с домашнего IP отдают 200 (в отличие от прод-прокси). Layer-A + (price/area/rooms/floor/total_floors/lat/lon/address) тоже из card-SSR. + +### Режим окна + +QRATOR ловит СТАРЫЙ headless (`launch(headless=True)`) → `403 | Домклик`. NEW-headless +И headed → `200` + полный `__SSR_STATE__`. Дефолт = new-headless (окно скрыто); +`--headed` = видимое окно (debug). + +### Resume / enum-cache (рестарт не теряет позицию) + +- **detail-resume**: при старте читает JSONL и пропускает id с терминальным статусом + (ok/blocked/parse_fail). +- **`--enum-cache PATH`**: enumerate (~6400 id, ~1.5-2ч) пишет полный список id в файл с + sentinel завершённости; **следующий запуск грузит кэш и ПРОПУСКАЕТ enumerate** → сразу + detail. Композится с detail-resume → рестарт продолжает строго с текущей позиции, без + пересбора и без перекачки готовых. + +### Темп (НЕ спалить домашний IP) + +- пауза между карточками `--min-delay` (12с) / `--max-delay` (30с); +- render-wait `--render-min` (6с) / `--render-max` (10с) — settle поверх `wait_for_selector`; +- «человеческий перерыв» каждые 25-40 карточек; на 403 — ретрай, потом `status="blocked"`. + +Дефолт ≈ 20-34с/карта; для быстрее — `--min-delay 5 --max-delay 12 --render-min 3 +--render-max 5` (~16-20с/карта; card-path чистый, запас есть). Полный свод ЕКБ (~6300) — +несколько ночей; держи ПК от сна (иначе wall-clock простаивает, данные не теряются). + +```bash +cd tradein-mvp/backend +# боевой прогон с кэшем id + sold_similar (рекомендуется): +python scripts/domclick_local_runner.py --out domclick_ekb.jsonl \ + --enum-cache enum_cache.jsonl --with-sold-similar +# быстрый 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"`). + +## `ingest_domclick_jsonl.py` — JSONL раннера → trade-in БД + +Запускается ВНУТРИ tradein-контейнера (импортит `app`). Читает JSONL раннера, на каждую +ok-запись: `ScrapedLot` → `save_listings()` (upsert + house-match хук в SAVEPOINT, +fault-tolerant — нематчнутый листинг всё равно вставляется) → `save_detail_enrichment()` +(detail-колонки COALESCE + `offer_price_history`). Идемпотентно (ON CONFLICT), `--limit`, +`--dry-run`. Запуск: `python -m scripts.ingest_domclick_jsonl --jsonl <path>` (PYTHONPATH=/app). 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..f9ccfca0 --- /dev/null +++ b/tradein-mvp/backend/scripts/domclick_local_runner.py @@ -0,0 +1,1430 @@ +#!/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 (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, и detail идут +ОДНИМ браузерным транспортом ``page.goto`` (verified 2026-06-27): + • enumerate — ``page.goto(SERP_url)`` на фронтовый + ``ekaterinburg.domclick.ru/search?...`` (SSR). Прямой BFF + ``bff-search-web.domclick.ru/api/offers/v1`` режет QRATOR-403 уже с + домашнего IP, а фронтовый SERP грузится чисто (тот же транспорт, что + card-detail). id офферов берём из DOM (``a[href*="/card/"]``), total — + из ``<title>`` ("… N объявлений"). Прямого offers/v1 XHR фронт НЕ шлёт. + Пагинация — параметром ``offset`` (page-size 20); при total>2000 — + price-bisection бакета (границы тоже из title-total). + • detail — ``page.goto(card_url)`` → jittered wait → ``page.evaluate`` с + ``fetch(location.href)`` чтобы получить СЫРОЙ HTML (SPA удаляет + ``window.__SSR_STATE__`` после гидрации, поэтому нужен повторный fetch). + Layer-A (price/area/rooms/floor/total_floors/lat/lon/address) теперь + берётся ИЗ card-SSR (enumerate отдаёт только id+url). + +ВАЖНО — РЕЖИМ ОКНА (verified 2026-06-27 на домашнем IP этой машины) +------------------------------------------------------------------ +QRATOR ДЕТЕКТИТ СТАРЫЙ HEADLESS-РЕЖИМ: ``launch(headless=True)`` → даже с +домашнего IP карточка/SERP отдаёт ``401 / 403 | Домклик`` (нет +``__SSR_STATE__``). А вот НОВЫЙ headless (``--headless=new``) И обычный headed → +``200`` + полный рендер. Поэтому по умолчанию запускаемся в new-headless +(окно НЕ видно, но детект проходит). ``--headed`` → видимое окно (debug). + +ВАЖНО — ЧЕЛОВЕКОПОДОБНЫЙ ТЕМП (явное требование оператора) +========================================================= +Домашний 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) ────────────────── + +_EKB_AREA_ID = "20561" # aids= фильтр ЕКБ-вторички на фронтовом SERP +_SERP_HOST = "https://ekaterinburg.domclick.ru" +_WARMUP_URL = "https://ekaterinburg.domclick.ru/" + +# Порядок бакетов комнат влияет на логи. КРИТИЧНО: "5+" обязан кодироваться как +# "5%2B" — литеральный '+' в query фронт читает как пробел → фильтр игнорится и +# отдаётся ПОЛНЫЙ свод (verified 2026-06-27). urlencode("5+") → "5%2B" — корректно. +# "st" фронт принимает как студию. "5" в одиночку фронт НЕ понимает (только "5+"). +ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+") + +OFFSET_CAP: int = 2000 # практический потолок offset фронтового SERP +_SERP_PAGE_SIZE: int = 20 # офферов на страницу SERP (verified) +# total из <title>: "... 2 269 объявлений" — число с разрядами (пробел/nbsp/thin). +_SERP_TOTAL_RE = re.compile(r"(\d[\d\s]*)\s*объявлен") +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) + +# Гео-фильтр ЕКБ обеспечивает aids=20561 в SERP-URL; bbox-guard убран вместе с +# BFF-enumerate (фронт отдаёт только id+url, координаты — из card-SSR). + +# Маркеры 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(); }" +) + +# JS: id+url всех офферов из DOM фронтового SERP (anchor'ы /card/...<8+ digits>). +# Дедуп через Set по сериализованному объекту. Фронт — SSR, offers/v1 XHR НЕ шлёт. +_SERP_IDS_JS = ( + "() => [...new Set([...document.querySelectorAll('a[href*=\"/card/\"]')]" + ".map(a => { const h=a.getAttribute('href')||'';" + " const m=h.match(/\\/card\\/[a-z_]*?(\\d{8,})/);" + " return m ? {id:m[1], url: h.startsWith('http')? h :" + " 'https://ekaterinburg.domclick.ru'+h} : null; })" + ".filter(Boolean).map(o => JSON.stringify(o)))].map(s => JSON.parse(s))" +) + + +# ── Исключения ─────────────────────────────────────────────────────────────── + + +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 + + +# ── Block detection / SERP URL & parsing ───────────────────────────────────── + + +def _is_block_text(text: str) -> bool: + low = text.lower() + return any(m in low for m in _BLOCK_MARKERS) + + +def _build_serp_url(rooms: str, gte: int | None, lte: int | None, offset: int) -> str: + """URL фронтового SERP. urlencode кодирует "5+" → "5%2B" (иначе фронт читает + '+' как пробел и игнорит room-фильтр).""" + params: list[tuple[str, str]] = [ + ("deal_type", "sale"), + ("category", "living"), + ("offer_type", "flat"), + ("aids", _EKB_AREA_ID), + ("from", "topline2020"), + ("offset", str(offset)), + ("rooms", rooms), + ] + 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"{_SERP_HOST}/search?{urlencode(params)}" + + +def _parse_serp_total(title: str) -> int | None: + """total из <title> ("… 2 269 объявлений"). None если числа нет.""" + if not title: + return None + m = _SERP_TOTAL_RE.search(title) + if not m: + return None + digits = re.sub(r"\D", "", m.group(1)) + return int(digits) if digits else None + + +def _canon_card_url(sid: str, url: str | None) -> str: + """Канонизируем source_url к /card/sale__flat__<id>, если href неполный.""" + if url and url.startswith("http") and "/card/" in url: + return url + return f"{_SERP_HOST}/card/sale__flat__{sid}" + + +def _serp_offer_to_dict(raw: Any) -> dict[str, Any] | None: + """offer из _SERP_IDS_JS ({id,url}) → минимальный SERP-dict {id, source_url}. + Layer-A (price/area/...) теперь добирается из card-SSR в parse_detail_html.""" + if not isinstance(raw, dict): + return None + sid = raw.get("id") + if sid is None: + return None + sid = str(sid) + if not sid.isdigit(): + return None + return {"id": sid, "source_url": _canon_card_url(sid, raw.get("url"))} + + +# ── 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")) + + # Layer-A из card-SSR (раньше шёл из BFF serp; теперь enumerate даёт + # только id+url). Пути verified 2026-06-27: цена/squarePrice — в + # priceInfo; area/rooms/floor — в objectInfo; total_floors — houseInfo; + # lat/lon/address — productCard.address(.position). + price_info = pc.get("priceInfo") or {} + address = pc.get("address") or {} + position = address.get("position") or {} + price_rub = _to_int(price_info.get("price")) + square_price = _to_int(price_info.get("squarePrice")) + area_m2 = _pos_float(oi.get("area")) + rooms = _to_int(oi.get("rooms")) + floor = _to_int(oi.get("floor")) + total_floors = _to_int(hi.get("floors")) or _to_int( + (pc.get("house") or {}).get("info", {}).get("floors") + ) + lat = _to_float(position.get("lat")) + lon = _to_float(position.get("lon")) + address_name = address.get("displayName") + is_apartment = oi.get("isApartment") + description = oi.get("description") + + # 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 { + # Layer-A (из card-SSR; раньше из BFF serp). + "price_rub": price_rub, + "square_price": square_price, + "area_m2": area_m2, + "rooms": rooms, + "floor": floor, + "total_floors": total_floors, + "lat": lat, + "lon": lon, + "address": address_name, + "is_apartment": is_apartment, + "description": description, + # Layer-B. + "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-инференс из описания (теперь из card-SSR; serp-описания больше нет) + repair_state = infer_repair_state_from_text(d.get("description") or serp.get("description")) + + is_sber = serp.get("is_sber_collateral") + if not is_sber and d.get("collateral_sber"): + is_sber = True + + # Layer-A: приоритет card-SSR (d), фолбэк на serp (на случай если serp когда-то + # снова начнёт отдавать поля). enumerate теперь даёт только id+source_url. + return { + "id": serp.get("id"), + "source_url": serp.get("source_url"), + "status": status, + "fetched_at": datetime.now(UTC).isoformat(), + "price_rub": d.get("price_rub") or serp.get("price_rub"), + "area_m2": d.get("area_m2") or serp.get("area_m2"), + "rooms": d.get("rooms") if d.get("rooms") is not None else serp.get("rooms"), + "floor": d.get("floor") or serp.get("floor"), + "total_floors": d.get("total_floors") or serp.get("total_floors"), + "year_built": d.get("year_built") or serp.get("year_built"), + "lat": d.get("lat") or serp.get("lat"), + "lon": d.get("lon") or serp.get("lon"), + "address": d.get("address") or 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": d.get("square_price") or serp.get("square_price"), + "is_apartment": ( + d.get("is_apartment") + if d.get("is_apartment") is not None + else serp.get("is_apartment") + ), + "offer_region_name": serp.get("offer_region_name"), + "published_date": serp.get("published_date"), + **(d.get("raw_extra") or {}), + **({"sold_similar": d["sold_similar"]} if d.get("sold_similar") else {}), + }, + } + + +# ── 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() + + +# ── Enumerate cache (полный список id, чтобы НЕ гонять SERP заново) ──────────── + + +def write_enum_cache(path: str, offers: list[dict[str, Any]]) -> None: + """Перезаписать кэш-файл полным списком offer-id + sentinel завершённости. + + Каждый offer пишется как {"id":..., "source_url":...} (только 2 поля), + финальной строкой — sentinel {"_enum_complete": true, "count": N}. Sentinel + означает «enumerate завершился полностью» — без него load_enum_cache считает + кэш неполным и заставляет re-enumerate. + """ + cache_path = Path(path) + cache_path.parent.mkdir(parents=True, exist_ok=True) + with cache_path.open("w", encoding="utf-8") as fh: + for serp in offers: + row = {"id": serp.get("id"), "source_url": serp.get("source_url")} + fh.write(json.dumps(row, ensure_ascii=False) + "\n") + fh.write(json.dumps({"_enum_complete": True, "count": len(offers)}) + "\n") + fh.flush() + logger.info("enum-cache: wrote %d ids → %s", len(offers), path) + + +def load_enum_cache(path: str) -> list[dict[str, Any]] | None: + """Загрузить полный список offer-id из кэша. None если файла нет/кэш неполный. + + Кэш валиден ТОЛЬКО если последняя валидная строка — sentinel с + ``_enum_complete == True`` (enumerate завершился). Битые строки пропускаются; + нет sentinel / пустой / повреждённый → None (заставляет re-enumerate). + """ + cache_path = Path(path) + if not cache_path.exists(): + return None + records: list[dict[str, Any]] = [] + with cache_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 + if isinstance(rec, dict): + records.append(rec) + if not records: + return None + last = records[-1] + if not (isinstance(last, dict) and last.get("_enum_complete") is True): + return None # кэш неполный (enumerate не завершился) → re-enumerate + offers: list[dict[str, Any]] = [] + for rec in records[:-1]: + sid = rec.get("id") + if sid is None: + continue + offers.append({"id": str(sid), "source_url": rec.get("source_url")}) + logger.info("enum-cache: loaded %d ids (complete)", len(offers)) + return offers + + +# ── SERP transport (playwright page navigation, чистый браузерный транспорт) ── + + +async def fetch_serp_page( + page: Any, + url: str, + render_wait: tuple[float, float], +) -> tuple[list[dict[str, Any]], int | None, int]: + """Навигация на фронтовый SERP → (offers, total, http_status). + + offers — список {id, source_url} из DOM; total — из <title>. + Raises: + DomClickBlockedError: 403 или QRATOR-маркеры в title. + """ + resp = await page.goto(url, wait_until="domcontentloaded", timeout=70000) + # SERP рендерит карточки клиентской гидрацией через ~6-12с после domcontentloaded. + # Фиксированный sleep хрупок: при медленном ответе DOM ещё пуст → 0 ids (silent + # 0-coverage). Ждём появления card-ссылок ЯВНО; genuinely-пустой ценовой band → + # timeout → 0 offers (корректно). render_wait остаётся коротким settle поверх. + try: + await page.wait_for_selector("a[href*='/card/']", timeout=12000) + except Exception: + pass # пустой band или медленный рендер — читаем как есть (0 offers) + await asyncio.sleep(random.uniform(*render_wait)) # settle: дорисовать все 20 + title + status = resp.status if resp else 0 + title = "" + try: + title = (await page.title()) or "" + except Exception: + title = "" + if status == 403 or _is_block_text(title): + raise DomClickBlockedError(f"SERP block status={status} title={title!r}") + raw = await page.evaluate(_SERP_IDS_JS) + offers: list[dict[str, Any]] = [] + if isinstance(raw, list): + for item in raw: + serp = _serp_offer_to_dict(item) + if serp is not None: + offers.append(serp) + total = _parse_serp_total(title) + return offers, total, status + + +# ── Enumerate (mirror domclick.py _sweep_bucket / _paginate, на title-total) ── + + +def _add_offers( + offers: list[dict[str, Any]], + seen: set[str], + done: set[str], + out: list[dict[str, Any]], + target_new: int | None, +) -> int: + """Добавить новые офферы в out (dedup по seen/done). → число добавленных. + Бросает _EnumLimitReachedError при достижении target_new.""" + added = 0 + for serp in offers: + sid = serp["id"] + if sid in seen or sid in done: + continue + seen.add(sid) + out.append(serp) + added += 1 + if target_new is not None and len(out) >= target_new: + raise _EnumLimitReachedError + return added + + +async def _paginate( + page: Any, + rooms: str, + gte: int | None, + lte: int | None, + total: int | None, + seen: set[str], + done: set[str], + out: list[dict[str, Any]], + limit_pages: int, + target_new: int | None, + render_wait: tuple[float, float], + enum_jitter: tuple[float, float], + start_page: int, +) -> None: + """Пагинация SERP с offset=start_page*20 далее. Стоп: достигли total/cap, + кончились limit_pages, или страница не дала новых id.""" + bound = OFFSET_CAP if total is None else min(total, OFFSET_CAP) + page_idx = start_page + while page_idx < limit_pages: + offset = page_idx * _SERP_PAGE_SIZE + if offset >= bound: + break + await asyncio.sleep(random.uniform(*enum_jitter)) + offers, _, _ = await fetch_serp_page( + page, _build_serp_url(rooms, gte, lte, offset), render_wait + ) + if not offers: + break + # NB: НЕ break при added==0. При resume первые страницы бакета — это топ-qi + # офферы, уже лежащие в `done` → added=0, но новые id есть на след. страницах. + # Терминаторы — только пустая страница (выше) и offset>=bound (total/cap). + _add_offers(offers, seen, done, out, target_new) + page_idx += 1 + + +async def _sweep_bucket( + page: 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, + render_wait: tuple[float, float], + enum_jitter: tuple[float, float], + depth: int, +) -> None: + """offset=0 → total + page-0 офферы. total>cap → price-bisection, иначе + пагинируем остаток. total читаем из <title> (чистый фронтовый транспорт).""" + offers0, total, _ = await fetch_serp_page( + page, _build_serp_url(rooms, gte, lte, 0), render_wait + ) + _add_offers(offers0, seen, done, out, target_new) + if not total: + # нет числа / 0 объявлений: офферов на page-0 нет → выходим; есть → допагинируем. + if offers0: + await _paginate( + page, + rooms, + gte, + lte, + None, + seen, + done, + out, + limit_pages, + target_new, + render_wait, + enum_jitter, + 1, + ) + return + if total > 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: + mid = (_gte + _lte) // 2 + logger.info( + "rooms=%r total=%d > cap — split [%d,%d]+[%d,%d]", + rooms, + total, + _gte, + mid, + mid + 1, + _lte, + ) + await _sweep_bucket( + page, + rooms, + _gte, + mid, + seen, + done, + out, + limit_pages, + target_new, + render_wait, + enum_jitter, + depth + 1, + ) + await _sweep_bucket( + page, + rooms, + mid + 1, + _lte, + seen, + done, + out, + limit_pages, + target_new, + render_wait, + enum_jitter, + depth + 1, + ) + return + logger.warning( + "rooms=%r price=[%s,%s] total=%d > cap но span<=%d — пагинируем как есть", + rooms, + gte, + lte, + total, + MIN_PRICE_SPAN, + ) + await _paginate( + page, + rooms, + gte, + lte, + total, + seen, + done, + out, + limit_pages, + target_new, + render_wait, + enum_jitter, + 1, + ) + + +async def enumerate_cards( + page: Any, + done: set[str], + limit_pages: int, + target_new: int | None, + render_wait: tuple[float, float], + enum_jitter: tuple[float, float], +) -> list[dict[str, Any]]: + """Свод ЕКБ-вторички через фронтовый SERP: все 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( + page, + bucket, + None, + None, + seen, + done, + out, + limit_pages, + target_new, + render_wait, + 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 + + +# JS: дозабор offer-card v3 (AVM price_prediction + опц. sold_similar) из +# контекста УЖЕ загруженной страницы — та же сессия/куки, что прошли QRATOR. +_OFFER_CARD_EXTRAS_JS = """async (args) => { + const [id, withSold, soldLimit] = args; + const out = {}; + const get = async (u) => { + try { + const r = await fetch(u, {credentials: 'include'}); + return r.ok ? await r.json() : {__status: r.status}; + } catch (e) { + return {__err: String(e)}; + } + }; + out.price_prediction = await get( + 'https://offer-card.domclick.ru/api/v3/offers/price_prediction?offer_id=' + id + ); + if (withSold) { + out.sold_similar = await get( + 'https://offer-card.domclick.ru/api/v3/offers/sold_similar/flat/' + id + + '?in_house=false&limit=' + soldLimit + '&offset=0' + ); + } + return out; +}""" + + +def _parse_offer_card_avm(price_prediction: Any) -> dict[str, Any] | None: + """price_prediction.result → AVM-форма (как d["avm"] из SSR). None если пусто.""" + if not isinstance(price_prediction, dict): + return None + result = price_prediction.get("result") + if not isinstance(result, dict): + return None + market_price = result.get("market_price") + if market_price is None: + return None + avm = { + "market_price": market_price, + "min": result.get("min_market_price"), + "max": result.get("max_market_price"), + "repair_quality": result.get("repair_quality"), + "rent_long": result.get("rent_long"), + "rent_short": result.get("rent_short"), + } + return {k: v for k, v in avm.items() if v is not None} + + +def _parse_offer_card_sold(sold_similar: Any) -> dict[str, Any] | None: + """sold_similar → {total, items}. None если нет result.items.""" + if not isinstance(sold_similar, dict): + return None + result = sold_similar.get("result") + if not isinstance(result, dict): + return None + items = result.get("items") + if not isinstance(items, list): + return None + pagination = sold_similar.get("pagination") or {} + total = pagination.get("total") if isinstance(pagination, dict) else None + return {"total": total, "items": items} + + +async def fetch_offer_card_extras( + page: Any, + item_id: str, + with_sold_similar: bool, + sold_limit: int, +) -> dict[str, Any]: + """Дозабор AVM (всегда) + sold_similar (опц.) с offer-card v3 из контекста + загруженной страницы. None-safe: любая ошибка/__status/__err → поле None.""" + raw = await page.evaluate(_OFFER_CARD_EXTRAS_JS, [str(item_id), with_sold_similar, sold_limit]) + if not isinstance(raw, dict): + return {"avm": None, "sold_similar": None} + avm = _parse_offer_card_avm(raw.get("price_prediction")) + sold = _parse_offer_card_sold(raw.get("sold_similar")) if with_sold_similar else None + return {"avm": avm, "sold_similar": sold} + + +async def process_card( + page: Any, + serp: dict[str, Any], + render_wait: tuple[float, float], + with_sold_similar: bool, + sold_limit: int, +) -> 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) + try: + extras = await fetch_offer_card_extras( + page, serp["id"], with_sold_similar, sold_limit + ) + if extras.get("avm"): + detail["avm"] = extras["avm"] + if extras.get("sold_similar"): + detail["sold_similar"] = extras["sold_similar"] + except Exception as exc: + logger.warning("card %s offer-card extras failed: %s", serp["id"], exc) + 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 + # SERP-навигация требует render-wait на SSR; enum_jitter — доп. пейсинг между + # страницами (SERP-навигаций много при свипе, бережём home-IP). + enum_render = (max(2.0, args.render_min - 2.0), max(3.0, args.render_max - 2.0)) + 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 перед SERP-навигацией. + 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 через фронтовый SERP (тот же чистый браузерный транспорт). + # enum-cache: полный список id живёт в файле → следующий запуск ПРОПУСКАЕТ + # enumerate (~1.5-2ч) и сразу идёт в detail. detail-resume по `done` + # композится поверх: кэш даёт ВСЕ id, `done` (из JSONL) скипает готовые. + cards: list[dict[str, Any]] | None = None + if args.enum_cache: + cards = load_enum_cache(args.enum_cache) + if cards is None: + cards = await enumerate_cards( + page, done, args.limit_pages, target_new, enum_render, enum_jitter + ) + # Sentinel пишем ТОЛЬКО после реально завершённого enumerate (offers + # полный) — прерванный/упавший свод кэш не перезаписывает. + if args.enum_cache: + write_enum_cache(args.enum_cache, cards) + else: + logger.info("enum-cache HIT: %d ids — enumerate ПРОПУЩЕН", len(cards)) + 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, args.with_sold_similar, args.sold_limit + ) + 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(args.break_min, args.break_max) + 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("--break-min", type=float, default=40.0, help="мин. длительность human-break, с") + p.add_argument( + "--break-max", type=float, default=60.0, help="макс. длительность human-break, с" + ) + p.add_argument("--limit-pages", type=int, default=100, help="макс. страниц на room-бакет") + p.add_argument("--enumerate-only", action="store_true", help="только перечислить, без detail") + p.add_argument( + "--enum-cache", + type=str, + default=None, + help="файл-кэш полного списка id (enumerate один раз → resume detail поверх)", + ) + p.add_argument( + "--with-sold-similar", + action="store_true", + help="дозабор sold_similar с offer-card v3 (+1 запрос/карточку)", + ) + p.add_argument( + "--sold-limit", type=int, default=5, help="limit для sold_similar (если включён)" + ) + 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() diff --git a/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py b/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py new file mode 100644 index 00000000..a963818d --- /dev/null +++ b/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py @@ -0,0 +1,291 @@ +"""Ингест JSONL от локального DomClick-раннера (`scripts/domclick_local_runner.py`) в БД tradein. + +Запускается ВНУТРИ tradein-контейнера (импортит `app`). Идемпотентно. + +Каждая ok-запись (status=="ok" и валидный price_rub) → + 1. upsert в `listings` через save_listings() (house-match хук — fault-tolerant, в SAVEPOINT); + 2. deep-detail enrichment через save_detail_enrichment() + (owners / encumbrances / kitchen / views / wall / floor / price-history). + +Listing.id не возвращается из save_listings — достаём отдельным SELECT по (source, source_id). + +Usage: + python -m scripts.ingest_domclick_jsonl --jsonl /tmp/domclick.jsonl [--limit N] [--dry-run] +""" + +from __future__ import annotations + +import argparse +import json +import logging +from typing import Any + +from sqlalchemy import text + +from app.core.db import SessionLocal +from app.services.scrapers.base import ScrapedLot, save_listings +from app.services.scrapers.domclick_detail import ( + DomClickDetailEnrichment, + save_detail_enrichment, +) + +logger = logging.getLogger(__name__) + +SOURCE = "domklik" + + +def _to_int(v: Any) -> int | None: + """int() None-safe: возвращает None на None / нечисловом мусоре (не крэшит прогон).""" + if v is None: + return None + try: + return int(v) + except (TypeError, ValueError): + return None + + +def _to_float(v: Any) -> float | None: + """float() None-safe: возвращает None на None / нечисловом мусоре.""" + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _build_lot(rec: dict[str, Any]) -> ScrapedLot: + """Построить ScrapedLot из JSONL-записи. price_rub уже провалидирован caller'ом.""" + raw_extra = rec.get("raw_extra") or {} + return ScrapedLot( + source=SOURCE, + source_url=rec["source_url"], + source_id=str(rec["id"]), + address=rec.get("address"), + lat=_to_float(rec.get("lat")), + lon=_to_float(rec.get("lon")), + rooms=_to_int(rec.get("rooms")), + area_m2=_to_float(rec.get("area_m2")), + floor=_to_int(rec.get("floor")), + total_floors=_to_int(rec.get("total_floors")), + year_built=_to_int(rec.get("year_built")), + price_rub=int(rec["price_rub"]), + listing_segment="vtorichka", + repair_state=rec.get("repair_state"), + raw_payload={ + "is_sber_collateral": rec.get("is_sber_collateral"), + "square_price": raw_extra.get("square_price"), + "avm": rec.get("avm") or {}, + }, + ) + + +def _build_enrichment(rec: dict[str, Any]) -> DomClickDetailEnrichment: + """Построить deep-detail enrichment из JSONL-записи.""" + raw_extra_in = rec.get("raw_extra") or {} + raw_extra: dict[str, Any] = { + "wall_type": rec.get("wall_type"), + "floor_type": rec.get("floor_type"), + "calls": rec.get("calls"), + "is_sber_collateral": rec.get("is_sber_collateral"), + "avm": rec.get("avm") or {}, + **raw_extra_in, + } + return DomClickDetailEnrichment( + item_id=str(rec["id"]), + source_url=rec["source_url"], + repair_state=rec.get("repair_state"), + repair_type=rec.get("repair_raw"), + living_area_m2=_to_float(rec.get("living_area_m2")), + kitchen_area_m2=_to_float(rec.get("kitchen_area_m2")), + sale_type=rec.get("sale_type"), + owners_count=_to_int(rec.get("owners_count")), + encumbrances_clean=rec.get("encumbrances_clean"), + year_built=_to_int(rec.get("year_built")), + views_total=_to_int(rec.get("views")), + price_changes=rec.get("price_changes") or [], + raw_extra=raw_extra, + ) + + +def run(jsonl_path: str, limit: int | None, dry_run: bool) -> dict[str, int]: + """Прочитать JSONL, отфильтровать ok+price, upsert listings + enrichment. + + Returns: dict со счётчиками для итогового лога / тестов. + """ + lots: list[ScrapedLot] = [] + valid_records: list[dict[str, Any]] = [] # (parallel to lots) для enrichment-шага + + skipped_status = 0 + skipped_no_price = 0 + parse_failed = 0 + + with open(jsonl_path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError as exc: + logger.warning("битая JSON-строка пропущена: %s", exc) + parse_failed += 1 + continue + + if rec.get("status") != "ok": + skipped_status += 1 + continue + price = _to_int(rec.get("price_rub")) + if price is None or price <= 0: + skipped_no_price += 1 + continue + + try: + lot = _build_lot(rec) + except Exception as exc: + logger.warning("не удалось построить lot для id=%s: %s", rec.get("id"), exc) + parse_failed += 1 + continue + + lots.append(lot) + valid_records.append(rec) + + if limit is not None and len(lots) >= limit: + break + + total_valid = len(lots) + logger.info( + "прочитано: valid=%d skipped_status=%d skipped_no_price=%d parse_failed=%d", + total_valid, + skipped_status, + skipped_no_price, + parse_failed, + ) + + inserted = 0 + updated = 0 + house_matched = 0 + match_failed = 0 + detail_applied = 0 + price_history_attempted = 0 + enrich_failed = 0 + + if dry_run: + logger.info("[dry-run] %d записей распарсено, запись в БД пропущена", total_valid) + return { + "inserted": inserted, + "updated": updated, + "house_matched": house_matched, + "match_failed": match_failed, + "detail_applied": detail_applied, + "price_history_attempted": price_history_attempted, + "enrich_failed": enrich_failed, + "skipped_status": skipped_status, + "skipped_no_price": skipped_no_price, + "parse_failed": parse_failed, + } + + db = SessionLocal() + try: + if lots: + inserted, updated = save_listings(db, lots) + logger.info("save_listings: inserted=%d updated=%d", inserted, updated) + + # Достаём listing.id + house_id_fk одним SELECT по source_id. + source_ids = [lot.source_id for lot in lots if lot.source_id is not None] + id_map: dict[str, tuple[int, Any]] = {} + if source_ids: + rows = db.execute( + text( + "SELECT source_id, id, house_id_fk FROM listings " + "WHERE source = :source AND source_id = ANY(:ids)" + ), + {"source": SOURCE, "ids": source_ids}, + ).fetchall() + for sid, lid, house_id_fk in rows: + id_map[str(sid)] = (int(lid), house_id_fk) + + total_saved = len(id_map) + house_matched = sum(1 for _, hid in id_map.values() if hid is not None) + match_failed = total_saved - house_matched + + # Deep-detail enrichment по каждой ok-записи. + for rec in valid_records: + sid = str(rec["id"]) + entry = id_map.get(sid) + if entry is None: + logger.warning("listing id не найден для source_id=%s — enrichment пропущен", sid) + continue + listing_id, _ = entry + # Per-record изоляция: save_detail_enrichment коммитит сама, поэтому + # падение одной записи иначе оборвало бы остаток valid_records трейсбэком. + # Уже закоммиченные строки персистят → повторный прогон добьёт хвост. + try: + enrichment = _build_enrichment(rec) + price_history_attempted += len(enrichment.price_changes) + if save_detail_enrichment(db, listing_id, enrichment): + detail_applied += 1 + except Exception as exc: + db.rollback() + enrich_failed += 1 + logger.warning("enrichment упал для source_id=%s: %s", sid, exc) + continue + finally: + db.close() + + logger.info( + "ИТОГО: saved(inserted=%d/updated=%d) house_matched=%d match_failed=%d " + "detail_applied=%d price_history_attempted=%d enrich_failed=%d " + "skipped_status=%d skipped_no_price=%d parse_failed=%d", + inserted, + updated, + house_matched, + match_failed, + detail_applied, + price_history_attempted, + enrich_failed, + skipped_status, + skipped_no_price, + parse_failed, + ) + + return { + "inserted": inserted, + "updated": updated, + "house_matched": house_matched, + "match_failed": match_failed, + "detail_applied": detail_applied, + "price_history_attempted": price_history_attempted, + "enrich_failed": enrich_failed, + "skipped_status": skipped_status, + "skipped_no_price": skipped_no_price, + "parse_failed": parse_failed, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Ингест DomClick JSONL (от domclick_local_runner) в БД tradein.", + ) + parser.add_argument("--jsonl", required=True, help="путь к JSONL-файлу раннера") + parser.add_argument( + "--limit", + type=int, + default=None, + help="обработать первые N валидных (ok+price) записей", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="парсить + логировать, но НЕ писать в БД", + ) + args = parser.parse_args() + run(args.jsonl, args.limit, args.dry_run) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + main() diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py new file mode 100644 index 00000000..3216ec29 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -0,0 +1,404 @@ +"""Unit-тесты для DomClick Layer B detail-scraper (domclick_detail.py). + +Offline: parse/SSR-тесты без сети/БД; save-тест через MagicMock (зеркало +test_cian_detail.py — в проекте нет live-DB фикстуры, идемпотентность +гарантируется ON CONFLICT-клаузой, которая проверяется на уровне SQL-строки). + +Покрытие: +- _extract_ssr_state: bare `undefined` → null; in-string "undefined" выживает; + скобки/эскейпы внутри строк не ломают balanced-scan; missing marker → + DomClickParseError; challenge → DomClickBlockedError; unbalanced/невалидный + JSON → DomClickParseError. +- parse_detail_html (REAL __SSR_STATE__ paths, live card 2075729321): + productCard.objectInfo.{renovation→repair_state+repair_type,livingArea,kitchenArea}; + balconies absent→None; productCard.legalOptions.saleType; egrnData snake_case + owners_count + encumbrances инверсия (collateral); houseInfo.info.{wallType,floorType} + → raw_extra (НЕ house_type); productCard.viewsCount; priceInfo.priceHistory + (ISO8601+offset kept tz-aware / bad-date + no-price skipped); top-level + pricePrediction → raw_extra.avm (плоский + nested .result); raw_extra без None; + item_id из URL. +- fetch_detail: browser-branch + проброс ошибок. +- save_detail_enrichment: UPDATE-параметры, detail_enriched_at, price-history INSERT. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.services.scrapers.domclick_detail import ( + DomClickDetailEnrichment, + _extract_ssr_state, + fetch_detail, + parse_detail_html, + save_detail_enrichment, +) +from app.services.scrapers.domclick_exceptions import DomClickBlockedError, DomClickParseError + +_CARD_URL = "https://ekaterinburg.domclick.ru/card/sale__flat__2075729321" + +# Литерал с bare `undefined` (value-позиция), in-string "undefined", скобкой +# внутри строки и эскейп-кавычками — написан вручную, json.dumps использовать +# нельзя (нужен именно НЕвалидный-для-json undefined). +# Real __SSR_STATE__ shape (ground truth, live card 2075729321, 2026-06-27). +# bare `undefined` (value-pos), in-string "undefined", '{' внутри строки и +# эскейп-кавычки оставлены для проверки санитайзера/balanced-scan; json.dumps +# использовать нельзя (нужен именно НЕвалидный-для-json undefined). +# priceHistory[0].date намеренно flush-left (длинная ISO+offset строка) — отступ +# в литерале не влияет на парсер, но держит физическую строку ≤100 для ruff. +_SSR_LITERAL = """{ + "productCard": { + "objectInfo": { + "area": 38.0, + "rooms": 1, + "floor": 5, + "isApartment": false, + "renovation": "евроремонт", + "livingArea": 22.37, + "kitchenArea": 10.13, + "description": "Состояние пока undefined.", + "note": "угол {da}", + "quote": "сказал \\"привет\\" сосед" + }, + "legalOptions": {"saleType": "Свободная продажа"}, + "egrnData": { + "area": 38.2, + "floor": 5, + "owners_count": 1, + "collateral": true, + "collateral_sber": false + }, + "priceInfo": { + "priceHistory": [ +{"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"}, + {"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"}, + {"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"} + ] + }, + "viewsCount": 338, + "callsCount": 5, + "favoriteOfferUsersCount": 12, + "duplicatesOfferCount": 0, + "address": {"guid": "abc-guid-123"}, + "unknownField": undefined + }, + "houseInfo": { + "info": { + "buildYear": 2015, + "wallType": "Кирпично-монолитный", + "floorType": "Железобетонный", + "entranceCount": 3, + "quartersCount": 120, + "energyEfficiency": "B", + "buildingSeries": "индивидуальный" + } + }, + "pricePrediction": { + "market_price": 13000000, + "min_market_price": 12000000, + "max_market_price": 14000000, + "rent_short": 50000, + "rent_long": 35000, + "repair_quality": "good" + } +}""" + +_HTML = ( + "<html><head></head><body><script>" + f"window.__SSR_STATE__ = {_SSR_LITERAL};" + "</script></body></html>" +) + + +def _wrap(state: dict) -> str: + """Заворачивает валидный dict в SSR-HTML (для variant-фикстур без undefined).""" + literal = json.dumps(state, ensure_ascii=False) + return f"<html><body><script>window.__SSR_STATE__ = {literal};</script></body></html>" + + +# ── _extract_ssr_state ──────────────────────────────────────────────────────── + + +def test_extract_ssr_state_parses_despite_undefined() -> None: + state = _extract_ssr_state(_HTML) + # bare undefined в value-позиции → null → None + assert state["productCard"]["unknownField"] is None + # in-string "undefined" НЕ испорчен санитайзером + assert state["productCard"]["objectInfo"]["description"] == "Состояние пока undefined." + + +def test_extract_ssr_state_brace_and_escape_in_string() -> None: + state = _extract_ssr_state(_HTML) + oi = state["productCard"]["objectInfo"] + # '{' внутри строки не ломает balanced-brace scan + assert oi["note"] == "угол {da}" + # эскейп-кавычки внутри строки обработаны + assert oi["quote"] == 'сказал "привет" сосед' + + +def test_extract_ssr_state_missing_marker_raises_parse() -> None: + with pytest.raises(DomClickParseError): + _extract_ssr_state("<html><body>no ssr state here</body></html>") + + +def test_extract_ssr_state_challenge_raises_blocked() -> None: + html = "<html><body>Access denied by DataDome captcha</body></html>" + with pytest.raises(DomClickBlockedError): + _extract_ssr_state(html) + + +def test_extract_ssr_state_unbalanced_raises_parse() -> None: + with pytest.raises(DomClickParseError): + _extract_ssr_state('window.__SSR_STATE__ = {"a": 1') + + +def test_extract_ssr_state_invalid_json_raises_parse() -> None: + with pytest.raises(DomClickParseError): + _extract_ssr_state("window.__SSR_STATE__ = {this: is, not: json}") + + +# ── parse_detail_html — полная карточка ─────────────────────────────────────── + + +def test_parse_detail_html_full() -> None: + e = parse_detail_html(_HTML, _CARD_URL) + + assert e.item_id == "2075729321" + assert e.source_url == _CARD_URL + + assert e.repair_state == "good" # евроремонт → good (LOCAL map) + assert e.repair_type == "евроремонт" # сырое значение сохранено + + assert e.living_area_m2 == 22.37 + assert e.kitchen_area_m2 == 10.13 + + # На реальной карточке нет ключа balconies в objectInfo → optional/None. + assert e.balconies_count is None + assert e.has_balcony is None + + assert e.sale_type == "Свободная продажа" + assert e.owners_count == 1 + assert e.encumbrances_clean is False # collateral=true → есть обременение + assert e.year_built == 2015 + assert e.views_total == 338 + + +def test_parse_detail_html_price_changes() -> None: + e = parse_detail_html(_HTML, _CARD_URL) + # entry1 ок; entry2 bad-date skip; entry3 price=null skip → 1 запись + assert len(e.price_changes) == 1 + change = e.price_changes[0] + assert change["price_rub"] == 13400000 + assert change["diff_percent"] == -400000 + assert isinstance(change["change_time"], datetime) + # ISO8601-with-offset: tz-aware, offset СОХРАНЁН (+03:00), НЕ сконвертирован в UTC. + assert change["change_time"].tzinfo is not None + assert change["change_time"].utcoffset() == timedelta(hours=3) + + +def test_parse_detail_html_raw_extra() -> None: + e = parse_detail_html(_HTML, _CARD_URL) + raw = e.raw_extra + assert raw["wall_type"] == "Кирпично-монолитный" + assert raw["floor_type"] == "Железобетонный" + # wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type. + assert "house_type" not in raw + assert raw["domclick_building_guid"] == "abc-guid-123" + assert raw["egrn_area"] == 38.2 + assert raw["demand"]["calls"] == 5 + assert raw["demand"]["favorites"] == 12 + # AVM (Layer C, top-level pricePrediction) → raw_extra.avm + assert raw["avm"]["market_price"] == 13000000 + assert raw["avm"]["min"] == 12000000 + assert raw["avm"]["max"] == 14000000 + assert raw["avm"]["repair_quality"] == "good" + # нет ключа balconies → raw_extra НЕ содержит balconies_raw + assert "balconies_raw" not in raw + # никаких None-значений на верхнем уровне (компактность) + assert all(v is not None for v in raw.values()) + + +# ── parse_detail_html — частные ветки ───────────────────────────────────────── + + +def test_parse_balconies_string_goes_to_raw_extra() -> None: + html = _wrap({"productCard": {"objectInfo": {"balconies": "есть"}}}) + e = parse_detail_html(html, _CARD_URL) + assert e.balconies_count is None + assert e.has_balcony is None + assert e.raw_extra["balconies_raw"] == "есть" + + +def test_parse_encumbrances_clean_true_when_no_collateral() -> None: + html = _wrap({"productCard": {"egrnData": {"collateral": False, "collateral_sber": False}}}) + e = parse_detail_html(html, _CARD_URL) + assert e.encumbrances_clean is True + + +def test_parse_encumbrances_none_when_egrn_missing() -> None: + html = _wrap({"productCard": {"objectInfo": {"livingArea": 30}}}) + e = parse_detail_html(html, _CARD_URL) + assert e.encumbrances_clean is None + + +def test_parse_repair_map_hit() -> None: + html = _wrap({"productCard": {"objectInfo": {"renovation": "Косметический"}}}) + e = parse_detail_html(html, _CARD_URL) + assert e.repair_state == "standard" + assert e.repair_type == "Косметический" + + +def test_parse_repair_fallback_to_inference() -> None: + # Не точное совпадение в карте → инференс из текста (евроремонт → good) + html = _wrap({"productCard": {"objectInfo": {"renovation": "сделан свежий евроремонт"}}}) + e = parse_detail_html(html, _CARD_URL) + assert e.repair_state == "good" + assert e.repair_type == "сделан свежий евроремонт" + + +def test_parse_owners_count_camelcase_key() -> None: + html = _wrap({"productCard": {"egrnData": {"ownersCount": 3}}}) + e = parse_detail_html(html, _CARD_URL) + assert e.owners_count == 3 + + +def test_parse_avm_nested_under_result() -> None: + # pricePrediction может приходить обёрнутым в .result — unwrap должен сработать. + html = _wrap( + { + "productCard": {}, + "pricePrediction": {"result": {"marketPrice": 9000000, "repairQuality": "fine"}}, + } + ) + e = parse_detail_html(html, _CARD_URL) + assert e.raw_extra["avm"]["market_price"] == 9000000 + assert e.raw_extra["avm"]["repair_quality"] == "fine" + + +def test_parse_avm_absent_no_key() -> None: + # Нет pricePrediction → raw_extra без ключа avm (компактность). + e = parse_detail_html(_wrap({"productCard": {}}), _CARD_URL) + assert "avm" not in e.raw_extra + + +def test_parse_item_id_none_for_bad_url() -> None: + e = parse_detail_html(_wrap({"productCard": {}}), "https://domclick.ru/card/rent__room__1") + assert e.item_id is None + + +def test_dataclass_defaults() -> None: + e = DomClickDetailEnrichment() + assert e.item_id is None + assert e.price_changes == [] + assert e.raw_extra == {} + + +# ── fetch_detail (browser-branch) ───────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_fetch_detail_parses_via_browser() -> None: + bf = MagicMock() + bf.fetch = AsyncMock(return_value=_HTML) + e = await fetch_detail(_CARD_URL, browser_fetcher=bf) + assert e.item_id == "2075729321" + assert e.repair_state == "good" + bf.fetch.assert_called_once_with(_CARD_URL) + + +@pytest.mark.asyncio +async def test_fetch_detail_browser_failure_raises_blocked() -> None: + bf = MagicMock() + bf.fetch = AsyncMock(side_effect=RuntimeError("502 bad gateway")) + with pytest.raises(DomClickBlockedError): + await fetch_detail(_CARD_URL, browser_fetcher=bf) + + +@pytest.mark.asyncio +async def test_fetch_detail_propagates_parse_error() -> None: + bf = MagicMock() + bf.fetch = AsyncMock(return_value="<html><body>no state</body></html>") + with pytest.raises(DomClickParseError): + await fetch_detail(_CARD_URL, browser_fetcher=bf) + + +@pytest.mark.asyncio +async def test_fetch_detail_propagates_blocked_from_html() -> None: + bf = MagicMock() + bf.fetch = AsyncMock(return_value="<html><body>Access denied datadome</body></html>") + with pytest.raises(DomClickBlockedError): + await fetch_detail(_CARD_URL, browser_fetcher=bf) + + +# ── save_detail_enrichment (MagicMock — зеркало test_cian_detail) ───────────── + + +def test_save_detail_enrichment_writes_columns() -> None: + db = MagicMock() + db.execute.return_value.rowcount = 1 + ct = datetime(2026, 4, 1, tzinfo=UTC) + e = DomClickDetailEnrichment( + item_id="2075729321", + source_url=_CARD_URL, + repair_state="good", + repair_type="Евроремонт", + living_area_m2=45.5, + kitchen_area_m2=12.3, + balconies_count=2, + has_balcony=True, + sale_type="свободная", + owners_count=1, + encumbrances_clean=False, + year_built=2015, + views_total=1234, + price_changes=[{"change_time": ct, "price_rub": 5000000, "diff_percent": -2.5}], + raw_extra={"wall_type": "монолит"}, + ) + + ok = save_detail_enrichment(db, 999, e) + assert ok is True + + # UPDATE — первый execute + update_sql = str(db.execute.call_args_list[0][0][0]) + update_params = db.execute.call_args_list[0][0][1] + assert "detail_enriched_at = NOW()" in update_sql + assert update_params["lid"] == 999 + assert update_params["repair_state"] == "good" + assert update_params["living_area_m2"] == 45.5 + assert update_params["balconies_count"] == 2 + assert update_params["has_balcony"] is True + assert update_params["encumbrances_clean"] is False + assert "монолит" in update_params["raw_extra"] + + # price-history INSERT с ON CONFLICT (идемпотентность) + insert_calls = [c for c in db.execute.call_args_list if "offer_price_history" in str(c[0][0])] + assert len(insert_calls) == 1 + assert "ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq" in str( + insert_calls[0][0][0] + ) + ins_params = insert_calls[0][0][1] + assert ins_params["price"] == 5000000 + assert ins_params["ct"] == ct + db.commit.assert_called_once() + + +def test_save_detail_enrichment_listing_not_found() -> None: + db = MagicMock() + db.execute.return_value.rowcount = 0 + e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL, repair_state="good") + assert save_detail_enrichment(db, 123, e) is False + + +def test_save_detail_enrichment_none_passthrough() -> None: + db = MagicMock() + db.execute.return_value.rowcount = 1 + e = DomClickDetailEnrichment(item_id="x", source_url=_CARD_URL) # все поля None + save_detail_enrichment(db, 1, e) + params = db.execute.call_args_list[0][0][1] + assert params["repair_state"] is None + assert params["living_area_m2"] is None + # пустой raw_extra → "{}" (merge no-op, не затирает) + assert params["raw_extra"] == "{}" -- 2.45.3