| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220 |
- #!/usr/bin/env python3
- """Build the embeddable dashboard artifact under results/<C>/.
- Wipes and recreates results/<C>/, 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 = (
- '<script type="application/json" id="polisci-manifest-version">'
- + json.dumps({"version": version, "generated_at": generated_at}, separators=(",", ":"))
- + "</script>"
- )
- # 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 = (
- '<script type="application/json" id="polisci-manifest">'
- + json.dumps(manifest, separators=(",", ":"))
- + "</script>"
- )
- if "</head>" not in text:
- raise SystemExit(f"build_app: no </head> in {html_path}")
- text = text.replace("</head>", ver_tag + "\n" + manifest_tag + "\n</head>", 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/<id>.json` — per-member metrics (~80 KB each)
- ## Embed modes
- ### 1. Standalone
- Open `app.html` (or `compare.html`) directly.
- ### 2. Iframe
- ```html
- <iframe
- src="https://your.host/path/to/app.html"
- sandbox="allow-scripts allow-same-origin"
- referrerpolicy="no-referrer"
- style="width:100%;min-height:1200px;border:0"></iframe>
- ```
- ### 3. Inline (single host page)
- ```html
- <link rel="stylesheet" href="https://your.host/path/to/app.css">
- <div id="polisci-root" data-base="https://your.host/path/to/data/"></div>
- <script src="https://your.host/path/to/vendor/chart.umd.min.js" defer></script>
- <script src="https://your.host/path/to/vendor/sortable.min.js" defer></script>
- <script src="https://your.host/path/to/app.js" defer></script>
- ```
- 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 <your-domain>; 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())
|