You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
209 lines
7.8 KiB
209 lines
7.8 KiB
"""Generic fetch → adapt → upsert pipeline. |
|
|
|
Country-neutral: every source-specific decision comes from `sources:` in |
|
parliament.yaml and from the adapter named there. |
|
""" |
|
from __future__ import annotations |
|
|
|
import io |
|
import json |
|
import zipfile |
|
from pathlib import Path |
|
from typing import Callable, Iterator, Optional |
|
|
|
import requests |
|
|
|
from bootstrap import DATA_DIR |
|
from parliament import PARLIAMENT |
|
from postgres_client import pg |
|
|
|
# Rows per INSERT. Large enough that round-trips are not the bottleneck, small |
|
# enough that one bad batch is cheap to retry. |
|
BATCH = 500 |
|
|
|
|
|
# ── fetching ────────────────────────────────────────────────────────────────── |
|
|
|
|
|
def source_config(name: str) -> dict: |
|
sources = PARLIAMENT.sources |
|
if name not in sources: |
|
available = [k for k in sources if k != "adapter"] |
|
raise ValueError(f"No source {name!r} in parliament.yaml; have {available}") |
|
return sources[name] |
|
|
|
|
|
def dest_dir(name: str) -> Path: |
|
return DATA_DIR / source_config(name).get("dest_dir", name) |
|
|
|
|
|
def fetch(name: str, ranges: Optional[list[str]] = None) -> list[Path]: |
|
"""Download a source's bulk archives and unpack them under DATA_DIR. |
|
|
|
Archives already unpacked are skipped, so this is safe to re-run and cheap to |
|
resume after an interruption — which matters when a full download is tens of GB. |
|
""" |
|
cfg = source_config(name) |
|
kind = cfg.get("kind", "zip-dataset") |
|
target = dest_dir(name) |
|
target.mkdir(parents=True, exist_ok=True) |
|
|
|
if kind == "json": |
|
out = target / f"{name}.json" |
|
out.write_bytes(requests.get(cfg["url"], timeout=120).content) |
|
print(f" fetched {out.relative_to(DATA_DIR)}") |
|
return [out] |
|
|
|
if kind != "zip-dataset": |
|
raise ValueError(f"Unsupported source kind {kind!r} for {name}") |
|
|
|
wanted = ranges or cfg.get("ranges") |
|
if not wanted: |
|
raise ValueError( |
|
f"Source {name!r} has no `ranges:` in parliament.yaml and none was given. " |
|
f"Pass --range, or list them in the config." |
|
) |
|
|
|
written = [] |
|
for rng in wanted: |
|
folder = target / rng |
|
if folder.exists() and any(folder.iterdir()): |
|
print(f" {rng}: already present, skipping") |
|
continue |
|
url = cfg["url_template"].format(range=rng) |
|
print(f" {rng}: downloading {url}") |
|
resp = requests.get(url, timeout=1800) |
|
if resp.status_code != 200: |
|
print(f" {rng}: HTTP {resp.status_code}, skipping") |
|
continue |
|
folder.mkdir(parents=True, exist_ok=True) |
|
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf: |
|
zf.extractall(folder) |
|
written.append(folder) |
|
print(f" {rng}: unpacked {len(list(folder.rglob('*.json')))} files") |
|
return written |
|
|
|
|
|
def read_records(name: str) -> Iterator[dict]: |
|
"""Yield every source record on disk for a source.""" |
|
root = dest_dir(name) |
|
if not root.exists(): |
|
raise FileNotFoundError(f"No data at {root}. Run `ingest.cli fetch --source {name}` first.") |
|
for path in sorted(root.rglob("*.json")): |
|
try: |
|
# utf-8-sig: the archives carry a byte-order mark. |
|
with open(path, encoding="utf-8-sig") as fh: |
|
payload = json.load(fh) |
|
except (json.JSONDecodeError, OSError) as exc: |
|
print(f" skipping {path.name}: {exc}") |
|
continue |
|
# A people listing is one file holding many records. |
|
if isinstance(payload, dict) and "personlista" in payload: |
|
for person in payload["personlista"].get("person", []): |
|
yield person |
|
else: |
|
yield payload |
|
|
|
|
|
# ── loading ─────────────────────────────────────────────────────────────────── |
|
|
|
|
|
def _insert(table: str, columns: list[str], rows: list[tuple], conflict: str) -> int: |
|
if not rows: |
|
return 0 |
|
sql = ( |
|
f"INSERT INTO {table} ({', '.join(columns)}) VALUES %s " |
|
f"ON CONFLICT ({conflict}) DO NOTHING" |
|
) |
|
pg.execute_values(sql, rows) |
|
return len(rows) |
|
|
|
|
|
def _flush(table: str, columns: list[str], conflict: str, buffer: list[dict]) -> int: |
|
"""Insert and clear a buffer. Argument order matches `_target()` unpacking.""" |
|
rows = [tuple(r.get(c) for c in columns) for r in buffer] |
|
n = _insert(table, columns, rows, conflict) |
|
buffer.clear() |
|
return n |
|
|
|
|
|
SPEECH_COLUMNS = [ |
|
"id", "source_speech_id", "source_doc_id", "text", "section_title", "sequence", |
|
"activity_type", "speaker_name", "party", "person_id", "date", "source_datetime", |
|
"year", "session_year", "related_doc_id", "source_doc_number", "source_record_id", |
|
"title", "is_reply", |
|
] |
|
DOCUMENT_COLUMNS = [ |
|
"doc_id", "doc_type", "source_record_id", "session_label", "designation", "subtype", |
|
"committee", "status", "date", "source_updated_at", "published_at", "session_year", |
|
"title", "subtitle", "text", "proposals_text", "has_text", "url_text", "url_html", |
|
"url_pdf", "parties", "author_names", "proposals_raw", "attachments", "num_proposals", |
|
] |
|
AUTHOR_COLUMNS = ["doc_id", "ordinal", "person_id", "name", "party", "role"] |
|
PROPOSAL_COLUMNS = [ |
|
"id", "doc_id", "ordinal", "number", "text", |
|
"committee_recommendation", "chamber_decision", "handled_in", |
|
] |
|
PERSON_COLUMNS = [ |
|
"person_id", "source_record_id", "source_record_guid", "source_id", "birth_year", |
|
"gender", "last_name", "first_name", "sort_name", "home_town", "party", |
|
"constituency", "status", "source_url", "image_url_small", "image_url_medium", |
|
"image_url_large", "assignments", "contact_details", "name", "active", |
|
] |
|
|
|
|
|
def _json_columns(rows: list[dict], columns: Iterator[str]) -> None: |
|
"""psycopg2 cannot adapt dicts/lists destined for JSONB; serialise them.""" |
|
for row in rows: |
|
for col in columns: |
|
if isinstance(row.get(col), (dict, list)): |
|
row[col] = json.dumps(row[col], ensure_ascii=False) |
|
|
|
|
|
def load(name: str, adapt: Callable[[str, dict], Optional[dict]], limit: Optional[int] = None) -> dict: |
|
"""Adapt every record on disk for a source and upsert it.""" |
|
counts = {"read": 0, "written": 0, "skipped": 0} |
|
buf: list[dict] = [] |
|
authors: list[dict] = [] |
|
proposals: list[dict] = [] |
|
|
|
for payload in read_records(name): |
|
if limit and counts["read"] >= limit: |
|
break |
|
counts["read"] += 1 |
|
row = adapt(name, payload) |
|
if row is None: |
|
counts["skipped"] += 1 |
|
continue |
|
|
|
if name == "documents": |
|
_json_columns([row["document"]], ("proposals_raw", "attachments")) |
|
buf.append(row["document"]) |
|
authors.extend(row["authors"]) |
|
proposals.extend(row["proposals"]) |
|
else: |
|
if name == "people": |
|
_json_columns([row], ("assignments", "contact_details")) |
|
buf.append(row) |
|
|
|
if len(buf) >= BATCH: |
|
counts["written"] += _flush(*_target(name), buf) |
|
if authors: |
|
_flush("document_authors", AUTHOR_COLUMNS, "doc_id, ordinal", authors) |
|
if proposals: |
|
_flush("document_proposals", PROPOSAL_COLUMNS, "id", proposals) |
|
|
|
counts["written"] += _flush(*_target(name), buf) |
|
if authors: |
|
_flush("document_authors", AUTHOR_COLUMNS, "doc_id, ordinal", authors) |
|
if proposals: |
|
_flush("document_proposals", PROPOSAL_COLUMNS, "id", proposals) |
|
return counts |
|
|
|
|
|
def _target(name: str) -> tuple[str, list[str], str]: |
|
return { |
|
"speeches": ("speeches", SPEECH_COLUMNS, "id"), |
|
"documents": ("documents", DOCUMENT_COLUMNS, "doc_id"), |
|
"people": ("people", PERSON_COLUMNS, "person_id"), |
|
}[name]
|
|
|