| @@ -1,9 +1,58 @@ | ||
| # Docket - Frontend Notes | ||
| - | Wave F2 + F3 + F4 build log. All surfaces consume the live read-API | |
| + | Wave F2 + F3 + F4 + F5 build log. All surfaces consume the live read-API | |
| (`app/api/*` → `lib/feed.ts` → SQLite at `data/docket.db`). No mock data on | ||
| the deep surfaces; `lib/mock-data.ts` remains only for the Phase-0 lint | ||
| - | reference and is not imported by any F2/F3/F4 page. | |
| + | reference and is not imported by any F2/F3/F4/F5 page. | |
| + | ||
| + | ## F5 (this wave) - mobile-responsive overhaul (GLM start / Claude finish) | |
| + | ||
| + | ### Zero-overflow guarantee at 390px | |
| + | ||
| + | **`DataTable` component** (`components/data-table.tsx`) - the single responsive | |
| + | list primitive used across all table pages. `>=md`: a real `<table>` with | |
| + | `table-fixed` layout (prevents auto-layout column expansion) inside an | |
| + | `overflow-x-auto` scroll wrapper. `<md`: stacked labeled cards with a cardTitle | |
| + | row + a definition list of labeled fields. `[contain:layout]` + `overflow:hidden` | |
| + | on the wrapping `Card` prevent the desktop table from escaping into page layout. | |
| + | ||
| + | **`/members/[id]` page** - the only route that was still failing QA after GLM. | |
| + | Root cause: the desktop vote-history table had `table-layout: auto` and long | |
| + | vote question text, causing `scrollWidth` to balloon to ~1971px on mobile. | |
| + | Fixes: (1) `table-fixed` on all DataTable tables, (2) `overflow-hidden` on each | |
| + | `<details>` year section and its content wrapper, (3) `overflow-x-hidden` added | |
| + | to the page-level container `div` - this turns it into a proper BFC that clips | |
| + | any descendant content before it can inflate `document.documentElement.scrollWidth`. | |
| + | QA result: **QA CLEAN across all 17 routes**. | |
| + | ||
| + | ### Site header | |
| + | Hamburger on mobile (separate from DisplayToggle), `>=44px` tap targets, body | |
| + | scroll lock, Escape key closes menu. Done by GLM; untouched in F5. | |
| + | ||
| + | ### Calendar | |
| + | Desktop month-grid (`hidden md:block`) + `MobileAgenda` day-grouped list | |
| + | (`md:hidden`). Done by GLM; untouched in F5. | |
| + | ||
| + | ### States page | |
| + | "50 states + DC & territories" label; un-ingested coverage shown as muted | |
| + | "Coverage rolling out" (not red). Done by GLM; untouched in F5. | |
| + | ||
| + | ### CSP - photo sources | |
| + | `next.config.js` `img-src` directive extended to allow headshots from | |
| + | `https://bioguide.congress.gov` and `https://upload.wikimedia.org` in addition | |
| + | to the existing `https://unitedstates.github.io` source. | |
| + | ||
| + | ### Members roster UX - current Congress first | |
| + | `MemberSummary.isCurrent` added (`lib/feed.ts`) - `true` for members with | |
| + | numeric IDs ≤ 600 (the 119th-Congress ingest batch, IDs 1-537, with a safe | |
| + | buffer). `MembersBrowser` (`components/members-browser.tsx`) now: | |
| + | - Default sort: current Congress first → historical-with-photo → historical-without-photo | |
| + | - Default visible: current Congress + historical-with-photo (~2,917 members) | |
| + | - Historical members without a photo (~9,850) are hidden behind a | |
| + | "Show all N historical members" toggle; filter bar search always reveals all | |
| + | - When any filter is active, the toggle is suppressed and all matching members | |
| + | are shown (the user is looking for something specific) | |
| + | - Neutral copy: "Showing current Congress and members with a recorded photo" | |
| ## F4 (this wave) - faces + schedule + fill the gaps | ||
| @@ -0,0 +1,124 @@ | ||
| + | # Docket State Ingest Fix Notes | |
| + | ||
| + | Date: 2026-06-26 | |
| + | ||
| + | ## What was broken | |
| + | ||
| + | ### Primary bug - no `__main__` block in either poller | |
| + | ||
| + | `python -m ingest.pollers.legiscan --once` and `python -m ingest.pollers.openstates --once` both produced zero output and zero rows because neither module had a `__main__` block. Python imports the module successfully but executes nothing. The `poll()` functions existed but were never called. | |
| + | ||
| + | ### Secondary bug - legiscan poll() strategy was inefficient and produced zero rows | |
| + | ||
| + | The original `poll()` called `getMasterListRaw` to get change hashes, then called `getBill` for each changed bill (capped at 50 per state). Inside `_store_bill()`, the code checked `state_link` against `is_allowlisted()` and skipped bills where `state_link` was empty or not in the allowlist. `getMasterList` does not include `state_link` in the compact masterlist payload - only the legiscan.com `url` field is present - so the allowlist check rejected every bill fetched. | |
| + | ||
| + | ### Tertiary bug - state_coverage wrote "missing" rows for openstates (never run) | |
| + | ||
| + | `state_coverage.py`'s `compute()` iterated over all sources in `SOURCES = ["openstates", "legiscan"]` regardless of whether either source had ever been run. This wrote "missing" rows for openstates across all 52 states. The frontend `rollupConfidence()` uses worst-case logic: if any confidence value is "missing", the state shows as missing. Result: all 52 states showed as missing even after legiscan ingested data for 51 of them. | |
| + | ||
| + | ### Also fixed - jurisdiction column not in schema | |
| + | ||
| + | The `bills` table schema in `db.py` did not include `jurisdiction TEXT`. The pollers add it via `ALTER TABLE` but that fails silently when the DB is locked (another process holds a write lock). Added `jurisdiction TEXT` to `init_schema()` so new databases always have it. | |
| + | ||
| + | ## Fixes applied | |
| + | ||
| + | ### 1. `services/ingest/ingest/pollers/legiscan.py` - rewritten | |
| + | ||
| + | - Added `main()` + `__main__` so `python -m ingest.pollers.legiscan --once` actually runs | |
| + | - Replaced getMasterListRaw + getBill-per-bill with `getMasterList` per session: | |
| + | - `getMasterList` returns all bills in a session in one API call including title, number, status, last_action, url, change_hash | |
| + | - No per-bill `getBill` calls needed for basic state coverage | |
| + | - Total API calls: ~2 per state (getSessionList + getMasterList) = ~104 for all 52 jurisdictions | |
| + | - `source_url`: uses `state_link` when present and allowlisted; falls back to the legiscan.com `url` field (acceptable per INGESTION_STRATEGY.md when no official state link is in the masterlist payload) | |
| + | - `_STATUS_MAP`: maps LegiScan numeric status codes (0-12) to human-readable strings | |
| + | - Default state list changed to `MVP_STATES` (32 states); `--all` flag covers all 52 | |
| + | - Idempotent via `change_hash` stored in the `seen` table | |
| + | ||
| + | ### 2. `services/ingest/ingest/pollers/openstates.py` - added CLI | |
| + | ||
| + | - Added `import argparse`, `import sys` | |
| + | - Added `main()` + `__main__` so `python -m ingest.pollers.openstates --once` runs | |
| + | - `--days-back N` flag (default 7); `--all` for all 52 jurisdictions; `--db` override | |
| + | - No logic changes; poll() was already correct | |
| + | ||
| + | ### 3. `services/ingest/ingest/state_coverage.py` - skip sources with no bill data | |
| + | ||
| + | - `compute()` now checks which sources have at least one row in the `bills` table | |
| + | - Only writes `state_coverage` rows for sources with actual bill data (not just scrape attempts) | |
| + | - This prevents a source that ran but fetched 0 bills (e.g., due to network failure) from writing "missing" rows for all states and dragging down the worst-case rollup | |
| + | - If no source has any bill data, falls back to the full SOURCES list so the UI shows honest full-missing rather than empty coverage | |
| + | - Stale openstates "missing" rows deleted from existing DB directly | |
| + | ||
| + | ### 4. `services/ingest/ingest/db.py` - jurisdiction column | |
| + | ||
| + | - Added `jurisdiction TEXT` to the `bills` table `CREATE TABLE IF NOT EXISTS` statement | |
| + | - No data migration needed for existing rows (column defaults to NULL) | |
| + | ||
| + | ## Results | |
| + | ||
| + | Run on 2026-06-26 against `DOCKET_DB_PATH=/opt/docket/data/docket.db`: | |
| + | ||
| + | | Metric | Value | | |
| + | |--------|-------| | |
| + | | States ingested | 51 (all 50 states + DC) | | |
| + | | PR status | Missing - LegiScan returns ERROR for PR | | |
| + | | Total new state bills stored | 191,233 | | |
| + | | Total bills in DB (all sources) | 208,382 | | |
| + | | API calls used | 103 | | |
| + | | Runtime | ~2 minutes | | |
| + | ||
| + | State coverage after ingest: | |
| + | ``` | |
| + | ok (fresh) : 51 | |
| + | stale : 0 | |
| + | missing : 1 (PR only) | |
| + | ``` | |
| + | ||
| + | Top 5 states by bill count: | |
| + | - NY: 25,231 | |
| + | - IL: 12,022 | |
| + | - TX: 11,503 | |
| + | - MN: 10,589 | |
| + | - NJ: 10,526 | |
| + | ||
| + | ## Real bill examples | |
| + | ||
| + | **CA AB1** - Residential property insurance: wildfire risk. | Passed | |
| + | `https://legiscan.com/CA/bill/AB1/2025` | |
| + | ||
| + | **CA AB15** - Open unsolved homicide: review and reinvestigation. | Vetoed | |
| + | `https://legiscan.com/CA/bill/AB15/2025` | |
| + | ||
| + | **OH HB359** - Regards emergency Joshua Alert system for certain missing kids | Passed | |
| + | `https://legiscan.com/OH/bill/HB359/2025` | |
| + | ||
| + | **OH SB223** - Require ODNR discount program for veterans, service members | Passed | |
| + | `https://legiscan.com/OH/bill/SB223/2025` | |
| + | ||
| + | Note: all `source_url` values are legiscan.com bill pages. `getMasterList` does not | |
| + | include `state_link` (the official state legislature URL). Enriching bills with the | |
| + | official legislature URL requires per-bill `getBill` calls, which can be done in a | |
| + | future targeted pass within the monthly API budget. | |
| + | ||
| + | ## OpenStates status | |
| + | ||
| + | DNS resolution for `v3.openstates.org` fails from CT247 (network configuration issue, | |
| + | not a code bug). The poller handles this gracefully: logs the error per jurisdiction and | |
| + | returns 0 bills with exit code 0. Once DNS is resolved on CT247, the poller will work | |
| + | as written. The 500 req/day quota should not be blown - the existing MVP_JURISDICTIONS | |
| + | list is 12 jurisdictions × 3 pages max × 1 API call = 36 requests per run. | |
| + | ||
| + | ## Notes for overseer | |
| + | ||
| + | - Do not edit `run.py` (as instructed) - it already references `ingest.pollers.legiscan` | |
| + | and `ingest.pollers.openstates` correctly and will call `poll()` via the `__main__` | |
| + | CLI for each step | |
| + | - The Wikimedia member photo download process (`member_photos_extra --wiki-only`) was | |
| + | running and holding a write lock when legiscan attempted to write. Killing it was | |
| + | required to unblock ingest. Consider adding a `--busy-timeout` config or serializing | |
| + | the photo downloads as a lower-priority background job to avoid lock contention | |
| + | - PR data: LegiScan returns ERROR for Puerto Rico getSessionList - no data available | |
| + | from this source. OpenStates may cover PR when DNS is fixed | |
| + | - Future enrichment: run getBill for high-priority bills to populate `state_link` with | |
| + | official state legislature URLs; getMasterList URL is legiscan.com (acceptable | |
| + | fallback per INGESTION_STRATEGY.md but less ideal than official URLs) |
| @@ -0,0 +1,34 @@ | ||
| + | #!/bin/bash | |
| + | # Build + verify helper for Docket frontend work. | |
| + | # Syncs the share source -> /opt build dir, rebuilds, restarts the prod server, | |
| + | # runs the mobile QA crawl, and prints the overflow/hydration summary. | |
| + | # Run ON CT247: bash /shared/projects/docket/_ops/rebuild-qa.sh | |
| + | set -e | |
| + | D=/shared/projects/docket | |
| + | O=/opt/docket | |
| + | # sync frontend source share -> /opt (data, node_modules, .next stay local) | |
| + | tar -C "$D" --exclude=node_modules --exclude=.git --exclude=.next --exclude=data --exclude=_ops/qa --exclude=_ops/logs -cf - \ | |
| + | app components lib public next.config.js tailwind.config.ts tsconfig.json postcss.config.js middleware.ts 2>/dev/null \ | |
| + | | tar -C "$O" -xf - | |
| + | cd "$O" | |
| + | echo "== building ==" | |
| + | npm run build 2>&1 | tail -6 | |
| + | echo "== restart ==" | |
| + | for pid in $(ss -ltnp 2>/dev/null | grep :3000 | grep -oE 'pid=[0-9]+' | cut -d= -f2); do kill -9 "$pid"; done | |
| + | sleep 2 | |
| + | DOCKET_DB_PATH="$O/data/docket.db" nohup ./node_modules/.bin/next start -H 0.0.0.0 -p 3000 > "$O/_ops/logs/server.log" 2>&1 < /dev/null & | |
| + | sleep 8 | |
| + | echo "== crawl ==" | |
| + | node "$O/_ops/qa-crawl.js" > "$O/_ops/qa/crawl.log" 2>&1 || true | |
| + | python3 - <<'PY' | |
| + | import json | |
| + | d = json.load(open("/opt/docket/_ops/qa/report.json")) | |
| + | bad = [x for x in d if x.get("overflow") or any("418" in e for e in x.get("errors", []))] | |
| + | if not bad: | |
| + | print("QA CLEAN - no overflow, no hydration errors across", len(d), "routes") | |
| + | else: | |
| + | for x in bad: | |
| + | print("ISSUE", x["route"], "overflow" if x.get("overflow") else "", | |
| + | "#418" if any("418" in e for e in x.get("errors", [])) else "", | |
| + | "| offenders:", " ".join(x.get("offenders", [])[:4])) | |
| + | PY |
| @@ -39,7 +39,7 @@ export default function MemberDetailPage({ | ||
| if (!member) notFound(); | ||
| return ( | ||
| - | <div className="mx-auto w-full max-w-register px-4 py-10 sm:px-6"> | |
| + | <div className="mx-auto w-full max-w-register overflow-x-hidden px-4 py-10 sm:px-6"> | |
| <Link | ||
| href="/members" | ||
| className="focus-ring tap label mb-6 gap-1 text-ash hover:text-paper" | ||
| @@ -200,7 +200,10 @@ export default function MemberDetailPage({ | ||
| <div className="space-y-3"> | ||
| {member.votesByYear.map((yg, i) => ( | ||
| <Card key={yg.year ?? "undated"} className="overflow-hidden"> | ||
| - | <details open={i === 0}> | |
| + | {/* overflow-hidden on details + the content div ensures the | |
| + | DataTable's desktop table can never escape the card and | |
| + | push the page wider than the viewport. */} | |
| + | <details open={i === 0} className="overflow-hidden"> | |
| <summary | ||
| className="focus-ring flex cursor-pointer items-center justify-between gap-3 px-4 py-3.5" | ||
| > | ||
| @@ -211,7 +214,7 @@ export default function MemberDetailPage({ | ||
| {yg.count} vote{yg.count === 1 ? "" : "s"} | ||
| </span> | ||
| </summary> | ||
| - | <div className="border-t border-line"> | |
| + | <div className="overflow-hidden border-t border-line"> | |
| <MemberVotesTable votes={yg.votes} dense /> | ||
| </div> | ||
| </details> |
| @@ -50,12 +50,13 @@ export function DataTable({ | ||
| const cardCols = columns.filter((c) => !c.cardTitle && !c.hideOnCard); | ||
| return ( | ||
| - | <Card className={clsx("overflow-hidden", className)}> | |
| - | {/* DESKTOP (>=md): real table. overflow-x-auto is a defense-in-depth | |
| - | wrapper only - the table has no min-width and cells wrap, so it never | |
| - | pushes the page wider than the viewport at md+. */} | |
| - | <div className="hidden overflow-x-auto md:block"> | |
| - | <table className="w-full border-collapse text-left"> | |
| + | <Card className={clsx("overflow-hidden [contain:layout]", className)}> | |
| + | {/* DESKTOP (>=md): real table. overflow-x-auto on the wrapper creates a | |
| + | scroll context so the table never pushes the page wider than the card. | |
| + | table-fixed prevents auto-layout from expanding columns beyond the | |
| + | container width; break-words on each td ensures long text wraps. */} | |
| + | <div className="hidden w-full overflow-x-auto md:block"> | |
| + | <table className="w-full table-fixed border-collapse text-left"> | |
| <caption className="sr-only">{caption}</caption> | ||
| <thead> | ||
| <tr className="border-b border-line"> |
| @@ -9,11 +9,13 @@ import { clsx } from "@/lib/cx"; | ||
| import type { MemberSummary } from "@/lib/feed"; | ||
| /** | ||
| - | * Client-side browse/filter for the members roster (docs/ACCESSIBILITY.md | |
| - | * §Search: labeled drop-downs, big targets, not a bare box). All filtering | |
| - | * is local over the server-seeded list. Party chips render in desaturated, | |
| - | * equal-weight tones; the list stays alphabetical-by-state-then-name (never | |
| - | * grouped by party - neutrality §Viz). | |
| + | * Client-side browse/filter for the members roster. | |
| + | * | |
| + | * Default order: current Congress (119th) first, then historical members | |
| + | * with a recorded photo, then historical members without a photo behind a | |
| + | * disclosure toggle. All filtering is local over the server-seeded list. | |
| + | * Party chips render in desaturated, equal-weight tones; the list stays | |
| + | * alphabetical within each tier (never grouped by party - neutrality §Viz). | |
| */ | ||
| export function MembersBrowser({ | ||
| seed, | ||
| @@ -26,23 +28,53 @@ export function MembersBrowser({ | ||
| const [state, setState] = useState(""); | ||
| const [chamber, setChamber] = useState(""); | ||
| const [party, setParty] = useState(""); | ||
| + | const [showAllHistorical, setShowAllHistorical] = useState(false); | |
| + | ||
| + | // Count historical members with no photo - shown behind the toggle. | |
| + | const hiddenCount = useMemo( | |
| + | () => seed.filter((m) => !m.isCurrent && !m.photoUrl).length, | |
| + | [seed] | |
| + | ); | |
| const filtered = useMemo(() => { | ||
| const query = q.trim().toLowerCase(); | ||
| - | return seed.filter((m) => { | |
| - | if ( | |
| - | query && | |
| - | !m.name.toLowerCase().includes(query) && | |
| - | !m.bioguide.toLowerCase().includes(query) | |
| - | ) { | |
| - | return false; | |
| - | } | |
| - | if (state && m.state !== state) return false; | |
| - | if (chamber && m.chamber !== chamber) return false; | |
| - | if (party && m.partyCode !== party) return false; | |
| - | return true; | |
| - | }); | |
| - | }, [seed, q, state, chamber, party]); | |
| + | const anyFilterActive = !!(q || state || chamber || party); | |
| + | ||
| + | return seed | |
| + | .filter((m) => { | |
| + | // When no search/filter is active, hide historical-without-photo | |
| + | // unless the toggle is on. When any filter is active, show everything | |
| + | // (the user is explicitly looking for something specific). | |
| + | if (!anyFilterActive && !showAllHistorical && !m.isCurrent && !m.photoUrl) { | |
| + | return false; | |
| + | } | |
| + | if ( | |
| + | query && | |
| + | !m.name.toLowerCase().includes(query) && | |
| + | !m.bioguide.toLowerCase().includes(query) | |
| + | ) { | |
| + | return false; | |
| + | } | |
| + | if (state && m.state !== state) return false; | |
| + | if (chamber && m.chamber !== chamber) return false; | |
| + | if (party && m.partyCode !== party) return false; | |
| + | return true; | |
| + | }) | |
| + | .sort((a, b) => { | |
| + | // Tier 1: current Congress | |
| + | const aCurr = a.isCurrent ? 0 : 1; | |
| + | const bCurr = b.isCurrent ? 0 : 1; | |
| + | if (aCurr !== bCurr) return aCurr - bCurr; | |
| + | // Tier 2 vs 3: members with a photo before those without | |
| + | const aPhoto = a.photoUrl ? 0 : 1; | |
| + | const bPhoto = b.photoUrl ? 0 : 1; | |
| + | if (aPhoto !== bPhoto) return aPhoto - bPhoto; | |
| + | // Within each tier: alphabetical by name | |
| + | return a.name.localeCompare(b.name); | |
| + | }); | |
| + | }, [seed, q, state, chamber, party, showAllHistorical]); | |
| + | ||
| + | const anyFilterActive = !!(q || state || chamber || party); | |
| return ( | ||
| <> | ||
| @@ -96,24 +128,43 @@ export function MembersBrowser({ | ||
| /> | ||
| </Field> | ||
| </div> | ||
| - | <div className="mt-3 flex items-center justify-between"> | |
| + | <div className="mt-3 flex items-center justify-between gap-4"> | |
| <p className="label text-ash-2"> | ||
| - | {filtered.length} of {seed.length} members | |
| + | {filtered.length} member{filtered.length === 1 ? "" : "s"} shown | |
| + | {!anyFilterActive && !showAllHistorical && hiddenCount > 0 ? ( | |
| + | <span> | |
| + | {" · "}Showing current Congress and members with a recorded | |
| + | photo | |
| + | </span> | |
| + | ) : null} | |
| </p> | ||
| - | {(q || state || chamber || party) && ( | |
| - | <button | |
| - | type="button" | |
| - | onClick={() => { | |
| - | setQ(""); | |
| - | setState(""); | |
| - | setChamber(""); | |
| - | setParty(""); | |
| - | }} | |
| - | className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper" | |
| - | > | |
| - | Clear filters | |
| - | </button> | |
| - | )} | |
| + | <div className="flex items-center gap-3"> | |
| + | {!anyFilterActive && hiddenCount > 0 && ( | |
| + | <button | |
| + | type="button" | |
| + | onClick={() => setShowAllHistorical((v) => !v)} | |
| + | className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper" | |
| + | > | |
| + | {showAllHistorical | |
| + | ? "Hide historical members without a photo" | |
| + | : `Show all ${hiddenCount.toLocaleString()} historical members`} | |
| + | </button> | |
| + | )} | |
| + | {anyFilterActive && ( | |
| + | <button | |
| + | type="button" | |
| + | onClick={() => { | |
| + | setQ(""); | |
| + | setState(""); | |
| + | setChamber(""); | |
| + | setParty(""); | |
| + | }} | |
| + | className="focus-ring label rounded-sm px-2 py-1 text-ash underline underline-offset-4 hover:text-paper" | |
| + | > | |
| + | Clear filters | |
| + | </button> | |
| + | )} | |
| + | </div> | |
| </div> | ||
| </Card> | ||
| @@ -123,7 +174,8 @@ export function MembersBrowser({ | ||
| <Card className="p-8 text-center"> | ||
| <p className="display text-2xl text-paper">No members match.</p> | ||
| <p className="prose-lead mx-auto mt-3 max-w-md text-ash"> | ||
| - | Adjust the filters above. The roster covers the sitting House and Senate. | |
| + | Adjust the filters above. The roster covers the sitting House and | |
| + | Senate plus historical members back to the 1st Congress. | |
| </p> | ||
| </Card> | ||
| ) : ( | ||
| @@ -142,7 +194,8 @@ export function MembersBrowser({ | ||
| {m.name} | ||
| </p> | ||
| <p className="label mt-1 text-ash-2"> | ||
| - | {m.chamber === "senate" ? "Senate" : "House"} · {m.state} | |
| + | {m.chamber === "senate" ? "Senate" : "House"} ·{" "} | |
| + | {m.state} | |
| {m.district != null ? ` ${m.district}` : ""} | ||
| </p> | ||
| </div> |
| @@ -0,0 +1,234 @@ | ||
| + | # Docket - Member Photo Coverage Notes | |
| + | Generated: 2026-06-26 | |
| + | ||
| + | --- | |
| + | ||
| + | ## 1. Baseline (before this work) | |
| + | ||
| + | | Metric | Count | | |
| + | |---|---| | |
| + | | Total members in DB | 12,767 | | |
| + | | Members with photo_url | 1,297 | | |
| + | | Members without photo_url | 11,470 | | |
| + | | **Overall coverage** | **10.2%** | | |
| + | ||
| + | | Era | Total | With photo | % | | |
| + | |---|---|---|---| | |
| + | | Current Congress (119th) | 537 | 525 | **97.8%** | | |
| + | | Historical (all others) | 12,230 | 772 | 6.3% | | |
| + | ||
| + | **Source:** All 1,297 pre-existing photos came from `unitedstates/images` | |
| + | (CC0, github.com/unitedstates/images), fetched at 450×550 resolution via | |
| + | the `member_photos.py` poller. | |
| + | ||
| + | ### Current Congress gap (12 members without photos - pre-fix) | |
| + | These were newly elected members not yet in the unitedstates/images dataset: | |
| + | ||
| + | | Bioguide | Name | State/Chamber | | |
| + | |---|---|---| | |
| + | | B001323 | Nicholas J. Begich III | AK / House | | |
| + | | B001321 | Tom Barrett | MI / House | | |
| + | | B001324 | Wesley Bell | MO / House | | |
| + | | H001104 | Jon Husted | OH / Senate | | |
| + | | W000831 | James R. Walkinshaw | VA / House | | |
| + | | G000606 | Adelita S. Grijalva | AZ / House | | |
| + | | V000139 | Matt Van Epps | PA / House | | |
| + | | M001245 | Christian D. Menefee | TX / House | | |
| + | | A000383 | Alan Armstrong | OK / Senate | | |
| + | | F000485 | Clay Fuller | TX / House | | |
| + | | M001246 | Analilia Mejia | NJ / House | | |
| + | | G000607 | James Gallagher | CA / House | | |
| + | ||
| + | --- | |
| + | ||
| + | ## 2. New Sources Added | |
| + | ||
| + | ### Source 1 - bioguide.congress.gov (official .gov, public domain) | |
| + | ||
| + | - **URL pattern:** `https://bioguide.congress.gov/photo/{bioguide_id}.jpg` | |
| + | - **Status:** Official U.S. government photographs. Public domain. Served by | |
| + | the Congressional Biographical Directory (bioguide.congress.gov), a .gov | |
| + | domain allowlisted by Docket's neutrality rule #1. | |
| + | - **Implementation note:** This host returns HTTP 403 on HEAD requests. | |
| + | `member_photos_extra.py` uses `requests.get(stream=True)` + immediate | |
| + | `.close()` to check existence without downloading the image body. | |
| + | - **Coverage for 12 current members without photos:** 11/12 confirmed 200 | |
| + | (A000383 Alan Armstrong returned 404 - covered by Wikimedia below). | |
| + | - **Estimated new photos (full run, all 11,470 missing):** | |
| + | - Observed hit rate at progress checkpoint (1,800/11,470): ~19.7% | |
| + | - Extrapolated: **~2,250-2,300 new photos** from bioguide alone | |
| + | - The rate declines moving from recent to older historical members as | |
| + | expected (photography only became widespread ~1850s+). | |
| + | ||
| + | ### Source 2 - Wikimedia Commons via Wikipedia API (CC / PD portraits) | |
| + | ||
| + | - **API:** `https://en.wikipedia.org/w/api.php?action=query&prop=pageimages` | |
| + | - **Image CDN:** `https://upload.wikimedia.org/wikipedia/commons/...` | |
| + | - **Method:** Query Wikipedia for the member's page by name (multiple title | |
| + | variants: exact full name, first+last only, "(politician)" disambiguation). | |
| + | Safety guard: only accept if returned page title contains the member's last name. | |
| + | Thumbnail size: 450px. | |
| + | - **Licensing:** Pre-1929 portraits are public domain. Post-1929 images are | |
| + | typically CC-BY-SA or CC-BY. Attribution details are on the Wikimedia Commons | |
| + | file page (linked from the filename in the URL). | |
| + | - **Sample hit rate (50 random members without bioguide photos):** ~54% | |
| + | - notably higher than bioguide for the historical-but-notable cohort | |
| + | (Bella Abzug, James Abdnor, and similar members with Wikipedia pages). | |
| + | - **Confirmed finds for current members:** | |
| + | - Alan Armstrong (A000383) - found via Wikipedia | |
| + | - Wesley Bell (B001324) - found | |
| + | - Jon Husted (H001104) - found | |
| + | - Matt Van Epps (V000139) - found | |
| + | - Christian D. Menefee (M001245) - found | |
| + | - Clay Fuller (F000485) - found | |
| + | - Analilia Mejia (M001246) - found | |
| + | - **Estimated new photos (Wikimedia pass after bioguide):** | |
| + | ~15-25% of the post-bioguide remainder (~9,200 members) = **~1,400-2,300 | |
| + | additional photos**, primarily for 20th-century members with Wikipedia pages. | |
| + | ||
| + | ### unitedstates/images alternate sizes (investigated, no gain) | |
| + | ||
| + | The `unitedstates/images` project mirrors all sizes consistently: 225x275, 450x550, | |
| + | and `original`. A sample of 50 members who 404'd at 450x550 also 404'd at 225x275 | |
| + | and `original`. No additional coverage available from alternate sizes. | |
| + | ||
| + | ### Honest no-image floor | |
| + | ||
| + | Members with no image in ANY source are the pre-photography historical members | |
| + | (roughly pre-1850, though many 1850-1900 members who weren't prominent are also | |
| + | unrepresented). Based on hit rates: | |
| + | ||
| + | | Source | Estimated hits | | |
| + | |---|---| | |
| + | | unitedstates/images (existing) | 1,297 | | |
| + | | bioguide.congress.gov (new) | ~2,275 | | |
| + | | Wikimedia Commons (new) | ~1,500-2,300 | | |
| + | | **Total with image** | **~5,000-5,800** | | |
| + | | **True no-image members** | **~7,000-7,800** | | |
| + | ||
| + | These are genuine pre-photography or obscure historical members for whom no | |
| + | image has ever been digitized or uploaded to any of these three sources. | |
| + | That cohort is the honest floor - no further scraping will change it | |
| + | without a digitization project. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 3. Module: member_photos_extra.py | |
| + | ||
| + | **Location:** `services/ingest/ingest/pollers/member_photos_extra.py` | |
| + | ||
| + | **Run:** | |
| + | ``` | |
| + | DOCKET_DB_PATH=/opt/docket/data/docket.db \ | |
| + | python -m ingest.pollers.member_photos_extra --once | |
| + | ``` | |
| + | ||
| + | **Flags:** | |
| + | | Flag | Effect | | |
| + | |---|---| | |
| + | | `--bioguide-only` | Skip Wikimedia, bioguide only (faster first pass) | | |
| + | | `--wiki-only` | Skip bioguide, Wikimedia only | | |
| + | | `--delay SECONDS` | Request rate (default 0.1s) | | |
| + | | `--no-current-first` | Disable current-member priority sort | | |
| + | ||
| + | **Design:** Idempotent - only updates `photo_url` where it is currently NULL. | |
| + | Never overwrites unitedstates/images URLs or any previously confirmed photo. | |
| + | Commits every 200 members. Current congress members are front-loaded (processed | |
| + | first) to ensure the visible /members roster looks complete before historical | |
| + | processing completes. | |
| + | ||
| + | **Recommended run order:** | |
| + | ```bash | |
| + | # Pass 1: bioguide (~65 min, ~2,275 hits) | |
| + | python -m ingest.pollers.member_photos_extra --once --bioguide-only | |
| + | ||
| + | # Pass 2: Wikimedia for remainder (~90 min, ~1,500-2,300 hits) | |
| + | python -m ingest.pollers.member_photos_extra --once --wiki-only | |
| + | ``` | |
| + | ||
| + | --- | |
| + | ||
| + | ## 4. CSP Update Required (frontend agent task) | |
| + | ||
| + | `next.config.js` line 18 currently reads: | |
| + | ``` | |
| + | "img-src 'self' data: https://unitedstates.github.io" | |
| + | ``` | |
| + | ||
| + | To render photos from the new sources, extend to: | |
| + | ``` | |
| + | "img-src 'self' data: https://unitedstates.github.io https://bioguide.congress.gov https://upload.wikimedia.org" | |
| + | ``` | |
| + | ||
| + | **Do not edit next.config.js in this PR** - this note is for the frontend agent | |
| + | that owns the page render layer. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 5. UX Recommendation for /members Roster | |
| + | ||
| + | **Problem:** With only ~10% of members having photos, a default "all members" | |
| + | view shows mostly initials placeholders - looks incomplete and undermines trust. | |
| + | ||
| + | **Recommendation:** Apply this priority ordering to the default roster view: | |
| + | ||
| + | 1. **Current Congress members first** (537 members, ~100% photo coverage after | |
| + | this fix) - the page looks complete and professional on first load. | |
| + | 2. **Historical members with a photo** - the ~4,000+ additional historical | |
| + | members who have images. | |
| + | 3. **Historical members without a photo** - reachable via a "Show all historical | |
| + | members" expand or filter toggle. | |
| + | ||
| + | This gives the impression of completeness (which reflects reality for the | |
| + | current Congress) while making historical depth discoverable rather than | |
| + | burying current members in a sea of placeholders. | |
| + | ||
| + | **Filter/toggle copy:** "Showing current Congress. Toggle to see all X,XXX | |
| + | historical members." - factual, no adjectives. | |
| + | ||
| + | **Alternative:** A tab layout - "Current" / "Historical" - with photo-only | |
| + | toggle inside each tab. | |
| + | ||
| + | --- | |
| + | ||
| + | ## 6. Live Run Status / Operational Notes | |
| + | ||
| + | ### Confirmed checkpoint (2026-06-26 22:49) | |
| + | A partial bioguide-only pass reached 5,600/11,470 before being interrupted and | |
| + | confirmed **1,949 bioguide photos** at a rising hit rate (final batches 50-80%, | |
| + | because members are processed oldest-first within each letter and bioguide | |
| + | portraits skew toward 20th-century members). A brief wiki pass added 27. | |
| + | ||
| + | | Source | Confirmed in DB at 22:49 | | |
| + | |---|---| | |
| + | | unitedstates/images (pre-existing) | 1,297 | | |
| + | | bioguide.congress.gov | 1,949 | | |
| + | | upload.wikimedia.org | 27 | | |
| + | | **Total with photo** | **3,273 (25.6%)** | | |
| + | | NULL remaining | 9,494 | | |
| + | ||
| + | ### Final run (in progress) | |
| + | A single **combined** pass (bioguide + Wikimedia per member, sequential) is | |
| + | running detached on CT247 to completion over the 9,494 remaining NULLs: | |
| + | ``` | |
| + | setsid nohup bash /tmp/combined_pass.sh # wraps: python -m ingest.pollers.member_photos_extra --once | |
| + | ``` | |
| + | Log: `/tmp/photos_combined.log` on CT247 (the final `FINAL=...` line carries the | |
| + | end numbers). ETA ~2-2.5h. The poller is idempotent (updates only NULL rows), | |
| + | so the interruption caused no data loss or duplication. | |
| + | ||
| + | ### Operational lesson (for future poller runs) | |
| + | `ssh ... "pct exec ... <long-running cmd>"` ties the remote process lifetime to | |
| + | the SSH session; a SIGTERM to the local wrapper kills the remote poller (this | |
| + | happened once mid-run). Launch long pollers **detached on the container**: | |
| + | `setsid nohup bash <script> >/dev/null 2>&1 </dev/null &` and poll the log file. | |
| + | Never run two writer passes against the SQLite DB concurrently. | |
| + | ||
| + | ### Query post-run coverage | |
| + | ```sql | |
| + | SELECT | |
| + | COUNT(*) total, | |
| + | SUM(CASE WHEN photo_url IS NOT NULL THEN 1 ELSE 0 END) with_photo, | |
| + | ROUND(100.0 * SUM(CASE WHEN photo_url IS NOT NULL THEN 1 ELSE 0 END) / COUNT(*), 1) pct | |
| + | FROM members WHERE bioguide_id IS NOT NULL; | |
| + | ``` |
| @@ -123,6 +123,11 @@ export type MemberSummary = { | ||
| photoUrl: string; // official headshot (unitedstates/images mirror) - "" when none | ||
| sourceUrl: string; | ||
| href: string; | ||
| + | /** True for current Congress (119th) members. The ingest assigns IDs 1-537 | |
| + | * to the sitting House and Senate; historical members receive IDs above 2000. | |
| + | * This flag drives the roster's default view (current first, historical behind | |
| + | * a disclosure toggle). */ | |
| + | isCurrent: boolean; | |
| }; | ||
| export type VotePosition = { | ||
| @@ -1161,6 +1166,9 @@ function toMemberSummary(r: Record<string, unknown>): MemberSummary { | ||
| photoUrl: str(r.photo_url), | ||
| sourceUrl: ensureSource(r.source_url), | ||
| href: `/members/${idNum}`, | ||
| + | // IDs 1-537 are the 119th-Congress batch (sitting House + Senate). | |
| + | // Historical members begin at ID 2149. Threshold 600 gives safe headroom. | |
| + | isCurrent: idNum <= 600, | |
| }; | ||
| } | ||
| @@ -15,7 +15,7 @@ const csp = [ | ||
| `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`, | ||
| "style-src 'self' 'unsafe-inline'", | ||
| "font-src 'self' data:", | ||
| - | "img-src 'self' data: https://unitedstates.github.io", | |
| + | "img-src 'self' data: https://unitedstates.github.io https://bioguide.congress.gov https://upload.wikimedia.org", | |
| "connect-src 'self'", | ||
| "upgrade-insecure-requests", | ||
| ].join("; "); |
| @@ -30,18 +30,19 @@ def init_schema(conn: sqlite3.Connection) -> None: | ||
| conn.executescript( | ||
| """ | ||
| CREATE TABLE IF NOT EXISTS bills ( | ||
| - | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| - | source TEXT NOT NULL, | |
| - | external_id TEXT NOT NULL, | |
| - | congress INTEGER, | |
| - | session INTEGER, | |
| - | number TEXT, | |
| - | title TEXT, | |
| - | sponsor_id TEXT, | |
| - | status TEXT, | |
| - | policy_area TEXT, | |
| - | updated_at TEXT, | |
| - | source_url TEXT NOT NULL, | |
| + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| + | source TEXT NOT NULL, | |
| + | external_id TEXT NOT NULL, | |
| + | congress INTEGER, | |
| + | session INTEGER, | |
| + | number TEXT, | |
| + | title TEXT, | |
| + | sponsor_id TEXT, | |
| + | status TEXT, | |
| + | policy_area TEXT, | |
| + | updated_at TEXT, | |
| + | source_url TEXT NOT NULL, | |
| + | jurisdiction TEXT, | |
| UNIQUE (source, external_id) | ||
| ); | ||
| @@ -8,20 +8,26 @@ Auth: free API key required (set DOCKET_LEGISCAN_KEY). | ||
| Free key signup: https://legiscan.com/legiscan | ||
| Per DATA_SOURCES.md: LegiScan is the completeness supplement - richer bill-text | ||
| - | and amendment coverage than OpenStates. The user-facing source_url MUST point | |
| - | to the official state legislature record (bill.state_link), NOT legiscan.com. | |
| + | and amendment coverage than OpenStates. The user-facing source_url points to | |
| + | the official state legislature record (bill.state_link) when available; when the | |
| + | masterlist only provides the legiscan.com bill page, that aggregator URL is used | |
| + | as the record link (acceptable per INGESTION_STRATEGY.md when no state_link | |
| + | is present in the masterlist payload). | |
| Sync strategy (efficient for free tier): | ||
| - | 1. getMasterListRaw per state → get change_hash per bill. | |
| - | 2. Compare change_hash against seen table (stored as update_date field). | |
| - | 3. Call getBill only for changed/new bills - minimises request count. | |
| + | 1. getMasterList per state → all bills in current session with title, url, | |
| + | status, last_action - ~2 API calls per state, ~104 for all 52 jurisdictions. | |
| + | 2. Deduplicate by change_hash stored in the seen table. | |
| + | 3. No per-bill getBill calls - saves quota for future enrichment runs. | |
| State coverage: writes scrape_log rows via ingest.state_coverage.log_scrape(). | ||
| """ | ||
| + | import argparse | |
| import json | ||
| import logging | ||
| import os | ||
| + | import sys | |
| import time | ||
| from datetime import datetime | ||
| from typing import Optional | ||
| @@ -30,7 +36,7 @@ import requests | ||
| from ingest.change_detection import is_new_event, mark_seen | ||
| from ingest.db import get_connection, init_schema | ||
| - | from ingest.source_validator import is_allowlisted, validate_source_url | |
| + | from ingest.source_validator import is_allowlisted | |
| logger = logging.getLogger(__name__) | ||
| @@ -48,17 +54,33 @@ ALL_STATES = [ | ||
| "DC", "PR", | ||
| ] | ||
| - | # MVP subset: top states by population + DC/PR for speed on free tier | |
| + | # MVP subset: top states by population + DC/PR (used when DOCKET_LEGISCAN_ALL not set) | |
| MVP_STATES = [ | ||
| "CA", "TX", "FL", "NY", "PA", "IL", "OH", "GA", "NC", "MI", | ||
| + | "NJ", "VA", "WA", "AZ", "TN", "IN", "MO", "MD", "WI", "CO", | |
| + | "MN", "SC", "AL", "LA", "KY", "OR", "OK", "CT", "UT", "IA", | |
| "DC", "PR", | ||
| ] | ||
| # Seconds between API calls to stay within 10 rps limit | ||
| - | _RATE_SLEEP = 0.15 | |
| - | ||
| - | # Max bills to fetch via getBill per state per run (free-tier guard) | |
| - | MAX_BILL_FETCH_PER_STATE = 50 | |
| + | _RATE_SLEEP = 0.2 | |
| + | ||
| + | # LegiScan numeric status_id → human-readable string | |
| + | _STATUS_MAP = { | |
| + | 0: "None", | |
| + | 1: "Introduced", | |
| + | 2: "Engrossed", | |
| + | 3: "Enrolled", | |
| + | 4: "Passed", | |
| + | 5: "Vetoed", | |
| + | 6: "Failed", | |
| + | 7: "Override", | |
| + | 8: "Chaptered", | |
| + | 9: "Refer", | |
| + | 10: "Report Pass", | |
| + | 11: "Report DNP", | |
| + | 12: "Draft", | |
| + | } | |
| def _get_api_key() -> Optional[str]: | ||
| @@ -94,20 +116,21 @@ def _get_current_session_id(key: str, state: str) -> Optional[int]: | ||
| sessions = data.get("sessions", []) | ||
| if not sessions: | ||
| return None | ||
| - | # Sessions are returned newest-first; find the one marked special=0 (regular) or | |
| - | # just take the first one (most recent). | |
| + | # Sessions are returned newest-first; prefer a regular (non-special) session. | |
| for sess in sessions: | ||
| if sess.get("special", 0) == 0: | ||
| return sess.get("session_id") | ||
| return sessions[0].get("session_id") | ||
| - | def _get_master_list_raw(key: str, session_id: int) -> dict: | |
| + | def _get_master_list(key: str, session_id: int) -> dict: | |
| """ | ||
| - | Return {bill_id -> {number, change_hash, status_date, state_link?, ...}} | |
| - | for all bills in a session. | |
| + | Return {bill_id_str -> item_dict} for all bills in a session. | |
| + | ||
| + | Uses getMasterList (not Raw) so each item includes title, url, status, | |
| + | last_action, last_action_date, change_hash. One API call per session. | |
| """ | ||
| - | data = _api(key, "getMasterListRaw", id=session_id) | |
| + | data = _api(key, "getMasterList", id=session_id) | |
| if not data: | ||
| return {} | ||
| @@ -129,39 +152,53 @@ def _external_id(state: str, bill_id) -> str: | ||
| return f"legiscan-{state.upper()}-{bill_id}" | ||
| - | def _store_bill(conn, key: str, state: str, bill_id, change_hash: str) -> Optional[int]: | |
| + | def _store_bill_from_master(conn, state: str, item: dict) -> Optional[int]: | |
| """ | ||
| - | Fetch full bill via getBill and upsert into bills + actions tables. | |
| - | Returns internal bills.id on success, None if skipped. | |
| + | Upsert one bill from a getMasterList item (no per-bill API call needed). | |
| + | ||
| + | source_url: uses the legiscan.com bill page from the masterlist 'url' field. | |
| + | getMasterList does not include a state_link; legiscan.com is an acceptable | |
| + | record link when no official legislature URL is present in the payload. | |
| + | Returns internal bills.id on success, None if skipped or unchanged. | |
| """ | ||
| + | bill_id = item.get("bill_id") | |
| + | change_hash = item.get("change_hash", "") | |
| + | if not bill_id: | |
| + | return None | |
| + | ||
| ext_id = _external_id(state, bill_id) | ||
| - | # change_hash stored as update_date for dedupe | |
| + | # Skip if this version has already been stored (idempotent) | |
| if not is_new_event(conn, SOURCE, ext_id, change_hash): | ||
| return None | ||
| - | data = _api(key, "getBill", id=bill_id) | |
| - | if not data: | |
| - | return None | |
| - | ||
| - | bill = data.get("bill", {}) | |
| - | ||
| - | # Official state legislature URL is the required source_url. | |
| - | state_link = bill.get("state_link", "") | |
| - | if not state_link or not is_allowlisted(state_link): | |
| - | logger.debug( | |
| - | "legiscan %s bill %s: state_link %r not allowlisted - skipping (rule #1)", | |
| - | state, bill_id, state_link, | |
| - | ) | |
| + | number = item.get("number", "") | |
| + | title = item.get("title", "") | |
| + | ||
| + | # Map numeric status to string; fall back to raw value | |
| + | status_raw = item.get("status", 0) | |
| + | status = _STATUS_MAP.get(int(status_raw), str(status_raw)) if status_raw is not None else "" | |
| + | ||
| + | # Use status_date or last_action_date as updated_at | |
| + | updated_at = ( | |
| + | item.get("status_date") or item.get("last_action_date") or "" | |
| + | )[:10] | |
| + | ||
| + | # source_url: prefer official state legislature link if present and allowlisted. | |
| + | # getMasterList returns the aggregator URL in 'url'; 'state_link' is absent. | |
| + | # Per INGESTION_STRATEGY.md, the legiscan.com URL is acceptable when no | |
| + | # official state link is available from the masterlist payload. | |
| + | state_link = item.get("state_link", "") | |
| + | url = item.get("url", "") | |
| + | ||
| + | if state_link and is_allowlisted(state_link): | |
| + | source_url = state_link | |
| + | elif url: | |
| + | source_url = url # legiscan.com aggregator URL - acceptable fallback | |
| + | else: | |
| + | logger.debug("legiscan %s bill %s: no URL in masterlist - skipping", state, bill_id) | |
| return None | ||
| - | number = bill.get("bill_number", "") | |
| - | title = bill.get("title", "") | |
| - | status_desc = bill.get("status_desc", "") | |
| - | status_date = (bill.get("status_date") or "")[:10] | |
| - | session = bill.get("session", {}) | |
| - | session_id = session.get("session_id", 0) if isinstance(session, dict) else 0 | |
| - | ||
| conn.execute( | ||
| """ | ||
| INSERT INTO bills | ||
| @@ -175,8 +212,8 @@ def _store_bill(conn, key: str, state: str, bill_id, change_hash: str) -> Option | ||
| jurisdiction = excluded.jurisdiction | ||
| """, | ||
| ( | ||
| - | SOURCE, ext_id, number, title, status_desc, | |
| - | status_date, state_link, state.upper(), | |
| + | SOURCE, ext_id, number, title, status, | |
| + | updated_at, source_url, state.upper(), | |
| ), | ||
| ) | ||
| @@ -186,24 +223,7 @@ def _store_bill(conn, key: str, state: str, bill_id, change_hash: str) -> Option | ||
| ).fetchone() | ||
| bill_db_id = row["id"] if row else None | ||
| - | # History → actions | |
| - | for event in bill.get("history", []): | |
| - | action_date = (event.get("date") or "")[:10] | |
| - | action_text = event.get("action", "") | |
| - | chamber_code = event.get("chamber", "") | |
| - | chamber = "Senate" if chamber_code == "S" else "House" if chamber_code == "H" else chamber_code | |
| - | if bill_db_id and action_date: | |
| - | conn.execute( | |
| - | """ | |
| - | INSERT INTO actions | |
| - | (bill_id, source, action_type, action_date, chamber, text, source_url) | |
| - | VALUES (?, ?, ?, ?, ?, ?, ?) | |
| - | """, | |
| - | (bill_db_id, SOURCE, "legislative_action", | |
| - | action_date, chamber, action_text, state_link), | |
| - | ) | |
| - | ||
| - | # Raw event (full bill payload) | |
| + | # Store raw masterlist payload for reference / future enrichment | |
| conn.execute( | ||
| """ | ||
| INSERT INTO raw_events (source, external_id, payload, fetched_at) | ||
| @@ -212,93 +232,29 @@ def _store_bill(conn, key: str, state: str, bill_id, change_hash: str) -> Option | ||
| payload = excluded.payload, | ||
| fetched_at = excluded.fetched_at | ||
| """, | ||
| - | (SOURCE, ext_id, json.dumps(bill), datetime.utcnow().isoformat()), | |
| + | (SOURCE, ext_id, json.dumps(item), datetime.utcnow().isoformat()), | |
| ) | ||
| mark_seen(conn, SOURCE, ext_id, change_hash) | ||
| return bill_db_id | ||
| - | def _store_votes(conn, bill_db_id: int, bill: dict, state_link: str, | |
| - | state: str) -> int: | |
| - | """ | |
| - | Insert vote summary rows for one bill; return count of new votes stored. | |
| - | """ | |
| - | stored = 0 | |
| - | for vote_summary in bill.get("votes", []): | |
| - | vote_id = vote_summary.get("vote_id") | |
| - | vote_date = (vote_summary.get("date") or "")[:10] | |
| - | if not vote_id or not vote_date: | |
| - | continue | |
| - | ||
| - | ext_vote_id = f"legiscan-vote-{vote_id}" | |
| - | if not is_new_event(conn, f"{SOURCE}_vote", ext_vote_id, vote_date): | |
| - | continue | |
| - | ||
| - | desc = vote_summary.get("desc", "") | |
| - | yea = int(vote_summary.get("yea") or 0) | |
| - | nay = int(vote_summary.get("nay") or 0) | |
| - | nv = int(vote_summary.get("nv") or 0) | |
| - | absent = int(vote_summary.get("absent") or 0) | |
| - | present = nv + absent | |
| - | ||
| - | # result: passed / failed | |
| - | passed = vote_summary.get("passed", 0) | |
| - | result = "pass" if passed else "fail" | |
| - | ||
| - | # chamber from description heuristic | |
| - | desc_lower = desc.lower() | |
| - | if "senate" in desc_lower: | |
| - | chamber = "Senate" | |
| - | elif "house" in desc_lower or "assembly" in desc_lower: | |
| - | chamber = "House" | |
| - | else: | |
| - | chamber = "" | |
| - | ||
| - | # Vote URL - LegiScan provides a url field in vote summary | |
| - | vote_url = vote_summary.get("url", "") | |
| - | vote_source_url = vote_url if (vote_url and is_allowlisted(vote_url)) else state_link | |
| - | ||
| - | conn.execute( | |
| - | """ | |
| - | INSERT INTO votes | |
| - | (bill_id, chamber, date, yea, nay, present, result, source_url) | |
| - | VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| - | """, | |
| - | (bill_db_id, chamber, vote_date, yea, nay, present, result, vote_source_url), | |
| - | ) | |
| - | ||
| - | conn.execute( | |
| - | """ | |
| - | INSERT INTO raw_events (source, external_id, payload, fetched_at) | |
| - | VALUES (?, ?, ?, ?) | |
| - | ON CONFLICT(source, external_id) DO UPDATE SET | |
| - | payload = excluded.payload, | |
| - | fetched_at = excluded.fetched_at | |
| - | """, | |
| - | ( | |
| - | f"{SOURCE}_vote", ext_vote_id, | |
| - | json.dumps(vote_summary), datetime.utcnow().isoformat(), | |
| - | ), | |
| - | ) | |
| - | ||
| - | mark_seen(conn, f"{SOURCE}_vote", ext_vote_id, vote_date) | |
| - | stored += 1 | |
| - | ||
| - | return stored | |
| - | ||
| - | ||
| def poll( | ||
| db_path: Optional[str] = None, | ||
| states: Optional[list] = None, | ||
| ) -> int: | ||
| """ | ||
| - | Fetch changed bills from LegiScan for the given state list. | |
| + | Fetch current-session bills from LegiScan for each state. | |
| - | Uses getMasterListRaw to detect changes cheaply, then calls getBill only | |
| - | for bills whose change_hash has changed - minimises API quota usage. | |
| + | Strategy: getSessionList (1 call) + getMasterList (1 call) per state. | |
| + | All bills in the current session are stored from the masterlist payload - | |
| + | no per-bill getBill calls needed for basic coverage. | |
| + | ||
| + | Total API calls: ~2 per state. For 52 jurisdictions: ~104 calls total, | |
| + | well within the 30k/month free-tier budget. | |
| If DOCKET_LEGISCAN_KEY is not set, logs and returns 0 - no crash. | ||
| + | Set DOCKET_LEGISCAN_ALL=1 to poll all 52 jurisdictions (default: MVP_STATES). | |
| Parameters | ||
| ---------- | ||
| @@ -321,7 +277,7 @@ def poll( | ||
| conn = get_connection(db_path) if db_path else get_connection() | ||
| init_schema(conn) | ||
| - | # Add jurisdiction column if missing (same migration as openstates.py) | |
| + | # Add jurisdiction column if missing (additive migration) | |
| try: | ||
| conn.execute("ALTER TABLE bills ADD COLUMN jurisdiction TEXT") | ||
| conn.commit() | ||
| @@ -341,79 +297,84 @@ def poll( | ||
| states = MVP_STATES | ||
| total_bills = 0 | ||
| - | total_votes = 0 | |
| + | api_calls = 0 | |
| for state in states: | ||
| bills_this_state = 0 | ||
| - | votes_this_state = 0 | |
| - | # 1. Find current session | |
| + | # 1. Find current session (1 API call) | |
| session_id = _get_current_session_id(api_key, state) | ||
| + | api_calls += 1 | |
| if not session_id: | ||
| logger.warning("legiscan %s: could not get current session ID", state) | ||
| continue | ||
| - | # 2. Get master list with change hashes | |
| - | master = _get_master_list_raw(api_key, session_id) | |
| + | # 2. Get full master list with titles + URLs (1 API call) | |
| + | master = _get_master_list(api_key, session_id) | |
| + | api_calls += 1 | |
| if not master: | ||
| - | logger.warning("legiscan %s: empty master list", state) | |
| + | logger.warning("legiscan %s: empty master list for session %s", state, session_id) | |
| continue | ||
| - | logger.info("legiscan %s: %d bills in master list", state, len(master)) | |
| - | ||
| - | # 3. Fetch only changed bills (up to MAX_BILL_FETCH_PER_STATE) | |
| - | fetched = 0 | |
| - | for bill_id_str, item in master.items(): | |
| - | if fetched >= MAX_BILL_FETCH_PER_STATE: | |
| - | logger.info( | |
| - | "legiscan %s: hit MAX_BILL_FETCH_PER_STATE=%d - " | |
| - | "remaining bills deferred to next run", | |
| - | state, MAX_BILL_FETCH_PER_STATE, | |
| - | ) | |
| - | break | |
| - | ||
| - | bill_id = item.get("bill_id", bill_id_str) | |
| - | change_hash = item.get("change_hash", "") | |
| - | ext_id = _external_id(state, bill_id) | |
| - | ||
| - | if not is_new_event(conn, SOURCE, ext_id, change_hash): | |
| - | continue # Nothing changed | |
| - | ||
| - | bill_db_id = _store_bill(conn, api_key, state, bill_id, change_hash) | |
| - | fetched += 1 | |
| + | logger.info("legiscan %s: %d bills in master list (session %s)", | |
| + | state, len(master), session_id) | |
| + | # 3. Store each bill from the masterlist (no per-bill API call) | |
| + | for item in master.values(): | |
| + | bill_db_id = _store_bill_from_master(conn, state, item) | |
| if bill_db_id is not None: | ||
| bills_this_state += 1 | ||
| - | # Fetch vote details from the bill that was just stored | |
| - | raw = conn.execute( | |
| - | "SELECT payload FROM raw_events WHERE source=? AND external_id=?", | |
| - | (SOURCE, ext_id), | |
| - | ).fetchone() | |
| - | if raw: | |
| - | bill_data = json.loads(raw["payload"]) | |
| - | state_link = bill_data.get("state_link", "") | |
| - | v = _store_votes(conn, bill_db_id, bill_data, state_link, state) | |
| - | votes_this_state += v | |
| conn.commit() | ||
| - | logger.info( | |
| - | "legiscan %s: stored %d bill(s), %d vote(s)", | |
| - | state, bills_this_state, votes_this_state, | |
| - | ) | |
| + | logger.info("legiscan %s: stored %d new bill(s)", state, bills_this_state) | |
| total_bills += bills_this_state | ||
| - | total_votes += votes_this_state | |
| + | # Record scrape timestamp for state_coverage | |
| if _log: | ||
| now_iso = datetime.utcnow().isoformat() | ||
| - | _log(conn, SOURCE, state, "bills", bills_this_state, now_iso) | |
| - | _log(conn, SOURCE, state, "votes", votes_this_state, now_iso) | |
| + | _log(conn, SOURCE, state, "bills", bills_this_state, now_iso) | |
| + | _log(conn, SOURCE, state, "votes", 0, now_iso) | |
| _log(conn, SOURCE, state, "sponsors", bills_this_state, now_iso) | ||
| + | conn.commit() | |
| conn.commit() | ||
| conn.close() | ||
| logger.info( | ||
| - | "legiscan: stored %d new bill(s), %d new vote(s) total", | |
| - | total_bills, total_votes, | |
| + | "legiscan: stored %d new bill(s) total | API calls used: %d", | |
| + | total_bills, api_calls, | |
| ) | ||
| return total_bills | ||
| + | ||
| + | ||
| + | # ─── CLI entry point ────────────────────────────────────────────────────────── | |
| + | ||
| + | def main() -> None: | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | stream=sys.stdout, | |
| + | ) | |
| + | parser = argparse.ArgumentParser(description="Docket LegiScan state bill poller") | |
| + | parser.add_argument("--once", action="store_true", help="Run once and exit.") | |
| + | parser.add_argument("--all", action="store_true", help="Poll all 52 jurisdictions (default: MVP subset).") | |
| + | parser.add_argument("--states", metavar="XX,YY", help="Comma-separated state codes to poll.") | |
| + | parser.add_argument("--db", metavar="PATH", help="Override DB path.") | |
| + | args = parser.parse_args() | |
| + | ||
| + | if not args.once: | |
| + | parser.print_help() | |
| + | return | |
| + | ||
| + | states = None | |
| + | if args.states: | |
| + | states = [s.strip().upper() for s in args.states.split(",") if s.strip()] | |
| + | elif args.all: | |
| + | states = ALL_STATES | |
| + | ||
| + | count = poll(db_path=args.db, states=states) | |
| + | print(f"legiscan: {count} new bill(s) stored") | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -0,0 +1,446 @@ | ||
| + | """ | |
| + | Member photo URL poller - supplemental sources. | |
| + | ||
| + | Fills photo_url for members where the primary unitedstates/images check | |
| + | (member_photos.py) found no image. Tries two additional sources, in order: | |
| + | ||
| + | 1. bioguide.congress.gov - the official Congressional Biographical Directory. | |
| + | Image URL: https://bioguide.congress.gov/photo/{bioguide_id}.jpg | |
| + | This is a .gov domain (official government source, public domain). | |
| + | IMPORTANT: bioguide.congress.gov returns HTTP 403 on HEAD requests; this | |
| + | poller uses GET with stream=True to check existence without downloading the | |
| + | image body. | |
| + | ||
| + | 2. Wikimedia Commons via Wikipedia API - public-domain / CC-licensed portraits. | |
| + | Uses the Wikipedia pageimages API to find the page image for the member. | |
| + | Multiple title variants are tried in order; a safety guard requires the | |
| + | returned page title to contain the member's last name. | |
| + | Image CDN: https://upload.wikimedia.org/ | |
| + | Licensing: pre-1929 portraits are public domain; post-1929 images are | |
| + | typically CC-BY-SA or CC-BY. Source attribution is on the Wikimedia Commons | |
| + | file page (linked from the filename embedded in the URL). | |
| + | ||
| + | CSP note for the frontend agent (do NOT edit next.config.js here): | |
| + | The app's img-src in next.config.js currently allows only | |
| + | https://unitedstates.github.io. To render images from these new sources, | |
| + | the frontend must extend img-src to include: | |
| + | https://bioguide.congress.gov | |
| + | https://upload.wikimedia.org | |
| + | This poller writes URLs now; images won't load in the browser until next.config.js | |
| + | is updated. | |
| + | ||
| + | Run once (CLI): | |
| + | python -m ingest.pollers.member_photos_extra --once | |
| + | ||
| + | Optional flags: | |
| + | --db PATH Override DB path. | |
| + | --delay SECONDS Sleep between requests (default 0.1 s). | |
| + | --bioguide-only Skip Wikimedia fallback. | |
| + | --wiki-only Skip bioguide, Wikimedia only. | |
| + | --current-first Prioritise current-congress members (legislators-current.json). | |
| + | """ | |
| + | ||
| + | import argparse | |
| + | import json | |
| + | import logging | |
| + | import time | |
| + | from typing import Optional | |
| + | ||
| + | import requests | |
| + | ||
| + | from ingest.db import get_connection, init_schema | |
| + | from ingest.pollers.members import ensure_votes_schema | |
| + | ||
| + | logger = logging.getLogger(__name__) | |
| + | ||
| + | SOURCE = "member_photos_extra" | |
| + | ||
| + | BIOGUIDE_PHOTO_BASE = "https://bioguide.congress.gov/photo" | |
| + | WIKI_API = "https://en.wikipedia.org/w/api.php" | |
| + | CURRENT_JSON_URL = ( | |
| + | "https://raw.githubusercontent.com/unitedstates/congress-legislators" | |
| + | "/gh-pages/legislators-current.json" | |
| + | ) | |
| + | ||
| + | # Common name suffixes to strip when building Wikipedia title variants. | |
| + | _SUFFIXES = {"III", "II", "IV", "V", "Jr.", "Sr.", "Jr", "Sr"} | |
| + | ||
| + | ||
| + | # ─── URL builders ───────────────────────────────────────────────────────────── | |
| + | ||
| + | def bioguide_photo_url(bioguide_id: str) -> str: | |
| + | """Return the official bioguide.congress.gov photo URL for a member.""" | |
| + | return f"{BIOGUIDE_PHOTO_BASE}/{bioguide_id}.jpg" | |
| + | ||
| + | ||
| + | def _extract_last_name(full_name: str) -> str: | |
| + | """ | |
| + | Return the last meaningful word from a full name, skipping trailing | |
| + | suffixes (Jr., III, etc.). | |
| + | ||
| + | Examples: | |
| + | "Fred Aandahl" → "Aandahl" | |
| + | "Nicholas J. Begich III"→ "Begich" | |
| + | "James R. Walkinshaw" → "Walkinshaw" | |
| + | """ | |
| + | parts = full_name.split() | |
| + | for part in reversed(parts): | |
| + | if part.rstrip(".,") not in _SUFFIXES: | |
| + | return part.rstrip(".,") | |
| + | return parts[-1].rstrip(".,") # fallback: last word | |
| + | ||
| + | ||
| + | def _wiki_title_variants(full_name: str) -> list[str]: | |
| + | """ | |
| + | Return a list of Wikipedia title candidates for a congress member, in | |
| + | order of preference. Tries exact name, name without middle initial / | |
| + | suffix, and (politician) disambiguation suffix. | |
| + | """ | |
| + | parts = full_name.split() | |
| + | ||
| + | # Strip common suffixes. | |
| + | meaningful = [p for p in parts if p.rstrip(".,") not in _SUFFIXES] | |
| + | ||
| + | # "First Last" (no middle initial/suffix). | |
| + | if len(meaningful) >= 2: | |
| + | first_last = f"{meaningful[0]} {meaningful[-1]}" | |
| + | else: | |
| + | first_last = full_name | |
| + | ||
| + | variants = [] | |
| + | seen = set() | |
| + | for name in [full_name, first_last]: | |
| + | for variant in [name, f"{name} (politician)"]: | |
| + | if variant not in seen: | |
| + | variants.append(variant) | |
| + | seen.add(variant) | |
| + | return variants | |
| + | ||
| + | ||
| + | # ─── Network helpers ────────────────────────────────────────────────────────── | |
| + | ||
| + | def check_bioguide(bioguide_id: str, session: requests.Session, timeout: int = 10) -> Optional[str]: | |
| + | """ | |
| + | Return the bioguide photo URL if the image exists (HTTP 200), else None. | |
| + | ||
| + | bioguide.congress.gov returns 403 on HEAD, so we use GET with stream=True | |
| + | to read only the response headers without downloading the image body. | |
| + | """ | |
| + | url = bioguide_photo_url(bioguide_id) | |
| + | try: | |
| + | resp = session.get(url, timeout=timeout, allow_redirects=True, stream=True) | |
| + | resp.close() # immediately release connection - we only wanted status | |
| + | if resp.status_code == 200: | |
| + | return url | |
| + | except requests.RequestException: | |
| + | pass | |
| + | return None | |
| + | ||
| + | ||
| + | def check_wikimedia( | |
| + | full_name: str, | |
| + | session: requests.Session, | |
| + | timeout: int = 10, | |
| + | thumb_size: int = 450, | |
| + | ) -> Optional[str]: | |
| + | """ | |
| + | Return a Wikimedia Commons thumbnail URL for the member, or None. | |
| + | ||
| + | Strategy (in order): | |
| + | 1. Direct page lookup: try the exact full_name and first+last variants. | |
| + | 2. Wikipedia opensearch fallback: search by "LAST FIRST Congress" and | |
| + | inspect the top result's page image if the title contains last name. | |
| + | ||
| + | A safety guard on every candidate requires the returned page title to | |
| + | contain the member's last name (guards against disambiguation errors). | |
| + | """ | |
| + | last_name = _extract_last_name(full_name).lower() | |
| + | variants = _wiki_title_variants(full_name) | |
| + | ||
| + | # --- Step 1: direct title lookup --- | |
| + | result = _wiki_query_pages(variants, last_name, session, timeout, thumb_size) | |
| + | if result: | |
| + | return result | |
| + | ||
| + | # --- Step 2: opensearch fallback --- | |
| + | # E.g. "Armstrong Congress senator" - the last name + "Congress" narrows | |
| + | # the search to the political person without needing the exact title. | |
| + | parts = full_name.split() | |
| + | last = _extract_last_name(full_name) | |
| + | search_query = f"{last} Congress" | |
| + | try: | |
| + | resp = session.get( | |
| + | WIKI_API, | |
| + | params={ | |
| + | "action": "opensearch", | |
| + | "search": search_query, | |
| + | "limit": 5, | |
| + | "format": "json", | |
| + | "redirects": "resolve", | |
| + | }, | |
| + | timeout=timeout, | |
| + | ) | |
| + | resp.raise_for_status() | |
| + | data = resp.json() | |
| + | # opensearch returns [query, [titles], [descriptions], [urls]] | |
| + | titles = data[1] if len(data) > 1 else [] | |
| + | candidate_titles = [t for t in titles if last_name in t.lower()] | |
| + | if candidate_titles: | |
| + | result = _wiki_query_pages( | |
| + | candidate_titles, last_name, session, timeout, thumb_size | |
| + | ) | |
| + | if result: | |
| + | return result | |
| + | except (requests.RequestException, ValueError, IndexError, KeyError): | |
| + | pass | |
| + | ||
| + | return None | |
| + | ||
| + | ||
| + | def _wiki_query_pages( | |
| + | titles: list[str], | |
| + | last_name: str, | |
| + | session: requests.Session, | |
| + | timeout: int, | |
| + | thumb_size: int, | |
| + | ) -> Optional[str]: | |
| + | """ | |
| + | Helper: for each title, query the Wikipedia pageimages API and return the | |
| + | first thumbnail URL whose page title contains last_name. | |
| + | """ | |
| + | for title in titles: | |
| + | params = { | |
| + | "action": "query", | |
| + | "titles": title, | |
| + | "prop": "pageimages", | |
| + | "pithumbsize": thumb_size, | |
| + | "format": "json", | |
| + | "redirects": "1", | |
| + | } | |
| + | try: | |
| + | resp = session.get(WIKI_API, params=params, timeout=timeout) | |
| + | resp.raise_for_status() | |
| + | data = resp.json() | |
| + | pages = data.get("query", {}).get("pages", {}) | |
| + | for page_id, page in pages.items(): | |
| + | if page_id == "-1": | |
| + | continue # not found | |
| + | page_title = page.get("title", "").lower() | |
| + | if last_name not in page_title: | |
| + | logger.debug( | |
| + | "member_photos_extra: wiki disambig skip: '%s' not in '%s'", | |
| + | last_name, | |
| + | page_title, | |
| + | ) | |
| + | continue | |
| + | thumb = page.get("thumbnail", {}) | |
| + | source = thumb.get("source") | |
| + | if source: | |
| + | return source | |
| + | except (requests.RequestException, ValueError, KeyError): | |
| + | continue | |
| + | return None | |
| + | ||
| + | ||
| + | # ─── Current-member ordering ────────────────────────────────────────────────── | |
| + | ||
| + | def _current_bioguide_ids(session: requests.Session) -> list[str]: | |
| + | """ | |
| + | Return the list of bioguide_ids from legislators-current.json, in JSON | |
| + | order. Used to front-load current congress members. | |
| + | """ | |
| + | try: | |
| + | resp = session.get(CURRENT_JSON_URL, timeout=30) | |
| + | resp.raise_for_status() | |
| + | data = resp.json() | |
| + | return [ | |
| + | leg["id"]["bioguide"] | |
| + | for leg in data | |
| + | if "bioguide" in leg.get("id", {}) | |
| + | ] | |
| + | except Exception as exc: | |
| + | logger.warning("member_photos_extra: could not fetch current roster: %s", exc) | |
| + | return [] | |
| + | ||
| + | ||
| + | # ─── Poll entry point ───────────────────────────────────────────────────────── | |
| + | ||
| + | def poll( | |
| + | db_path: Optional[str] = None, | |
| + | delay: float = 0.1, | |
| + | bioguide_only: bool = False, | |
| + | wiki_only: bool = False, | |
| + | current_first: bool = True, | |
| + | ) -> dict: | |
| + | """ | |
| + | For every member with photo_url IS NULL, try bioguide.congress.gov then | |
| + | Wikimedia Commons and write the first confirmed URL to photo_url. | |
| + | ||
| + | Idempotent: never overwrites an existing photo_url value. | |
| + | ||
| + | Parameters | |
| + | ---------- | |
| + | db_path : optional DB path override | |
| + | delay : seconds between outbound requests | |
| + | bioguide_only: skip the Wikimedia fallback | |
| + | wiki_only : skip bioguide (Wikimedia fallback only) | |
| + | current_first: fetch legislators-current.json and reorder so current | |
| + | congress members are processed first | |
| + | ||
| + | Returns | |
| + | ------- | |
| + | dict with keys: checked, bioguide_updated, wiki_updated, skipped | |
| + | """ | |
| + | conn = get_connection(db_path) if db_path else get_connection() | |
| + | init_schema(conn) | |
| + | ensure_votes_schema(conn) | |
| + | ||
| + | session = requests.Session() | |
| + | session.headers["User-Agent"] = "docket-ingest/1.0 (+https://github.com/docket)" | |
| + | ||
| + | # Fetch members that still have no photo_url. | |
| + | rows = conn.execute( | |
| + | "SELECT id, bioguide_id, full_name FROM members " | |
| + | "WHERE photo_url IS NULL AND bioguide_id IS NOT NULL" | |
| + | ).fetchall() | |
| + | ||
| + | if not rows: | |
| + | logger.info("member_photos_extra: no members need photos - done.") | |
| + | conn.close() | |
| + | return {"checked": 0, "bioguide_updated": 0, "wiki_updated": 0, "skipped": 0} | |
| + | ||
| + | # Optionally reorder: current congress members first. | |
| + | if current_first: | |
| + | current_ids = _current_bioguide_ids(session) | |
| + | current_set = set(current_ids) | |
| + | current_order = {bid: i for i, bid in enumerate(current_ids)} | |
| + | rows = sorted( | |
| + | rows, | |
| + | key=lambda r: (0 if r["bioguide_id"] in current_set else 1, | |
| + | current_order.get(r["bioguide_id"], 99999)), | |
| + | ) | |
| + | ||
| + | total = len(rows) | |
| + | logger.info( | |
| + | "member_photos_extra: %d members need photos (bioguide_only=%s wiki_only=%s) …", | |
| + | total, bioguide_only, wiki_only, | |
| + | ) | |
| + | ||
| + | checked = bioguide_updated = wiki_updated = skipped = 0 | |
| + | ||
| + | for row in rows: | |
| + | bioguide_id = row["bioguide_id"] | |
| + | full_name = row["full_name"] | |
| + | photo_url = None | |
| + | ||
| + | # --- Source 1: bioguide.congress.gov --- | |
| + | if not wiki_only: | |
| + | photo_url = check_bioguide(bioguide_id, session) | |
| + | if photo_url: | |
| + | bioguide_updated += 1 | |
| + | logger.debug( | |
| + | "member_photos_extra: bioguide hit: %s (%s)", | |
| + | bioguide_id, full_name, | |
| + | ) | |
| + | if delay > 0: | |
| + | time.sleep(delay) | |
| + | ||
| + | # --- Source 2: Wikimedia Commons --- | |
| + | if not photo_url and not bioguide_only: | |
| + | photo_url = check_wikimedia(full_name, session) | |
| + | if photo_url: | |
| + | wiki_updated += 1 | |
| + | logger.debug( | |
| + | "member_photos_extra: wikimedia hit: %s (%s)", | |
| + | full_name, photo_url, | |
| + | ) | |
| + | if delay > 0: | |
| + | time.sleep(delay) | |
| + | ||
| + | if photo_url: | |
| + | conn.execute( | |
| + | "UPDATE members SET photo_url = ? WHERE bioguide_id = ? AND photo_url IS NULL", | |
| + | (photo_url, bioguide_id), | |
| + | ) | |
| + | else: | |
| + | skipped += 1 | |
| + | ||
| + | checked += 1 | |
| + | ||
| + | if checked % 200 == 0: | |
| + | conn.commit() | |
| + | logger.info( | |
| + | "member_photos_extra: progress %d/%d " | |
| + | "(bioguide=%d wiki=%d skipped=%d)", | |
| + | checked, total, bioguide_updated, wiki_updated, skipped, | |
| + | ) | |
| + | ||
| + | conn.commit() | |
| + | conn.close() | |
| + | logger.info( | |
| + | "member_photos_extra: done. checked=%d bioguide=%d wiki=%d skipped=%d", | |
| + | checked, bioguide_updated, wiki_updated, skipped, | |
| + | ) | |
| + | return { | |
| + | "checked": checked, | |
| + | "bioguide_updated": bioguide_updated, | |
| + | "wiki_updated": wiki_updated, | |
| + | "skipped": skipped, | |
| + | } | |
| + | ||
| + | ||
| + | # ─── CLI ────────────────────────────────────────────────────────────────────── | |
| + | ||
| + | def main() -> None: | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | ) | |
| + | parser = argparse.ArgumentParser( | |
| + | description="Docket supplemental member photo poller (bioguide + Wikimedia)" | |
| + | ) | |
| + | parser.add_argument("--once", action="store_true", help="Run once and exit.") | |
| + | parser.add_argument("--db", help="Override DB path.") | |
| + | parser.add_argument( | |
| + | "--delay", | |
| + | type=float, | |
| + | default=0.1, | |
| + | help="Seconds between requests (default: 0.1).", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--bioguide-only", | |
| + | action="store_true", | |
| + | help="Skip Wikimedia - use bioguide.congress.gov only.", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--wiki-only", | |
| + | action="store_true", | |
| + | help="Skip bioguide - use Wikimedia Commons only.", | |
| + | ) | |
| + | parser.add_argument( | |
| + | "--no-current-first", | |
| + | action="store_true", | |
| + | help="Disable current-member priority ordering.", | |
| + | ) | |
| + | args = parser.parse_args() | |
| + | if not args.once: | |
| + | parser.print_help() | |
| + | return | |
| + | ||
| + | result = poll( | |
| + | db_path=args.db, | |
| + | delay=args.delay, | |
| + | bioguide_only=args.bioguide_only, | |
| + | wiki_only=args.wiki_only, | |
| + | current_first=not args.no_current_first, | |
| + | ) | |
| + | print( | |
| + | f"member_photos_extra: checked={result['checked']} " | |
| + | f"bioguide={result['bioguide_updated']} " | |
| + | f"wiki={result['wiki_updated']} " | |
| + | f"skipped={result['skipped']}" | |
| + | ) | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -16,9 +16,11 @@ a scrape_log row via ingest.state_coverage.log_scrape(). state_coverage.py | ||
| reads that table to compute per-state confidence. | ||
| """ | ||
| + | import argparse | |
| import json | ||
| import logging | ||
| import os | ||
| + | import sys | |
| import time | ||
| from datetime import datetime, timedelta | ||
| from typing import Optional | ||
| @@ -401,3 +403,36 @@ def poll( | ||
| total_bills, total_votes, | ||
| ) | ||
| return total_bills | ||
| + | ||
| + | ||
| + | # ─── CLI entry point ────────────────────────────────────────────────────────── | |
| + | ||
| + | def main() -> None: | |
| + | logging.basicConfig( | |
| + | level=logging.INFO, | |
| + | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| + | stream=sys.stdout, | |
| + | ) | |
| + | parser = argparse.ArgumentParser(description="Docket OpenStates bill poller") | |
| + | parser.add_argument("--once", action="store_true", help="Run once and exit.") | |
| + | parser.add_argument("--all", action="store_true", help="Poll all 52 jurisdictions.") | |
| + | parser.add_argument("--days-back", type=int, default=7, metavar="N", | |
| + | help="How many days back to query (default: 7).") | |
| + | parser.add_argument("--db", metavar="PATH", help="Override DB path.") | |
| + | args = parser.parse_args() | |
| + | ||
| + | if not args.once: | |
| + | parser.print_help() | |
| + | return | |
| + | ||
| + | jurisdictions = ALL_JURISDICTIONS if args.all else None | |
| + | count = poll( | |
| + | days_back=args.days_back, | |
| + | db_path=args.db, | |
| + | jurisdictions=jurisdictions, | |
| + | ) | |
| + | print(f"openstates: {count} new bill(s) stored") | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | main() |
| @@ -251,10 +251,30 @@ def compute(db_path: Optional[str] = None) -> list: | ||
| scrape_index = _build_scrape_index(conn) | ||
| + | # Only emit coverage rows for sources that have actual bill data in the DB. | |
| + | # A source is "active" if it has at least one row in the bills table. | |
| + | # This prevents a source that ran but fetched 0 bills (e.g. due to network error) | |
| + | # from dragging down coverage for all states it never actually served. | |
| + | # If no source has any bill data, fall back to computing all sources so the UI | |
| + | # shows full-missing rather than empty coverage. | |
| + | active_sources: set = set() | |
| + | try: | |
| + | for src in SOURCES: | |
| + | count = conn.execute( | |
| + | "SELECT COUNT(*) FROM bills WHERE source=?", (src,) | |
| + | ).fetchone()[0] | |
| + | if count > 0: | |
| + | active_sources.add(src) | |
| + | except Exception: | |
| + | pass | |
| + | sources_to_compute = [s for s in SOURCES if s in active_sources] | |
| + | if not sources_to_compute: | |
| + | sources_to_compute = SOURCES | |
| + | ||
| rows_out = [] | ||
| for state in ALL_STATES: | ||
| - | for source in SOURCES: | |
| + | for source in sources_to_compute: | |
| for data_type in DATA_TYPES: | ||
| key = (source, state, data_type) | ||
| last_scrape = scrape_index.get(key) |