diff --git a/tradein-mvp/backend/scripts/domclick_local_runner.py b/tradein-mvp/backend/scripts/domclick_local_runner.py index 8f7979fd..f9ccfca0 100644 --- a/tradein-mvp/backend/scripts/domclick_local_runner.py +++ b/tradein-mvp/backend/scripts/domclick_local_runner.py @@ -755,6 +755,65 @@ def append_record(path: Path, rec: dict[str, Any]) -> None: 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, чистый браузерный транспорт) ── @@ -1220,9 +1279,22 @@ async def run(args: argparse.Namespace) -> None: logger.warning("warmup navigation failed (%s) — continuing", exc) # 1. Enumerate через фронтовый SERP (тот же чистый браузерный транспорт). - cards = await enumerate_cards( - page, done, args.limit_pages, target_new, enum_render, enum_jitter - ) + # 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: @@ -1312,6 +1384,12 @@ def build_arg_parser() -> argparse.ArgumentParser: ) 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",