#!/usr/bin/env python3 """Parameterized Senate dashboard builder. Parses cached senate.gov XMLs for a given senator (LIS ID) and emits a self-contained HTML dashboard. Usage: python3 build_senator.py "" python3 build_senator.py # builds the default ROSTER """ import sys, os, json, glob, re import xml.etree.ElementTree as ET from collections import Counter, defaultdict CACHE_DIR = "/home/user/polisci/senate_vote_cache" OUT_DIR = "/home/user/polisci/results" MONTHS_FULL = ["January","February","March","April","May","June", "July","August","September","October","November","December"] # Independents who caucus with Democrats — treat as D for party-majority math. DEM_CAUCUSING_INDEPENDENTS = {"S313"} # Sanders (VT); add King (S354) etc as needed. def parse_date(s): """'January 9, 2025, 02:54 PM' -> ('2025-01', '2025-01-09').""" if not s: return None, None m = re.match(r"\s*(\w+)\s+(\d+),\s+(\d+)", s) if not m: return None, None mon, day, yr = m.groups() try: mi = MONTHS_FULL.index(mon) + 1 return f"{yr}-{mi:02d}", f"{yr}-{mi:02d}-{int(day):02d}" except ValueError: return None, None def parse_xml(path, session, vnum, lis_id): try: with open(path, "rb") as f: root = ET.fromstring(f.read()) except Exception: return None def t(tag): el = root.find(tag) return (el.text or "").strip() if el is not None and el.text else "" # Build party totals from members R = {"yea":0,"nay":0,"present":0,"nv":0} D = {"yea":0,"nay":0,"present":0,"nv":0} member_vote = None for mem in root.iter("member"): party = (mem.findtext("party","") or "").strip() vc = (mem.findtext("vote_cast","") or "").strip() lid = (mem.findtext("lis_member_id","") or "").strip() if lid == lis_id: member_vote = vc bucket = ("yea" if vc == "Yea" else "nay" if vc == "Nay" else "present" if vc == "Present" else "nv") if party == "R": R[bucket] += 1 elif party == "D" or lid in DEM_CAUCUSING_INDEPENDENTS: D[bucket] += 1 # other independents (none currently caucus with R) ignored for party math # Compose bill id doc_type = root.findtext("document/document_type","") or "" doc_num = root.findtext("document/document_number","") or "" legis = (doc_type + " " + doc_num).strip() if (doc_type or doc_num) else (root.findtext("vote_title","") or "") date_raw = t("vote_date") return { "session": session, "vnum": vnum, "date_raw": date_raw, "month_key": parse_date(date_raw)[0], "date_iso": parse_date(date_raw)[1], "question": t("question") or t("vote_question_text"), "result": t("vote_result"), "legis_num": legis, "desc": t("vote_title") or t("vote_document_text"), "R": R, "D": D, "member": member_vote, } def classify(v): r_yea, r_nay = v["R"]["yea"], v["R"]["nay"] d_yea, d_nay = v["D"]["yea"], v["D"]["nay"] r_pos = "Yea" if r_yea > r_nay else ("Nay" if r_nay > r_yea else "Split") d_pos = "Yea" if d_yea > d_nay else ("Nay" if d_nay > d_yea else "Split") m = v["member"] if m not in ("Yea","Nay"): return ("N/A: " + (m or "absent"), None, r_pos, d_pos) helped_r = (r_pos != "Split" and m == r_pos) helped_d = (d_pos != "Split" and m == d_pos) if helped_r and helped_d: align = "Helped Both" elif helped_r: align = "Helped Republicans" elif helped_d: align = "Helped Democrats" else: align = "Helped Neither" result = v["result"].lower() # Senate results often say "agreed to" / "rejected" / "passed" / "failed" / "not agreed to" measure_failed = ("rejected" in result or "not agreed" in result or "failed" in result or "not passed" in result or "not invoked" in result) blocked = None if d_pos == "Yea" and measure_failed and m == "Nay" and r_pos != "Yea": blocked = "Democrat" if r_pos == "Yea" and measure_failed and m == "Nay" and d_pos != "Yea": blocked = "Republican" return (align, blocked, r_pos, d_pos) def gather(lis_id): votes = [] for path in sorted(glob.glob(f"{CACHE_DIR}/*.xml")): base = os.path.basename(path).replace(".xml","") try: session, vnum = base.split("_") session, vnum = int(session), int(vnum) except Exception: continue v = parse_xml(path, session, vnum, lis_id) if not v: continue align, blocked, r_pos, d_pos = classify(v) v["alignment"] = align v["blocked"] = blocked v["r_pos"] = r_pos v["d_pos"] = d_pos votes.append(v) return votes def aggregate(votes, member_party): align_counts = Counter(v["alignment"] for v in votes) member_counts = Counter(v["member"] for v in votes) blocked_counts = Counter(v["blocked"] for v in votes if v["blocked"]) month_align = defaultdict(Counter) for v in votes: if v["month_key"]: month_align[v["month_key"]][v["alignment"]] += 1 months_sorted = sorted(month_align.keys()) align_labels = ["Helped Republicans","Helped Democrats","Helped Both","Helped Neither"] monthly = {l: [month_align[m].get(l,0) for m in months_sorted] for l in align_labels} def row(v): return {"y": (v["date_iso"] or "")[:4] or "?", "r": v["vnum"], "d": v["date_raw"] or "", "ln": v["legis_num"], "q": v["question"], "ds": (v["desc"] or "")[:90], "rs": v["result"], "m": v["member"], "ry": v["R"]["yea"], "rn": v["R"]["nay"], "dy": v["D"]["yea"], "dn": v["D"]["nay"], "a": v["alignment"], "b": v["blocked"] or ""} rows = [row(v) for v in votes] total = len(votes) voting = sum(1 for v in votes if v["member"] in ("Yea","Nay")) yeas = sum(1 for v in votes if v["member"] == "Yea") nays = sum(1 for v in votes if v["member"] == "Nay") nv = sum(1 for v in votes if v["member"] == "Not Voting") present = sum(1 for v in votes if v["member"] == "Present") va_gop = vw_gop = va_dem = vw_dem = 0 for v in votes: if v["member"] not in ("Yea","Nay"): continue r_pos = v["r_pos"]; d_pos = v["d_pos"] if r_pos != "Split": if v["member"] == r_pos: vw_gop += 1 else: va_gop += 1 if d_pos != "Split": if v["member"] == d_pos: vw_dem += 1 else: va_dem += 1 own = member_party lone_wolf = 0 for v in votes: if v["member"] not in ("Yea","Nay"): continue own_pos = v["r_pos"] if own == "R" else v["d_pos"] if own_pos == "Split" or v["member"] == own_pos: continue own_totals = v["R"] if own == "R" else v["D"] defectors = own_totals["nay"] if own_pos == "Yea" else own_totals["yea"] if defectors <= 3: # Senate threshold tighter (smaller chamber) lone_wolf += 1 return { "total":total,"voting":voting,"yeas":yeas,"nays":nays,"nv":nv,"present":present, "alignment":dict(align_counts),"member":dict(member_counts), "blocked":dict(blocked_counts),"months":months_sorted,"monthly":monthly, "rows":rows, "blocked_dem_count":sum(1 for v in votes if v["blocked"] == "Democrat"), "blocked_rep_count":sum(1 for v in votes if v["blocked"] == "Republican"), "lone_wolf":lone_wolf, "voted_against_gop":va_gop,"voted_against_dem":va_dem, "voted_with_gop":vw_gop,"voted_with_dem":vw_dem, } HTML_TEMPLATE = r""" __NAME__ - 119th Congress Voting Dashboard

