"""Persistent per-URL state: makes runs resumable and failures trackable. state.json lives in the run's OUT_DIR and maps every URL seen to a record: { "status": "ok" | "failed" | "error", "attempts": , "error": "", "files": [{"path": ..., "size": ...}, ...], "updated": "" } Re-running skips URLs whose status is "ok"; failed URLs are retried and also written to failed_urls.txt for easy manual retry lists. """ from __future__ import annotations import json import logging import time from pathlib import Path log = logging.getLogger(__name__) class State: """JSON-backed state store for one run directory.""" def __init__(self, path: Path): self.path = path self.records: dict[str, dict] = {} if path.exists(): try: self.records = json.loads(path.read_text(encoding="utf-8")) if not isinstance(self.records, dict): raise ValueError("state root must be an object") except (ValueError, OSError) as exc: backup = path.with_suffix(".json.bak") log.warning("Corrupt state file %s (%s); moving to %s and starting fresh", path, exc, backup) path.replace(backup) self.records = {} def is_done(self, url: str) -> bool: rec = self.records.get(url) return bool(rec) and rec.get("status") == "ok" def pending(self, urls: list[str]) -> list[str]: return [u for u in urls if not self.is_done(u)] def mark(self, url: str, **fields) -> dict: rec = self.records.setdefault(url, {"status": "pending", "attempts": 0, "files": []}) for key, value in fields.items(): if value is not None: rec[key] = value rec["attempts"] = rec.get("attempts", 0) + 1 rec["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") self.save() return rec def failures(self) -> list[str]: return [u for u, r in self.records.items() if r.get("status") != "ok"] def save(self) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_suffix(".json.tmp") tmp.write_text( json.dumps(self.records, indent=2, sort_keys=True), encoding="utf-8" ) tmp.replace(self.path) def append_manifest(manifest_path: Path, row: dict) -> None: """Append one JSON line to manifest.jsonl (per downloaded file).""" manifest_path.parent.mkdir(parents=True, exist_ok=True) with manifest_path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(row, ensure_ascii=False) + "\n")