| 1 | """ |
| 2 | GovInfo API poller - full bill text, Congressional Record, public laws. |
| 3 | |
| 4 | Endpoint: GET https://api.govinfo.gov/published/{start}/{end} |
| 5 | Auth: same api.data.gov key as Congress.gov (DOCKET_CONGRESS_KEY) |
| 6 | Rate: 36,000 req/hr - most generous of the four primary sources. |
| 7 | Lag: 1-2 business days behind the legislative action. |
| 8 | |
| 9 | If DOCKET_CONGRESS_KEY is not set, this poller skips gracefully. |
| 10 | """ |
| 11 | |
| 12 | import json |
| 13 | import logging |
| 14 | import os |
| 15 | import sqlite3 |
| 16 | from datetime import datetime, timedelta |
| 17 | from typing import Optional |
| 18 | |
| 19 | import requests |
| 20 | |
| 21 | from ingest.change_detection import is_new_event, mark_seen |
| 22 | from ingest.db import get_connection, init_schema |
| 23 | from ingest.source_validator import validate_source_url |
| 24 | |
| 25 | logger = logging.getLogger(__name__) |
| 26 | |
| 27 | SOURCE = "govinfo" |
| 28 | BASE_URL = "https://api.govinfo.gov" |
| 29 | |
| 30 | COLLECTIONS = ["BILLS", "PLAW", "CREC"] |
| 31 | |
| 32 | |
| 33 | def poll(days_back: int = 2, db_path: Optional[str] = None) -> int: |
| 34 | """ |
| 35 | Fetch recently published GovInfo packages (bill text, public laws, CR). |
| 36 | |
| 37 | If DOCKET_CONGRESS_KEY is not set, skips gracefully and returns 0. |
| 38 | """ |
| 39 | api_key = os.environ.get("DOCKET_CONGRESS_KEY") |
| 40 | if not api_key: |
| 41 | logger.warning( |
| 42 | "GovInfo poller: DOCKET_CONGRESS_KEY is not set. " |
| 43 | "Skipping GovInfo source (same key as Congress.gov - see congress.py)." |
| 44 | ) |
| 45 | return 0 |
| 46 | |
| 47 | end_dt = datetime.utcnow() |
| 48 | start_dt = end_dt - timedelta(days=days_back) |
| 49 | start_str = start_dt.strftime("%Y-%m-%dT%H:%M:%SZ") |
| 50 | end_str = end_dt.strftime("%Y-%m-%dT%H:%M:%SZ") |
| 51 | |
| 52 | conn = get_connection(db_path) if db_path else get_connection() |
| 53 | init_schema(conn) |
| 54 | stored = 0 |
| 55 | |
| 56 | for collection in COLLECTIONS: |
| 57 | url = f"{BASE_URL}/published/{start_str}/{end_str}" |
| 58 | params = { |
| 59 | "api_key": api_key, |
| 60 | "collection": collection, |
| 61 | "pageSize": 100, |
| 62 | } |
| 63 | try: |
| 64 | resp = requests.get(url, params=params, timeout=30) |
| 65 | resp.raise_for_status() |
| 66 | except requests.RequestException as exc: |
| 67 | logger.error("GovInfo %s request failed: %s", collection, exc) |
| 68 | continue |
| 69 | |
| 70 | data = resp.json() |
| 71 | packages = data.get("packages", []) |
| 72 | logger.info("GovInfo %s: received %d package(s)", collection, len(packages)) |
| 73 | |
| 74 | for pkg in packages: |
| 75 | pkg_id = pkg.get("packageId", "") |
| 76 | update_date = pkg.get("dateIssued", pkg.get("lastModified", "")) |
| 77 | |
| 78 | if not pkg_id or not is_new_event(conn, SOURCE, pkg_id, update_date): |
| 79 | continue |
| 80 | |
| 81 | source_url = pkg.get("packageLink", f"https://www.govinfo.gov/content/pkg/{pkg_id}/pdf/{pkg_id}.pdf") |
| 82 | try: |
| 83 | validate_source_url(source_url) |
| 84 | except Exception as exc: |
| 85 | logger.error("GovInfo: invalid source_url %s: %s", source_url, exc) |
| 86 | continue |
| 87 | |
| 88 | conn.execute( |
| 89 | """ |
| 90 | INSERT INTO raw_events (source, external_id, payload, fetched_at) |
| 91 | VALUES (?, ?, ?, ?) |
| 92 | ON CONFLICT(source, external_id) DO UPDATE SET |
| 93 | payload = excluded.payload, |
| 94 | fetched_at = excluded.fetched_at |
| 95 | """, |
| 96 | (SOURCE, pkg_id, json.dumps(pkg), datetime.utcnow().isoformat()), |
| 97 | ) |
| 98 | |
| 99 | mark_seen(conn, SOURCE, pkg_id, update_date) |
| 100 | stored += 1 |
| 101 | |
| 102 | conn.commit() |
| 103 | conn.close() |
| 104 | logger.info("GovInfo: stored %d new package(s)", stored) |
| 105 | return stored |