__NAME____PARTY__SENATE

LIS __LIS__ · Senate roll-call votes, 119th Congress (Jan 2025 – present) · Source: senate.gov
__MEMBER_NOTE__
↕ Drag any card to rearrange.
Roll Calls / Participation
__VOTING__ / __TOTAL__ __VOTING_PCT__%
119th Congress · Yea: __YEAS__ · Nay: __NAYS__ · Not Voting: __NV__ · Present: __PRESENT__
Voted Against GOP Majority
__VA_GOP__ __VA_GOP_PCT__%
of __PARTISAN_R__ votes where R majority took a side
Voted Against Dem Majority
__VA_DEM__ __VA_DEM_PCT__%
of __PARTISAN_D__ votes where D majority took a side
Blocked Dem-Backed
__BLK_DEM__ __BLK_DEM_PCT__%
Senator Nay + D wanted Yea + measure failed
Blocked GOP-Backed
__BLK_REP__ __BLK_REP_PCT__%
Senator Nay + R wanted Yea + measure failed
Lone Wolf Defections
__LONE__ __LONE_PCT__%
Defected from own (__PARTY__) party majority w/ ≤3 fellow defectors

Alignment Classification

Vote Distribution

Voted With vs. Against — by Party Majority

Blocking Wins

Alignment Over Time (monthly)

