● Zion Boggan
repos/Docket/services/ingest/ingest/bill_summaries.py
zionboggan.com ↗
297 lines · python
History for this file →
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")