| 1 | """ |
| 2 | Bill summaries ingest - CRS plain-language summaries from GovInfo BILLSTATUS XML. |
| 3 | |
| 4 | The BILLSTATUS XML already fetched by bills_bulk contains a <summaries> element |
| 5 | with official CRS (Congressional Research Service) text. This module backfills |
| 6 | the `summary` and `summary_source` columns on the `bills` table by re-fetching |
| 7 | those XMLs for bills that don't have a summary yet. |
| 8 | |
| 9 | Source: GovInfo BILLSTATUS (bulkdata, keyless) |
| 10 | https://www.govinfo.gov/bulkdata/BILLSTATUS/{congress}/{type}/BILLSTATUS-{congress}{type}{num}.xml |
| 11 | |
| 12 | Usage |
| 13 | ----- |
| 14 | python -m ingest.bill_summaries --once |
| 15 | python -m ingest.bill_summaries --once --congress 119 --limit 500 |
| 16 | python -m ingest.bill_summaries --once --types hr s |
| 17 | |
| 18 | The summary text is stored verbatim (US gov CRS text, public domain). HTML tags |
| 19 | are stripped; HTML entities are decoded. Attribution: "CRS via GovInfo". |
| 20 | """ |
| 21 | |
| 22 | import argparse |
| 23 | import html |
| 24 | import logging |
| 25 | import re |
| 26 | import sqlite3 |
| 27 | import sys |
| 28 | import time |
| 29 | import xml.etree.ElementTree as ET |
| 30 | from typing import List, Optional |
| 31 | |
| 32 | import requests |
| 33 | |
| 34 | from ingest.db import get_connection, init_schema |
| 35 | |
| 36 | logger = logging.getLogger(__name__) |
| 37 | |
| 38 | SOURCE = "govinfo_billstatus" |
| 39 | SUMMARY_SOURCE = "CRS via GovInfo" |
| 40 | BULKDATA_BASE = "https://www.govinfo.gov/bulkdata/BILLSTATUS" |
| 41 | |
| 42 | BILL_TYPES = ["hr", "s", "hjres", "sjres", "hconres", "sconres", "hres", "sres"] |
| 43 | |
| 44 | _HTML_TAG_RE = re.compile(r"<[^>]+>") |
| 45 | _BLOCK_TAG_RE = re.compile(r"</?(p|div|br|li|ul|ol|h[1-6]|tr|table)\b[^>]*>", re.I) |
| 46 | _WS_RUN_RE = re.compile(r"[ \t]+") |
| 47 | _BLANKS_RE = re.compile(r"\n{3,}") |
| 48 | |
| 49 | |
| 50 | |
| 51 | def _ensure_columns(conn: sqlite3.Connection) -> None: |
| 52 | """Add summary / summary_source columns to bills if they don't exist yet.""" |
| 53 | for col, typedef in [("summary", "TEXT"), ("summary_source", "TEXT")]: |
| 54 | try: |
| 55 | conn.execute(f"ALTER TABLE bills ADD COLUMN {col} {typedef}") |
| 56 | conn.commit() |
| 57 | logger.info("Added column bills.%s", col) |
| 58 | except sqlite3.OperationalError: |
| 59 | pass |
| 60 | |
| 61 | |
| 62 | |
| 63 | def _parse_external_id(external_id: str): |
| 64 | """ |
| 65 | Decompose an external_id like '119-hr-160' or '119-hconres-1' into |
| 66 | (congress: int, bill_type: str, number: str). |
| 67 | """ |
| 68 | congress_str, rest = external_id.split("-", 1) |
| 69 | bill_type, number = rest.rsplit("-", 1) |
| 70 | return int(congress_str), bill_type, number |
| 71 | |
| 72 | |
| 73 | def _billstatus_url(congress: int, bill_type: str, number: str) -> str: |
| 74 | return ( |
| 75 | f"{BULKDATA_BASE}/{congress}/{bill_type}/" |
| 76 | f"BILLSTATUS-{congress}{bill_type}{number}.xml" |
| 77 | ) |
| 78 | |
| 79 | |
| 80 | def _strip_html(raw: str) -> str: |
| 81 | """Decode HTML entities and strip HTML tags from a CRS summary text field. |
| 82 | |
| 83 | Block tags -> newline, inline tags -> space, so sentences and words never |
| 84 | concatenate ("voters.</p><p>First" -> "voters.\n\nFirst", not "voters.First"). |
| 85 | """ |
| 86 | unescaped = html.unescape(raw) |
| 87 | text = _BLOCK_TAG_RE.sub("\n", unescaped) |
| 88 | text = _HTML_TAG_RE.sub(" ", text) |
| 89 | text = _WS_RUN_RE.sub(" ", text) |
| 90 | text = "\n".join(line.strip() for line in text.split("\n")) |
| 91 | text = _BLANKS_RE.sub("\n\n", text) |
| 92 | return text.strip() |
| 93 | |
| 94 | |
| 95 | def _extract_summary(xml_bytes: bytes) -> Optional[str]: |
| 96 | """ |
| 97 | Parse a BILLSTATUS XML byte string and return the best available CRS |
| 98 | summary text (plain text, HTML-stripped), or None if not present. |
| 99 | |
| 100 | Strategy: take the item whose <updateDate> is latest. Falls back to the |
| 101 | last <item> in document order if dates are missing. |
| 102 | """ |
| 103 | try: |
| 104 | root = ET.fromstring(xml_bytes) |
| 105 | except ET.ParseError: |
| 106 | return None |
| 107 | |
| 108 | bill_el = root.find("bill") |
| 109 | if bill_el is None: |
| 110 | if root.tag in ("bill", "Bill"): |
| 111 | bill_el = root |
| 112 | else: |
| 113 | return None |
| 114 | |
| 115 | summaries_el = bill_el.find("summaries") |
| 116 | if summaries_el is None: |
| 117 | return None |
| 118 | |
| 119 | items = summaries_el.findall("summary") |
| 120 | if not items: |
| 121 | return None |
| 122 | |
| 123 | best_item = None |
| 124 | best_date = "" |
| 125 | for item in items: |
| 126 | upd_el = item.find("updateDate") |
| 127 | upd = upd_el.text.strip() if upd_el is not None and upd_el.text else "" |
| 128 | if upd >= best_date: |
| 129 | best_date = upd |
| 130 | best_item = item |
| 131 | |
| 132 | if best_item is None: |
| 133 | return None |
| 134 | |
| 135 | text_el = best_item.find("text") |
| 136 | if text_el is None or not text_el.text: |
| 137 | return None |
| 138 | |
| 139 | return _strip_html(text_el.text) or None |
| 140 | |
| 141 | |
| 142 | |
| 143 | def poll( |
| 144 | congress: int = 119, |
| 145 | bill_types: Optional[List[str]] = None, |
| 146 | db_path: Optional[str] = None, |
| 147 | limit: Optional[int] = 500, |
| 148 | delay: float = 0.1, |
| 149 | ) -> int: |
| 150 | """ |
| 151 | Backfill CRS summaries for bills that don't have one yet. |
| 152 | |
| 153 | Fetches BILLSTATUS XML from GovInfo (no API key required) and stores the |
| 154 | best available summary text. Idempotent: skips bills that already have a |
| 155 | summary. Polite: `delay` seconds between requests. |
| 156 | |
| 157 | Parameters |
| 158 | ---------- |
| 159 | congress : Congress number to process (default 119). |
| 160 | bill_types : Bill types to process (default = all types in BILL_TYPES). |
| 161 | db_path : Override SQLite path. |
| 162 | limit : Maximum bills to process per run (None = unlimited). |
| 163 | delay : Seconds between GovInfo requests. |
| 164 | |
| 165 | Returns |
| 166 | ------- |
| 167 | Number of summaries newly stored. |
| 168 | """ |
| 169 | if bill_types is None: |
| 170 | bill_types = BILL_TYPES |
| 171 | |
| 172 | conn = get_connection(db_path) if db_path else get_connection() |
| 173 | init_schema(conn) |
| 174 | _ensure_columns(conn) |
| 175 | |
| 176 | session = requests.Session() |
| 177 | session.headers["User-Agent"] = "Docket-ingest/1.0 (public transparency research)" |
| 178 | |
| 179 | type_set = set(bill_types) |
| 180 | |
| 181 | rows = conn.execute( |
| 182 | """ |
| 183 | SELECT id, external_id |
| 184 | FROM bills |
| 185 | WHERE source = ? |
| 186 | AND congress = ? |
| 187 | AND (summary IS NULL OR summary = '') |
| 188 | ORDER BY id |
| 189 | """, |
| 190 | (SOURCE, congress), |
| 191 | ).fetchall() |
| 192 | |
| 193 | logger.info( |
| 194 | "bill_summaries: %d congress-%d bills missing summaries (types=%s, limit=%s)", |
| 195 | len(rows), congress, bill_types, limit, |
| 196 | ) |
| 197 | |
| 198 | stored = 0 |
| 199 | skipped_type = 0 |
| 200 | |
| 201 | for row in rows: |
| 202 | if limit is not None and stored >= limit: |
| 203 | logger.info("bill_summaries: limit %d reached, stopping.", limit) |
| 204 | break |
| 205 | |
| 206 | ext_id = row["external_id"] |
| 207 | bill_id = row["id"] |
| 208 | |
| 209 | try: |
| 210 | cong, bill_type, number = _parse_external_id(ext_id) |
| 211 | except Exception: |
| 212 | logger.debug("Cannot parse external_id %s, skipping.", ext_id) |
| 213 | continue |
| 214 | |
| 215 | if bill_type not in type_set: |
| 216 | skipped_type += 1 |
| 217 | continue |
| 218 | |
| 219 | url = _billstatus_url(cong, bill_type, number) |
| 220 | logger.debug("Fetching BILLSTATUS: %s", url) |
| 221 | |
| 222 | try: |
| 223 | r = session.get(url, timeout=30, allow_redirects=True) |
| 224 | except requests.RequestException as exc: |
| 225 | logger.warning("Fetch failed for %s: %s", ext_id, exc) |
| 226 | continue |
| 227 | |
| 228 | if r.status_code != 200: |
| 229 | logger.debug("HTTP %d for %s", r.status_code, ext_id) |
| 230 | if delay > 0: |
| 231 | time.sleep(delay) |
| 232 | continue |
| 233 | |
| 234 | summary_text = _extract_summary(r.content) |
| 235 | if not summary_text: |
| 236 | logger.debug("No summary in BILLSTATUS for %s", ext_id) |
| 237 | if delay > 0: |
| 238 | time.sleep(delay) |
| 239 | continue |
| 240 | |
| 241 | conn.execute( |
| 242 | "UPDATE bills SET summary=?, summary_source=? WHERE id=?", |
| 243 | (summary_text, SUMMARY_SOURCE, bill_id), |
| 244 | ) |
| 245 | stored += 1 |
| 246 | |
| 247 | if stored % 50 == 0: |
| 248 | conn.commit() |
| 249 | logger.info("bill_summaries: %d summaries committed so far", stored) |
| 250 | |
| 251 | if delay > 0: |
| 252 | time.sleep(delay) |
| 253 | |
| 254 | conn.commit() |
| 255 | conn.close() |
| 256 | |
| 257 | logger.info( |
| 258 | "bill_summaries: done - %d summaries stored, %d skipped (type filter)", |
| 259 | stored, skipped_type, |
| 260 | ) |
| 261 | return stored |
| 262 | |
| 263 | |
| 264 | |
| 265 | if __name__ == "__main__": |
| 266 | logging.basicConfig( |
| 267 | level=logging.INFO, |
| 268 | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", |
| 269 | stream=sys.stdout, |
| 270 | ) |
| 271 | ap = argparse.ArgumentParser( |
| 272 | description="Backfill CRS summaries from GovInfo BILLSTATUS XML" |
| 273 | ) |
| 274 | ap.add_argument("--once", action="store_true", help="Run once and exit") |
| 275 | ap.add_argument("--congress", type=int, default=119, help="Congress number (default 119)") |
| 276 | ap.add_argument( |
| 277 | "--types", nargs="+", default=None, |
| 278 | help="Bill types to process (hr s hjres …); default = all", |
| 279 | ) |
| 280 | ap.add_argument( |
| 281 | "--limit", type=int, default=500, |
| 282 | help="Max summaries to fetch per run (default 500; 0 = unlimited)", |
| 283 | ) |
| 284 | ap.add_argument( |
| 285 | "--delay", type=float, default=0.1, |
| 286 | help="Seconds between GovInfo requests (default 0.1)", |
| 287 | ) |
| 288 | args = ap.parse_args() |
| 289 | |
| 290 | limit_val = args.limit if args.limit and args.limit > 0 else None |
| 291 | n = poll( |
| 292 | congress=args.congress, |
| 293 | bill_types=args.types, |
| 294 | limit=limit_val, |
| 295 | delay=args.delay, |
| 296 | ) |
| 297 | print(f"Done: {n} summaries stored") |