#!/usr/bin/env python3 """Parameterized builder: parse cached clerk.house.gov XMLs for a given member and emit a self-contained dashboard HTML. Usage: python3 build_member.py "" """ import sys, os, json, glob import xml.etree.ElementTree as ET from collections import Counter, defaultdict CACHE_DIR = "/home/user/polisci/vote_cache" OUT_DIR = "/home/user/polisci/results" MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] def parse_xml(path, year, roll, bioguide): try: with open(path, "rb") as f: root = ET.fromstring(f.read()) except Exception: return None meta = root.find("vote-metadata") if meta is None: return None def t(tag): el = meta.find(tag) return (el.text or "").strip() if el is not None else "" info = { "year": year, "roll": roll, "date": t("action-date"), "question": t("vote-question"), "result": t("vote-result"), "legis_num": t("legis-num"), "desc": t("vote-desc"), } party_totals = {} for pt in meta.findall("vote-totals/totals-by-party"): party = pt.findtext("party","").strip() party_totals[party] = { "yea": int(pt.findtext("yea-total","0") or 0), "nay": int(pt.findtext("nay-total","0") or 0), } info["R"] = party_totals.get("Republican", {"yea":0,"nay":0}) info["D"] = party_totals.get("Democratic", {"yea":0,"nay":0}) member_vote = None for rv in root.iter("recorded-vote"): leg = rv.find("legislator") if leg is not None and leg.get("name-id") == bioguide: v = rv.find("vote") member_vote = (v.text or "").strip() if v is not None else None break info["member"] = member_vote return info 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","Aye","No"): return ("N/A: " + (m or "absent"), None, r_pos, d_pos) m_norm = "Yea" if m in ("Yea","Aye") else "Nay" helped_r = (r_pos != "Split" and m_norm == r_pos) helped_d = (d_pos != "Split" and m_norm == 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() measure_failed = "fail" in result or "reject" in result or "not agreed" in result or "not passed" in result blocked = None if d_pos == "Yea" and measure_failed and m_norm == "Nay" and r_pos != "Yea": blocked = "Democrat" if r_pos == "Yea" and measure_failed and m_norm == "Nay" and d_pos != "Yea": blocked = "Republican" return (align, blocked, r_pos, d_pos) def gather(bioguide, member_party): votes = [] for path in sorted(glob.glob(f"{CACHE_DIR}/*.xml")): base = os.path.basename(path).replace(".xml","") try: year, roll = base.split("_") year, roll = int(year), int(roll) except Exception: continue v = parse_xml(path, year, roll, bioguide) 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: try: d,m,y = v["date"].split("-") mk = f"{y}-{MONTHS.index(m)+1:02d}" month_align[mk][v["alignment"]] += 1 except Exception: pass 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["year"],"r":v["roll"],"d":v["date"],"ln":v["legis_num"], "q":v["question"],"ds":v["desc"][: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","Aye","No")) yeas = sum(1 for v in votes if v["member"] in ("Yea","Aye")) nays = sum(1 for v in votes if v["member"] in ("Nay","No")) 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","Aye","No"): continue m_norm = "Yea" if v["member"] in ("Yea","Aye") else "Nay" r_pos = v["r_pos"]; d_pos = v["d_pos"] if r_pos != "Split": if m_norm == r_pos: vw_gop += 1 else: va_gop += 1 if d_pos != "Split": if m_norm == d_pos: vw_dem += 1 else: va_dem += 1 # Lone-wolf: defected from OWN party majority with <=5 fellow defectors own = member_party lone_wolf = 0 for v in votes: if v["member"] not in ("Yea","Nay","Aye","No"): continue m_norm = "Yea" if v["member"] in ("Yea","Aye") else "Nay" own_pos = v["r_pos"] if own == "R" else v["d_pos"] if own_pos == "Split" or m_norm == 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 <= 5: lone_wolf += 1 blocked_dem = sum(1 for v in votes if v["blocked"] == "Democrat") blocked_rep = sum(1 for v in votes if v["blocked"] == "Republican") 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":blocked_dem,"blocked_rep_count":blocked_rep, "lone_wolf":lone_wolf, "voted_against_gop":va_gop,"voted_against_dem":va_dem, "voted_with_gop":vw_gop,"voted_with_dem":vw_dem, "voted_against_own":(va_gop if own=="R" else va_dem), "voted_with_own":(vw_gop if own=="R" else vw_dem), } HTML_TEMPLATE = r""" __NAME__ - 119th Congress Voting Dashboard

__NAME____PARTY__

Bioguide __BIOGUIDE__ · House roll-call votes, 119th Congress (Jan 2025 – present) · Source: clerk.house.gov
__MEMBER_NOTE__
↕ Drag any card to rearrange.
Roll Calls / Participation
__VOTING__ / __TOTAL__ __VOTING_PCT__%
119th Congress · Yea/Aye: __YEAS__ · Nay/No: __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__%
Member Nay + D wanted Yea + measure failed
Blocked GOP-Backed
__BLK_REP__ __BLK_REP_PCT__%
Member Nay + R wanted Yea + measure failed
Lone Wolf Defections
__LONE__ __LONE_PCT__%
Defected from own (__PARTY__) party majority w/ ≤5 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(bioguide, name, party, out_name, note=None): votes = gather(bioguide, party) 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("__BIOGUIDE__", bioguide) .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)) ) 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"alignment R:{data['alignment'].get('Helped Republicans',0)} " f"D:{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__": os.makedirs(OUT_DIR, exist_ok=True) if len(sys.argv) >= 5: render(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]) else: # Roster entries: (bioguide, name, party, output_filename, optional_note) # See DOCUMENTATION.md §3.1 for when to add a note. MTG_NOTE = ("Rep. Greene publicly announced her resignation from the House " "in late 2025, effective January 5, 2026. This explains her " "substantially lower participation count (325 of 553) versus " "other members analyzed. Votes after her departure date are " "necessarily recorded as Not Voting in clerk.house.gov data.") ROSTER = [ ("K000389", "Ro Khanna", "D", "RoKhanna119.html"), ("O000172", "Alexandria Ocasio-Cortez", "D", "AlexandriaOcasioCortez119.html"), ("O000173", "Ilhan Omar", "D", "IlhanOmar119.html"), ("G000596", "Marjorie Taylor Greene", "R", "MarjorieTaylorGreene119.html", MTG_NOTE), ("J000289", "Jim Jordan", "R", "JimJordan119.html"), ("D000032", "Byron Donalds", "R", "ByronDonalds119.html"), ] for entry in ROSTER: bg, nm, pt, fn = entry[:4] note = entry[4] if len(entry) > 4 else None render(bg, nm, pt, fn, note)