diff --git a/wizard-work/ledger/extract_gdp_imf_wb_un.py b/wizard-work/ledger/extract_gdp_imf_wb_un.py
new file mode 100644
index 0000000..ccb9415
--- /dev/null
+++ b/wizard-work/ledger/extract_gdp_imf_wb_un.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Extract the IMF / World Bank / UN GDP table (gdp_table.html) into a clean
+CSV and markdown table.
+
+Rules (deterministic, derived from the source, not from the prior partial run):
+
+ * The main table is the one whose caption is "GDP forecast or estimate
+ (million US$) by country".
+ * Data rows =
blocks whose first | contains a letter (a country name).
+ * Country name: visible text of the first cell with HTML stripped, HTML
+ unescaped, collapsed; footnote citation markers "[ ... ]" and leading "|"
+ glosses removed; trailing/leading whitespace trimmed.
+ * Values: visible text of each of the next three | cells, cleaned the same
+ way. A number followed by a "(YYYY)" source-year citation is stored as the
+ bare number. Cells that are a dash plus "N/a" (territories with no IMF
+ estimate) are stored as the explicit sentinel "N/a".
+
+Outputs:
+ wizard-work/ledger/gdp_imf_wb_un_extract.csv
+ wizard-work/ledger/gdp_imf_wb_un_extract.md
+"""
+import re
+import html
+import csv
+import sys
+
+SRC = '/root/.hermes/scripts/gdp_table.html'
+OUT_CSV = '/root/troupe-standup/wizard-work/ledger/gdp_imf_wb_un_extract.csv'
+OUT_MD = '/root/troupe-standup/wizard-work/ledger/gdp_imf_wb_un_extract.md'
+
+# Number like 128,003,776 optionally followed by (2024)/(2022) citation.
+NUMBER_RE = re.compile(r'^(\d{1,3}(?:,\d{3})+(?:\s*\(20\d{2}\))?$)')
+
+
+def clean_cell(raw):
+ """Visible text of a | : drop hidden spans, strip tags, unescape, collapse."""
+ raw = _drop_hidden(raw)
+ t = re.sub(r'<[^>]+>', ' ', raw)
+ t = html.unescape(t)
+ t = re.sub(r' ', ' ', t)
+ t = re.sub(r'\s+', ' ', t).strip()
+ return t
+
+
+def _drop_hidden(raw):
+ """Remove visually-hidden / hidden content that would leak into text.
+
+ Drops three kinds of noise:
+ * ... (screen-reader-only note, e.g. "N/a")
+ * (TemplateStyles CSS blob)
+ * elements styled display:none / visibility:hidden
+ """
+ raw = re.sub(r']*class="[^"]*\bsr-only\b[^>]*>.*?', '', raw, flags=re.S)
+ raw = re.sub(r'', '', raw, flags=re.S)
+ raw = re.sub(r'<[^>]*\b(?:style|class)=[^>]*\b(?:display\s*:\s*none|visibility\s*:\s*hidden)\b[^>]*>', '', raw, flags=re.S)
+ return raw
+
+
+def clean_country(raw):
+ """First-cell cleaning: remove footnote markers and leading gloss."""
+ t = clean_cell(raw)
+ t = re.sub(r'\[[^\]]*\]', '', t) # e.g. "[ n 3 ]", "[n 3]"
+ t = re.sub(r'\|', '', t) # leading gloss "| "
+ t = t.strip()
+ return t
+
+
+def clean_value(raw):
+ """Third-cell value cleaning: bare number, else explicit sentinel."""
+ t = clean_cell(raw)
+ if NUMBER_RE.match(t):
+ return re.sub(r'\s*\(20\d{2}\)\s*$', '', t).strip()
+ if t.lower() in ('n/a', '— n/a', '—'):
+ return 'N/a'
+ # Unknown cell: keep the cleaned text, but never let a number be truncated.
+ return t
+
+
+def extract(src=SRC):
+ data = open(src, encoding='utf-8', errors='replace').read()
+ m_cap = re.search(r']*>(.*?)', data, re.S)
+ caption = html.unescape(re.sub(r'<[^>]+>', ' ', m_cap.group(1))).strip() \
+ if m_cap else ''
+ if 'GDP forecast or estimate' not in caption:
+ raise SystemExit('unexpected caption: %r' % caption)
+ m_tbody = re.search(r' |