gendesign/tradein-mvp/backend/scripts/ingest_domclick_jsonl.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

291 lines
10 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.

"""Ингест 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()