gendesign/tradein-mvp/backend/scripts/ingest_domclick_jsonl.py
bot-backend ea24289f47
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
feat(tradein/scraper-kit): migrate admin.py debug routes + domclick ingest to kit (#2305)
Group A of the scraper_kit migration epic (#2277). Switches admin.py's manual
"run parser" debug endpoints and scripts/ingest_domclick_jsonl.py from direct
app.services.scrapers.* imports to their scraper_kit.providers.* equivalents,
using the DI adapters (RealScraperConfig/RealMatcherAdapter/RealProxyProvider,
app.services.scraper_settings.get_scraper_delay) already established by
app.scheduler_main._run_kit_scheduler.

Migrated: /scrape (AvitoScraper/CianScraper/YandexRealtyScraper + save_listings),
scrape_avito_house, scrape_avito_detail, scrape_avito_imv, scrape_yandex_detail,
scrape_yandex_valuation, scrape_cian_detail, cian_auto_login's BrowserFetcher.
scripts/ingest_domclick_jsonl.py: ScrapedLot/save_listings + (now that #2307/
Group D ported a kit equivalent while this was in flight) DomClickDetailEnrichment/
save_detail_enrichment.

Deliberately NOT migrated (documented in admin.py): scrape_yandex_newbuilding /
scrape_cian_newbuilding — their kit equivalents
(providers/{yandex,cian}/newbuilding.py) construct an internal BrowserFetcher(...)
without the mandatory `endpoint` kwarg, so any call crashes/silently-fails
regardless of caller-side DI. Bug lives in scraper_kit provider code, out of
scope here (only consuming, not touching provider logic) — flagged as follow-up.

Parity proven via tests/support/parity.py (assert_parity) against the exact
functions each debug route now calls, on offline fixtures — no live network/DB.

Refs #2305
2026-07-04 00:27:01 +03:00

295 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 scraper_kit.base import ScrapedLot, save_listings
from scraper_kit.orchestration.pipeline import DEFAULT_REGION_CODE
from scraper_kit.providers.domclick.detail import (
DomClickDetailEnrichment,
save_detail_enrichment,
)
from sqlalchemy import text
from app.core.db import SessionLocal
from app.services.scraper_adapters import RealMatcherAdapter
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, matcher=RealMatcherAdapter(), region_code=DEFAULT_REGION_CODE
)
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()