Search, chat and research over parliamentary speeches and documents
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

"""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]