All Votes (filterable)

Yr#DateBillQuestionDescriptionResultVoteR YeaR NayD YeaD NayAlignmentBlocked
Click headers to sort. Showing 0 rows.
""" def render(lis_id, name, party, out_name, note=None): votes = gather(lis_id) if not votes: print(f" WARN: no votes parsed for {name}", file=sys.stderr) data = aggregate(votes, party) def pct(n, d): return f"{(n/d*100):.1f}" if d else "0.0" partisan_r = data["voted_with_gop"] + data["voted_against_gop"] partisan_d = data["voted_with_dem"] + data["voted_against_dem"] color = "#d93b3b" if party == "R" else ("#3b6ed9" if party == "D" else "#888") note_html = f'
Note: {note}
' if note else '' html = (HTML_TEMPLATE .replace("__MEMBER_NOTE__", note_html) .replace("__NAME__", name) .replace("__LIS__", lis_id) .replace("__PARTY__", party) .replace("__PARTY_COLOR__", color) .replace("__TOTAL__", str(data["total"])) .replace("__VOTING__", str(data["voting"])) .replace("__VOTING_PCT__", pct(data["voting"], data["total"])) .replace("__YEAS__", str(data["yeas"])) .replace("__NAYS__", str(data["nays"])) .replace("__NV__", str(data["nv"])) .replace("__PRESENT__", str(data["present"])) .replace("__VA_GOP__", str(data["voted_against_gop"])) .replace("__VA_GOP_PCT__", pct(data["voted_against_gop"], partisan_r)) .replace("__VA_DEM__", str(data["voted_against_dem"])) .replace("__VA_DEM_PCT__", pct(data["voted_against_dem"], partisan_d)) .replace("__PARTISAN_R__", str(partisan_r)) .replace("__PARTISAN_D__", str(partisan_d)) .replace("__BLK_DEM__", str(data["blocked_dem_count"])) .replace("__BLK_DEM_PCT__", pct(data["blocked_dem_count"], data["total"])) .replace("__BLK_REP__", str(data["blocked_rep_count"])) .replace("__BLK_REP_PCT__", pct(data["blocked_rep_count"], data["total"])) .replace("__LONE__", str(data["lone_wolf"])) .replace("__LONE_PCT__", pct(data["lone_wolf"], data["voting"])) .replace("__DATA__", json.dumps(data)) ) os.makedirs(OUT_DIR, exist_ok=True) out = os.path.join(OUT_DIR, out_name) with open(out, "w") as f: f.write(html) print(f" {name}: {data['voting']}/{data['total']} voted, " f"R-aligned:{data['alignment'].get('Helped Republicans',0)} " f"D-aligned:{data['alignment'].get('Helped Democrats',0)} " f"Both:{data['alignment'].get('Helped Both',0)} " f"Neither:{data['alignment'].get('Helped Neither',0)} → {out}") if __name__ == "__main__": if len(sys.argv) >= 5: render(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]) else: ROSTER = [ ("S293", "Lindsey Graham", "R", "LindseyGraham119.html"), ] for entry in ROSTER: lis, nm, pt, fn = entry[:4] note = entry[4] if len(entry) > 4 else None render(lis, nm, pt, fn, note)