#!/usr/bin/env python3 """Build the embeddable dashboard artifact under results//. Wipes and recreates results//, copies templates + vendor + per-member data, stamps the manifest version into the HTML pages, and writes a README with embedding instructions. Usage: python3 build_app.py --congress 119 """ import argparse import json import shutil import sys from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parent TEMPLATE_FILES = [ "app.html", "compare.html", "ranking.html", "app.js", "compare.js", "ranking.js", "app.css", ] VENDOR_FILES = [ "vendor/chart.umd.min.js", "vendor/sortable.min.js", ] def dir_size_mb(path: Path) -> float: total = 0 for p in path.rglob("*"): if p.is_file(): total += p.stat().st_size return total / (1024 * 1024) def stamp_manifest_version(html_path: Path, version: str, generated_at: str, manifest: dict) -> None: text = html_path.read_text(encoding="utf-8") ver_tag = ( '" ) # Inline the full manifest so the picker loads under file:// (per-member JSON # still requires HTTP — but the app shell is interactive without a server). manifest_tag = ( '" ) if "" not in text: raise SystemExit(f"build_app: no in {html_path}") text = text.replace("", ver_tag + "\n" + manifest_tag + "\n", 1) html_path.write_text(text, encoding="utf-8") README_TEMPLATE = """# {congress_ord} Congress Voting Dashboard — Embeddable Artifact This directory is a self-contained dashboard for the {congress_ord} Congress. No external network requests at runtime; all data, charts, and vendor scripts ship in this directory. ## Files - `app.html` — single-member dashboard - `compare.html` — multi-member comparison view - `app.js`, `compare.js`, `app.css` — application code - `vendor/chart.umd.min.js` — Chart.js 4.4.0 - `vendor/sortable.min.js` — SortableJS 1.15.2 - `data/manifest.json` — member index (~{member_count} entries) - `data/members/.json` — per-member metrics (~80 KB each) ## Embed modes ### 1. Standalone Open `app.html` (or `compare.html`) directly. ### 2. Iframe ```html ``` ### 3. Inline (single host page) ```html
``` All CSS is scoped under `#polisci-root` to avoid collisions with host styles. Override `data-base` to point at the data directory served from your host. ## Recommended Content Security Policy ``` Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors ; base-uri 'none'; form-action 'none' ``` ## Regenerating From the project root: ``` python3 fetch.py --congress {congress} python3 parse.py --congress {congress} python3 enrich_roster.py --congress {congress} pytest tests/ python3 build_members.py --congress {congress} python3 build_app.py --congress {congress} ``` Or the all-in-one: ``` python3 build_all.py --congress {congress} ``` ## Provenance Each per-member JSON includes a `_meta` block with `schema_version`, `pipeline_version`, `classifier_hash` (SHA-256 of analyze.py), `data_snapshot_date`, and `source_xml_count`. See `DOCUMENTATION.md` in the source repository for full methodology. """ def ordinal(n: int) -> str: if 11 <= (n % 100) <= 13: suf = "th" else: suf = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") return f"{n}{suf}" def main() -> int: ap = argparse.ArgumentParser(description="Build embeddable dashboard artifact.") ap.add_argument("--congress", type=int, default=119) args = ap.parse_args() congress = args.congress template_dir = ROOT / "template" data_dir = ROOT / "data" / str(congress) manifest_path = data_dir / "manifest.json" members_dir = data_dir / "members" out_dir = ROOT / "results" / str(congress) if not manifest_path.is_file(): print( f"build_app: missing {manifest_path}; run build_members.py --congress {congress} first", file=sys.stderr, ) return 2 with manifest_path.open("r", encoding="utf-8") as f: manifest = json.load(f) version = manifest.get("version", "unknown") member_count = len(manifest.get("members", [])) # Wipe + recreate if out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir(parents=True, exist_ok=True) print(f"build_app: cleaned and recreated {out_dir}/") # Copy template files for rel in TEMPLATE_FILES: src = template_dir / rel dst = out_dir / rel dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) # Copy vendor files for rel in VENDOR_FILES: src = template_dir / rel dst = out_dir / rel dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) # Stamp manifest version + full inline manifest into HTML heads generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") for html_name in ("app.html", "compare.html", "ranking.html"): stamp_manifest_version(out_dir / html_name, version, generated_at, manifest) # Copy manifest + members data out_data = out_dir / "data" out_data.mkdir(parents=True, exist_ok=True) shutil.copy2(manifest_path, out_data / "manifest.json") shutil.copytree(members_dir, out_data / "members", dirs_exist_ok=True) # README readme = README_TEMPLATE.format( congress=congress, congress_ord=ordinal(congress), member_count=member_count, ) (out_dir / "README.md").write_text(readme, encoding="utf-8") # Methodology — copied verbatim from project root; linked from page footers methodology_src = Path("Methodology.md") if methodology_src.exists(): shutil.copy2(methodology_src, out_dir / "Methodology.md") size_mb = dir_size_mb(out_dir) print(f"build_app: results/{congress}/ ready ({member_count} members, {size_mb:.1f} MB)") return 0 if __name__ == "__main__": sys.exit(main())