Pipeline architecture β
ECI HTML (results.eci.gov.in) ββ
β
Census 2011 XLS (censusindia.gov.in) βββ€
β
kracekumar 2021 CSV (GitHub) βββΌββΊ Python (Polars + DuckDB)
β β
ECI Form 20 PDFs (TN CEO) βββ€ βΌ
β pdfplumber β Hetzner S3 (s3://tnelection2026/)
β βββ raw/ β exact bytes fetched
TN CEO voter rolls (CAPTCHA + PDF) βββ€ βββ curated/ β typed Parquet, zstd
β 2captcha + tesseract β β
β βΌ
DataMeet shapefile (GitHub) βββ DuckDB queries β docs/insights/*.json
β
βΌ
VitePress site (this site)Two hosts, one bucket β
| Host | What runs here | Why |
|---|---|---|
| Laptop (Indian IP) | form20.py, voters.py β TN CEO scrapes | TN State sites geo-block foreign IPs |
| deemwar-app1 (Hetzner Germany, 12 cores, 62GB RAM) | results.py, historical.py, demographics.py, path_a_build.py, voters_ocr.py | Compute-heavy + no geo issues |
Both hosts read the same env file shape (Hetzner S3 creds) and write to the same s3://tnelection2026/ bucket. Spikes are pure functions of network β S3 β they don't care which host ran them.
Storage layout β
s3://tnelection2026/
βββ results/ raw HTML + curated parquet (AC Γ candidate Γ round, 2026)
βββ form20/ raw PDF + curated parquet (booth Γ candidate, 2026 β partial)
βββ voters/ raw PDF + OCR'd voter list (booth-level, partial)
βββ candidates/ MyNeta scrapes (deprioritized β present but not used here)
βββ historical/ 2011/2016/2021 raw + parquet
βββ caste/ per-AC reservation flag + Wikipedia prose
βββ religion/ state baseline + (when available) booth-inferred from voter names
βββ alliance/ Wikipedia-sourced partyβalliance map per year
βββ geo/ DataMeet AC polygons (WKT)
βββ demographics/ Census 2011 district religion mix + AC-joined view
βββ insights/ 2021β2026 swing Γ demographic joins (curated)Phases β
Phase 1 β Raw dump β
Every spike downloads exact bytes from the original source to s3://.../raw/. This is the audit trail. Never mutated. If a parser changes, we re-derive from raw.
Phase 2 β Curated parquet β
Each spike's curate() reads from raw/, normalises, writes typed Parquet to s3://.../curated/year=YYYY/ac=NN/*.parquet. Polars for in-memory work, DuckDB for SQL + S3 reads.
Phase 3 β Insights β
DuckDB queries across curated/ parquets produce small JSON insight files for the site. See pipelines/path_a_build.py for the complete end-to-end script.
Why Parquet + zstd β
A 174 KB HTML page (one AC's Roundwise) becomes an 8.7 KB Parquet after parsing β 20Γ compression with no information loss. DuckDB reads Parquet from S3 via httpfs extension at near-native speed:
INSTALL httpfs; LOAD httpfs;
SET s3_endpoint='hel1.your-objectstorage.com';
SET s3_access_key_id='...';
SET s3_secret_access_key='...';
SELECT ac_no, ac_name, SUM(total_votes) AS total
FROM read_parquet('s3://tnelection2026/results/curated/year=2026/ac=*/candidate_totals.parquet',
hive_partitioning=true)
GROUP BY 1, 2
ORDER BY total DESC LIMIT 5;That's the whole architecture. No database server, no Spark, no cron Airflow. Bucket + Parquet + DuckDB on a 12-core box.
Two products under one repo β
This pipeline is the analyser. There's a separate live scoreboard in the same repo (scrape.ts, update.sh, docs/insights.json) that ran every 2 minutes during the May 2026 counting day and was the user-facing dashboard. The two share no code; the analyser is additive.