gendesign/tradein-mvp/backend/app/services/scrapers/domclick_detail.py
bot-backend 43b392b809
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein/domclick): local-runner + ingest tooling → main (split from parked Layer-B)
Выделяет МЕРДЖАБЕЛЬНУЮ часть 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, идемпотентно).
2026-06-28 10:26:52 +03:00

584 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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__<id>``) и
обогащает 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__<digits> из 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 <cols> (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