Skip to content

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 ​

HostWhat runs hereWhy
Laptop (Indian IP)form20.py, voters.py β€” TN CEO scrapesTN 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.pyCompute-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:

sql
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.

Built from public data β€” ECI, Census 2011, kracekumar/tn_elections.