feat(tradein/domclick): local-runner + ingest tooling (mergeable split from #2005) #2033

Merged
lekss361 merged 1 commit from feat/tradein-domclick-local-tooling into main 2026-06-28 07:29:41 +00:00
6 changed files with 2790 additions and 0 deletions

View file

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

View file

@ -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, а не блоком.
"""

View file

@ -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 — из `<title>`. Пагинация
`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).

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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.{renovationrepair_state+repair_type,livingArea,kitchenArea};
balconies absentNone; 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"] == "{}"