Live dashboard: https://lszoszk.github.io/hrc-voting/
Complete harvest of the Voting collection of the OHCHR search library (https://searchlibrary.ohchr.org/search?c=Voting) — voting records of the Commission on Human Rights (CHR, 1946–2006) and the Human Rights Council (HRC, 2006–present) — parsed into dashboard-ready CSVs and an interactive, zero-build static dashboard.
Coverage: 6,346 resolutions / 80,159 individual country roll-call votes (1946–2026). Record count matches the collection total exactly (verified by three independent harvest methods).
scripts/harvest.py # download all MARCXML (per-year partitioning)
scripts/parse_marcxml.py # MARCXML -> resolutions.csv + votes_long.csv
scripts/build_dashboard_data.py# CSVs -> dashboard/data.js (compact payload)
data/raw/ # harvested MARCXML, one file per year (git-ignored)
data/csv/resolutions.csv # one row per resolution (metadata + vote totals)
data/csv/votes_long.csv # one row per (resolution, country) roll-call vote
dashboard/index.html # self-contained static dashboard (open directly)
dashboard/data.js # embedded data payload (~970 KB)
dashboard/index.html (+ data.js) is a self-contained static page (no build
step, no CDN — works offline and on GitHub Pages). Open it directly or serve the
folder. For sharing by e-mail, use dashboard/OHCHR_voting_dashboard.html —
a single ~1.1 MB file with the data inlined (regenerate by re-running the inline
step in the audit notes, or just re-copy after build_dashboard_data.py).
scripts/smoke_test.py loads the page from file:// in headless Chromium (light
- dark), clicks through every tab and key interaction, and fails on any console error — run it before sending the prototype onward. Five views over the ~1,705 recorded (roll-call) votes:
- Overview — volume over time (CHR→HRC), how contested votes were, most divided votes.
- Country profile — per-state yearly Yes/Abstain/No composition chart (100%
columns + votes-per-year strip + dashed membership-gap markers), closest /
furthest 30 voting partners (scrollable lists, coloured by UN group), a world
choropleth of voting agreement with the selected state (same blue/red encoding
as the blocs matrix;
world.jsgenerated byscripts/build_world_map.pyfrom Natural Earth 110m + centroid dots for small states), and the full sortable vote table. All resolution tables across the app carry a Subject column (the catalogued OHCHR tag) so you can see what a resolution is about without clicking. - Topics — browse by OHCHR's own catalogued subject tags (
agenda_subject, 991$d), split into thematic vs country-situation. The chip row shows the most frequent tags (expandable to all); the search box autocompletes subject tags after 2 letters (ranked by vote count, Enter picks the top hit) and free text also searches titles; results show a "related subject tags" row for one-click refinement. For any topic: the matching resolutions, a support-over-time trend (yearly average Yes-share from official totals, with a least-squares rising/falling note in pp per decade), and how every state voted (Yes/Abstain/No share, ranked by support). - Blocs & alignment — an alignment map (reference-pole scatter: each state placed by its % agreement with two anchor states you pick, default USA → vs China ↑, so position is directly interpretable), a voting-coincidence matrix (blue = alike, red = apart) with greedy clustering, regional-group cohesion (Hix–Noury–Roland Agreement Index per UN group) and a most-often-outvoted ranking (share of a state's votes that differ from the whole-chamber majority position). Country sets: 30 most active, each UN regional group, P5 + key states, plus computed 30 most agreeing / 30 most disagreeing states; filter by year range. Methods are documented in the Methodology tab.
- Methodology — explains, with live numbers pulled from
meta.coverage, why the other three views operate on ~1,705 resolutions instead of the full 6,346 (only recorded/roll-call votes carry a per-country breakdown), the vote-code legend, how topics are identified, and the known data-quality caveats below.
Exports. Every chart carries a CSV button (the exact data behind it) and a
PNG button (the chart itself, rasterised client-side — resolved palette colours
inlined, current palette's paper background); the data tables carry CSV. All
export is offline/client-side, so it works in the single-file build too.
scripts/audit.py sweeps both desktop and mobile viewports for overflow/console
bugs; scripts/export_test.py downloads and validates every CSV + PNG.
Topics come straight from the library catalogue — no ML/topic-modelling — so every
label is authoritative and traceable. The thematic/country split is a heuristic
(build_dashboard_data.py) and editable. Coverage/reconciliation stats shown in the
Methodology tab are computed at build time in build_dashboard_data.py
(meta.coverage) so they can never drift from the underlying CSVs.
Rebuild the payload after re-harvesting: python scripts/build_dashboard_data.py.
python3 -m venv .venv && source .venv/bin/activate
pip install playwright && playwright install chromium
python scripts/harvest.py # ~2 min; resumable (skips years already on disk)
python scripts/parse_marcxml.py # writes data/csv/*.csv| column | source | notes |
|---|---|---|
record_id |
MARC 001 | stable Invenio id; record_url links to the page |
symbol |
791$a | e.g. A/HRC/RES/61/29, E/CN.4/RES/2005/1 |
title, statement |
245$a$b, 245$c | |
date, year |
269$a → ISO | falls back to 992$a |
body |
981$a | Commission on Human Rights / Human Rights Council |
vote_type |
591$a | RECORDED · ADOPTED WITHOUT VOTE · NON-RECORDED · WITHDRAWN · NOT CONSIDERED … |
meeting, meeting_type |
952$a, 793$v | |
agenda_item_no/title/symbol/subject |
991$b/$c/$a/$d | |
main_sponsors |
500$a | parsed from the "Main sponsors :" note |
yes,no,abstain,nonvoting,total |
996$b/$c/$d/$e/$f | official vote totals |
n_rollcall |
count of 967 | number of per-country rows in votes_long |
url_resolution, url_draft |
856$u | undocs.org links |
One row per country per resolution. record_id joins to resolutions.csv.
| column | meaning |
|---|---|
iso3, country |
967$b (ISO-3166 alpha-3), 967$e (name as catalogued) |
vote |
see legend below |
Vote code legend (967$d):
| code | meaning | rows |
|---|---|---|
Y |
Yes | 44,536 |
N |
No | 18,368 |
A |
Abstain | 15,911 |
. |
Absent / did not participate | 701 |
| `` (empty) | Not a Council member at the time / no position recorded | 643 |
Only ~1,705 resolutions (RECORDED votes) carry a per-country roll-call; the rest
were adopted without a vote or only totals were published (totals are still in
resolutions.csv).
- ~148 recorded votes (~9%, mostly older CHR) have a roll-call whose Y/N/A counts don't perfectly reconcile with the official 996 totals — almost always a discrepancy in abstentions / non-participation, occasionally a blank vote code where the total implies a position. These are inconsistencies in OHCHR's own MARC cataloguing; the data is preserved as-is (996 = official totals).
- One typo (
y→Y, UK 2011) is normalised; nothing else is altered.
Pages is served from dashboard/ by the workflow in
.github/workflows/pages.yml (Pages source =
GitHub Actions). Every push to main that touches dashboard/** redeploys.
To refresh the data end-to-end:
python scripts/harvest.py # re-harvest MARCXML (needs Playwright + a browser)
python scripts/parse_marcxml.py # -> data/csv/*.csv
python scripts/build_dashboard_data.py # -> dashboard/data.js
python scripts/build_world_map.py # -> dashboard/world.js (only if geography changes)
python scripts/smoke_test.py # file:// regression check, must pass
git commit -am "refresh data" && git push # Actions redeploys PagesIf you use this software, the data pipeline, or the derived datasets, please
cite it (see CITATION.cff; GitHub's "Cite this repository"
button generates APA/BibTeX from it):
Szoszkiewicz, Ł. (2026). OHCHR Voting Records — CHR · HRC (v1.0.0) [Software]. Zenodo. https://doi.org/10.5281/zenodo.21281232
Code and data are released under the PolyForm Noncommercial License 1.0.0
(LICENSE) — research, education, non-profit, and personal use are
permitted; commercial use requires a separate licence from the author. The
underlying voting records are public UN documents from the OHCHR Search Library.
Web fonts are loaded from Google Fonts under their own SIL Open Font License.
- The site sits behind AWS WAF with a JS challenge — plain
curl/fetch get a 202 with an empty body. A real browser (Playwright Chromium) solves it silently and yields anaws-waf-tokencookie (valid a few minutes; the script re-solves on demand). - Invenio hard-caps deep pagination at
jrec=5000: paging the full result set returns only ~5,199 unique records and then repeats the tail. Per-year partitioning (p=year:YYYY, each < 300 records) stays under the cap and is provably complete.of=iddoes not paginate; OAI-PMH exposes no Voting set.