"""Runs adapters and enrichers. fetch mode: start() -> fetch (policy, archive) -> parse() -> Loader, one transaction per response reparse mode: replay the latest archived response of every request the adapter ever made test mode: parse a given response without DB (used by golden tests) """ from __future__ import annotations import logging import traceback from collections import deque from collections.abc import Iterable from datetime import datetime, timezone from typing import Any from psycopg import Connection from psycopg.types.json import Jsonb from politrack.core import registry from politrack.core.adapter import Adapter, Enricher, Request, Response, RunContext, StateStore from politrack.core.archive import Archive from politrack.core.fetch import Disallowed, Fetcher from politrack.core.loader import Loader, source_row from politrack.db import connection log = logging.getLogger(__name__) ENRICH_COMMIT_EVERY = 200 def run(name: str, *, mode: str = "fetch", since: datetime | None = None, params: dict[str, Any] | None = None, max_requests: int | None = None) -> dict: cls = registry.get(name) with connection() as conn: run_id = conn.execute("INSERT INTO run (adapter, mode) VALUES (%s, %s) RETURNING id", (name, mode)).fetchone()["id"] conn.commit() ctx = RunContext(adapter=name, spec=cls.spec, state=StateStore(conn, name), since=since, params=params or {}, mode=mode, conn=conn, creates_persons=cls.creates_persons, log=logging.getLogger(f"politrack.{name}")) status, error = "ok", None try: if issubclass(cls, Enricher): _run_enricher(cls(), ctx, conn) elif mode == "reparse": _reparse(cls(), ctx, conn) else: _fetch_run(cls(), ctx, conn, max_requests) if ctx.stats.get("errors"): status = "partial" except Exception: conn.rollback() status, error = "error", traceback.format_exc() ctx.log.exception("%s failed", name) conn.execute("UPDATE run SET finished_at = now(), status = %s, stats = %s, error = %s WHERE id = %s", (status, Jsonb(dict(ctx.stats)), error, run_id)) conn.commit() ctx.log.info("%s %s: %s %s", name, mode, status, dict(ctx.stats)) if status == "error": raise RuntimeError(f"{name} failed, see run {run_id}") return {"status": status, **dict(ctx.stats)} def _fetch_run(adapter: Adapter, ctx: RunContext, conn: Connection, max_requests: int | None) -> None: fetcher = Fetcher() loader = Loader(conn, ctx) queue: deque[Request] = deque(adapter.start(ctx)) seen: set[str] = set() try: while queue: req = queue.popleft() if req.fingerprint in seen: continue seen.add(req.fingerprint) if max_requests is not None and ctx.stats["requests"] >= max_requests: ctx.stats["stopped_at_max_requests"] = 1 break ctx.stats["requests"] += 1 # Multi-publisher adapters (news feeds, GDELT) name the publisher per request. spec = registry.SPECS.get(req.meta.get("source") or adapter.spec.slug, adapter.spec) try: resp = fetcher.fetch(conn, req, adapter=adapter.name, spec=spec, source_id=source_row(conn, spec.slug)["id"]) except Disallowed: ctx.stats["disallowed_by_robots"] += 1 ctx.log.warning("robots.txt disallows %s", req.url) continue except Exception as exc: conn.rollback() ctx.stats["fetch_errors"] += 1 ctx.stats["errors"] += 1 ctx.log.warning("fetch failed %s: %s", req.url, exc) continue if req.skip_unchanged and _unchanged(conn, resp, adapter.parser_version): # Same body, same parser: its records are already loaded. Still collect follow-up requests, # so children an interrupted run never fetched are picked up (they have their own checks). ctx.stats["unchanged"] += 1 queue.extend(_follow_ups(adapter, ctx, resp)) continue queue.extend(_parse_and_load(adapter, ctx, conn, loader, resp)) finally: fetcher.close() def _unchanged(conn: Connection, resp: Response, parser_version: int) -> bool: if resp.fetch_id is None: return False row = conn.execute( """SELECT 1 FROM fetch_log WHERE request_fp = %s AND parsed_sha256 = %s AND parser_version = %s LIMIT 1""", (resp.request.fingerprint, resp.sha256, parser_version), ).fetchone() return row is not None def _follow_ups(adapter: Adapter, ctx: RunContext, resp: Response) -> list[Request]: ctx.response = resp try: return [item for item in adapter.parse(ctx, resp) if isinstance(item, Request)] except Exception: ctx.stats["parse_errors"] += 1 ctx.log.exception("parse failed for unchanged %s", resp.url) return [] finally: ctx.response = None def _parse_and_load(adapter: Adapter, ctx: RunContext, conn: Connection, loader: Loader, resp: Response) -> list[Request]: follow: list[Request] = [] ctx.response = resp try: for item in adapter.parse(ctx, resp): if isinstance(item, Request): follow.append(item) else: loader.load(item) if resp.fetch_id is not None: Archive().mark_parsed(conn, resp.fetch_id, resp.sha256, adapter.parser_version) conn.commit() except Exception as exc: conn.rollback() ctx.stats["errors"] += 1 ctx.stats["parse_errors"] += 1 ctx.log.exception("parse/load failed for %s", resp.url) if resp.fetch_id is not None: Archive().mark_error(conn, resp.fetch_id, f"{type(exc).__name__}: {exc}") conn.commit() loader.resolver = type(loader.resolver)(conn) # drop caches that may reference rolled-back rows finally: ctx.response = None return follow def _reparse(adapter: Adapter, ctx: RunContext, conn: Connection) -> None: archive = Archive() loader = Loader(conn, ctx) started = conn.execute("SELECT now() AS t").fetchone()["t"] rows = archive.latest_stored(conn, adapter.name) ctx.log.info("reparsing %s archived responses", len(rows)) # Parse list/index responses before detail responses: records referencing others (vote positions -> # vote events) are emitted by the parent's parse() in order, so the original order is preserved # by sorting on fetch time. for row in sorted(rows, key=lambda r: r["fetched_at"]): body = archive.read_blob(row["sha256"]) if body is None: ctx.stats["missing_blobs"] += 1 continue req_info = row["request"] or {} req = Request(url=row["url"], kind=req_info.get("kind", "default"), method=row["method"], params=req_info.get("params"), json=req_info.get("json"), meta=req_info.get("meta") or {}) resp = Response(request=req, status=row["status"], url=row["url"], headers={"content-type": row["content_type"] or ""}, body=body, fetched_at=row["fetched_at"], sha256=row["sha256"], fetch_id=row["id"], tdm_reserved=row["tdm_reserved"], from_archive=True) ctx.stats["responses"] += 1 _parse_and_load(adapter, ctx, conn, loader, resp) # follow-ups are archived separately if not ctx.stats.get("errors") and not ctx.stats.get("missing_blobs"): _sweep_stale_claims(adapter, ctx, conn, started) def _sweep_stale_claims(adapter: Adapter, ctx: RunContext, conn: Connection, started: datetime) -> None: """After a complete reparse, delete this adapter's claims that the current parser no longer produces (e.g. a fixed parser emits a corrected fact with a new identity) - unless another source backs them. Claims are reproducible from the archive, so nothing is lost; facets and evidence links cascade.""" deleted = conn.execute( """DELETE FROM claim c WHERE c.created_by = %(adapter)s AND c.updated_at < %(started)s AND NOT EXISTS (SELECT 1 FROM claim_evidence e JOIN document d ON d.id = e.document_id JOIN source s ON s.id = d.source_id WHERE e.claim_id = c.id AND s.slug <> %(source)s)""", {"adapter": adapter.name, "started": started, "source": adapter.spec.slug}, ).rowcount conn.commit() ctx.stats["stale_claims_deleted"] = deleted def _run_enricher(enricher: Enricher, ctx: RunContext, conn: Connection) -> None: loader = Loader(conn, ctx) result = enricher.run(ctx) pending = 0 for record in result or []: try: loader.load(record) except Exception: conn.rollback() ctx.stats["errors"] += 1 ctx.log.exception("enricher %s failed to load a record", enricher.name) loader = Loader(conn, ctx) continue pending += 1 if pending >= ENRICH_COMMIT_EVERY: conn.commit() pending = 0 conn.commit() def parse_offline(adapter: Adapter, response: Response, params: dict[str, Any] | None = None) -> Iterable[Any]: """Run parse() without a database (golden tests, debugging).""" ctx = RunContext(adapter=adapter.name, spec=adapter.spec, state=StateStore(None, adapter.name), mode="test", params=params or {}) ctx.response = response return list(adapter.parse(ctx, response)) def make_response(url: str, body: bytes, *, kind: str = "default", meta: dict | None = None, content_type: str = "", method: str = "GET", json_body: Any = None, params: dict | None = None) -> Response: """Build a Response for tests/fixtures.""" import hashlib req = Request(url=url, kind=kind, meta=meta or {}, method=method, json=json_body, params=params) return Response(request=req, status=200, url=url, headers={"content-type": content_type}, body=body, fetched_at=datetime(2026, 1, 1, tzinfo=timezone.utc), sha256=hashlib.sha256(body).hexdigest())