// Step 4 (§9): history and culture per place (NLD, GM0513 Gouda, CUW, USA, KOR). // - owid: long-run population, life expectancy and urbanisation (OWID grapher CSVs) // - religion: CBS 37944 (NL 1849–2018), 82904NED (NL 2010→), 71124NED (Gouda census 1930), // Pew Research Center 2010/2020 via OWID // - offices: heads of government / state, governor, mayor (Wikidata P39 statements) // - turnout: US and South Korean national elections (Wikidata P1867 / P1868) // - people: best-known people per place (Wikidata, ranked by sitelinks) // - holidays: public holidays (Nager.Date API) // - unesco: World Heritage sites (UNESCO WHC open data, whc001) // - curated: key events, cuisine/exports and transcribed series (scripts/stats/history-events.json) import { readFileSync } from 'node:fs'; import { getJson, getText, http, insertChunks, log, run, sql, writeSeries } from './common'; import type { ObsRow, SeriesRow } from './common'; import { parseCsv } from './owid'; export const HISTORY_PLACES = ['NLD', 'GM0513', 'CUW', 'USA', 'KOR'] as const; const year = (y: number) => `${String(y).padStart(4, '0')}-01-01`; const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)); // ── OWID long-run series ───────────────────────────────────────────────────────────────── // Curaçao population and life expectancy already exist as owid:cw:* (UN WPP 1950→), and // Maddison GDP per person as owid:maddison:*; those are reused, not duplicated. const OWID_SERIES = [ { slug: 'population', key: 'population', title: 'Population (long run)', unit: 'people', from: 1500, regions: ['NLD', 'USA', 'KOR', 'WLD'], source: 'HYDE, Gapminder and UN WPP, via Our World in Data' }, { slug: 'life-expectancy', key: 'life-expectancy', title: 'Life expectancy at birth (long run)', unit: 'years', from: 1500, regions: ['NLD', 'USA', 'KOR', 'WLD'], source: 'Riley (2005), Zijdeman et al. (2015), HMD and UN WPP, via Our World in Data' }, { slug: 'urbanization-last-500-years', key: 'urban-long', title: 'Share of people living in urban areas (long run)', unit: '%', from: 1500, regions: ['NLD', 'USA', 'KOR', 'WLD'], source: 'HYDE (2023), via Our World in Data' }, { slug: 'share-of-population-urban', key: 'urban', title: 'Share of people living in urban areas', unit: '%', from: 1950, regions: ['NLD', 'USA', 'KOR', 'CUW', 'WLD'], source: 'UN World Urbanization Prospects, via Our World in Data' } ]; const owidRegion = (code: string, entity: string) => code === 'OWID_WRL' || entity === 'World' ? 'WLD' : code; async function owidJob(note: (w: string) => void) { const series: SeriesRow[] = []; const obs: ObsRow[] = []; for (const s of OWID_SERIES) { try { const url = `https://ourworldindata.org/grapher/${s.slug}.csv?csvType=full&useColumnShortNames=true`; const [header, ...lines] = parseCsv(await getText(url)); const [e, c, y] = ['entity', 'code', 'year'].map((n) => header.indexOf(n)); const v = header.length - 1; let n = 0; for (const l of lines) { if (l.length <= v || l[v] === '') continue; const region = owidRegion(l[c], l[e]); const yr = Number(l[y]); if (!s.regions.includes(region) || yr < s.from) continue; const id = `owid:hist:${s.key}:${region}`; if (!series.some((x) => x.series_id === id)) series.push({ series_id: id, source_id: 'owid', source_code: `${s.slug}/${region}`, title: s.title, unit: s.unit, freq: 'A', region_code: region, theme: 'history', meta: { url: `https://ourworldindata.org/grapher/${s.slug}`, source: s.source } }); obs.push({ series_id: id, period: year(yr), value: Number(l[v]), status: null }); n++; } if (!n) throw new Error('no rows for the requested places'); } catch (error) { note(`owid ${s.slug}: ${errText(error)}`); } } return writeSeries(series, obs); } // ── Religion ───────────────────────────────────────────────────────────────────────────── type CbsRow = Record; const cbsRows = async (table: string, filter = '') => ( await getJson<{ value: CbsRow[] }>( `https://opendata.cbs.nl/ODataApi/odata/${table}/TypedDataSet?$format=json${filter ? `&$filter=${encodeURIComponent(filter)}` : ''}` ) ).value; const cbsYear = (p: unknown) => Number(String(p).slice(0, 4)); const RELIGION_37944: Record = { GeenKerkelijkeGezindte_1: ['none', 'No religious affiliation'], RoomsKatholiek_2: ['catholic', 'Roman Catholic'], ProtestantseKerkInNederland_3: ['pkn', 'Protestant Church in the Netherlands (PKN)'], NederlandsHervormd_4: ['hervormd', 'Dutch Reformed (Hervormd)'], Gereformeerd_5: ['gereformeerd', 'Reformed (Gereformeerd)'], OverigeKerkelijkeGezindten_6: ['other', 'Other religion'] }; const RELIGION_82904: Record = { A008467: ['none', 'No religious affiliation'], A008466: ['catholic', 'Roman Catholic'], A050076: ['protestant', 'Protestant (all)'], A008465: ['pkn', 'Protestant Church in the Netherlands (PKN)'], A008464: ['hervormd', 'Dutch Reformed (Hervormd)'], A008463: ['gereformeerd', 'Reformed (Gereformeerd)'], A047804: ['islam', 'Islam'], A008468: ['other', 'Other religion'] }; const RELIGION_1930: Record = { KGNH: 'Dutch Reformed (Hervormd)', KGRK: 'Roman Catholic', KGGK: 'Reformed (Gereformeerd)', KGCG: 'Christian Reformed', KGRE: 'Remonstrant', KGEL: 'Lutheran', KGDO: 'Mennonite', KGNI: 'Jewish (Ashkenazi)', KGPI: 'Jewish (Portuguese)', KGGN: 'No religious affiliation' }; const PEW: Record = { christians: 'Christians', muslims: 'Muslims', jews: 'Jews', hindus: 'Hindus', buddhists: 'Buddhists', other_religions: 'Other religions', religiously_unaffiliated: 'Religiously unaffiliated' }; async function religionJob(note: (w: string) => void) { const series: SeriesRow[] = []; const obs: ObsRow[] = []; const add = (s: Omit, period: string, value: number | null) => { if (value == null || !Number.isFinite(value)) return; if (!series.some((x) => x.series_id === s.series_id)) series.push({ ...s, freq: 'A', theme: 'culture' }); obs.push({ series_id: s.series_id, period, value, status: null }); }; try { for (const r of await cbsRows('37944')) for (const [col, [key, title]] of Object.entries(RELIGION_37944)) add( { series_id: `cbs:religion:37944:${key}:NL01`, source_id: 'cbs', source_code: `37944/${col}`, title: `${title}, share of adults (18+)`, unit: '%', region_code: 'NLD', meta: { url: 'https://opendata.cbs.nl/statline/#/CBS/nl/dataset/37944/table', note: 'Censuses until 1971, surveys after' } }, year(cbsYear(r.Perioden)), r[col] as number | null ); } catch (error) { note(`cbs 37944: ${errText(error)}`); } try { for (const r of await cbsRows('82904NED', "Persoonskenmerken eq 'T009002'")) { const hit = RELIGION_82904[String(r.KerkelijkeGezindte).trim()]; if (!hit) continue; add( { series_id: `cbs:religion:82904:${hit[0]}:NL01`, source_id: 'cbs', source_code: `82904NED/${String(r.KerkelijkeGezindte).trim()}`, title: `${hit[1]}, share of people 15+`, unit: '%', region_code: 'NLD', meta: { url: 'https://opendata.cbs.nl/statline/#/CBS/nl/dataset/82904NED/table' } }, year(cbsYear(r.Perioden)), r.PersonenKerkelijkeGezindte_1 as number | null ); } } catch (error) { note(`cbs 82904NED: ${errText(error)}`); } try { const rows = await cbsRows('71124NED', "Regio eq 'GM0513' and Geslacht eq 'GesT'"); const total = rows.find((r) => String(r.KerkelijkeGezindte).trim() === 'KGTO')?.Bewoners_1; if (typeof total !== 'number' || total <= 0) throw new Error('no Gouda total for 1930'); for (const r of rows) { const code = String(r.KerkelijkeGezindte).trim(); if (!RELIGION_1930[code] || typeof r.Bewoners_1 !== 'number') continue; add( { series_id: `cbs:religion:1930:${code}:GM0513`, source_id: 'cbs', source_code: `71124NED/${code}`, title: `${RELIGION_1930[code]}, share of residents (census 1930)`, unit: '%', region_code: 'GM0513', meta: { url: 'https://opendata.cbs.nl/statline/#/CBS/nl/dataset/71124NED/table', residents: r.Bewoners_1, total } }, '1930-01-01', Math.round((r.Bewoners_1 / total) * 1000) / 10 ); } } catch (error) { note(`cbs 71124NED: ${errText(error)}`); } try { const [header, ...lines] = parseCsv( await getText( 'https://ourworldindata.org/grapher/share-of-population-by-religious-affiliation.csv?csvType=full&useColumnShortNames=true' ) ); const [c, y] = [header.indexOf('code'), header.indexOf('year')]; for (const l of lines) { const region = owidRegion(l[c] ?? '', l[0] ?? ''); if (!['NLD', 'USA', 'KOR', 'CUW', 'WLD'].includes(region)) continue; for (const [key, label] of Object.entries(PEW)) { const i = header.indexOf(`share__religion_${key}`); if (i < 0 || l[i] === '' || l[i] == null) continue; add( { series_id: `pew:religion:${key}:${region}`, source_id: 'pew', source_code: `religious-composition/${key}/${region}`, title: `${label}, share of population`, unit: '%', region_code: region, meta: { url: 'https://www.pewresearch.org/religion/feature/religious-composition-by-country-2010-2020/', via: 'https://ourworldindata.org/grapher/share-of-population-by-religious-affiliation' } }, year(Number(l[y])), Number(l[i]) ); } } } catch (error) { note(`pew: ${errText(error)}`); } if (!obs.length) throw new Error('no religion rows'); return writeSeries(series, obs); } // ── Wikidata ───────────────────────────────────────────────────────────────────────────── type Binding = Record; async function sparql(query: string): Promise { const response = await http( `https://query.wikidata.org/sparql?query=${encodeURIComponent(query)}`, { headers: { accept: 'application/sparql-results+json' } } ); return ((await response.json()) as { results: { bindings: Binding[] } }).results.bindings; } const qid = (b: { value: string } | undefined) => b?.value.split('/').pop() ?? ''; const day = (b: { value: string } | undefined) => { const m = b?.value.match(/^(-?\d{4})-(\d{2})-(\d{2})/); return m ? `${m[1]}-${m[2] === '00' ? '01' : m[2]}-${m[3] === '00' ? '01' : m[3]}` : null; }; const OFFICES = [ { place: 'NLD', kind: 'head_of_government', qid: 'Q3058109' }, { place: 'NLD', kind: 'head_of_state', qid: 'Q2045066' }, { place: 'USA', kind: 'head_of_state', qid: 'Q11696' }, { place: 'KOR', kind: 'head_of_state', qid: 'Q6296418' }, { place: 'KOR', kind: 'head_of_government', qid: 'Q15407843' }, { place: 'CUW', kind: 'head_of_government', qid: 'Q2247822' }, { place: 'CUW', kind: 'governor', qid: 'Q2140877' }, { place: 'GM0513', kind: 'mayor', qid: 'Q98970128' } ]; async function officesJob(note: (w: string) => void) { const rows: Record[] = []; for (const o of OFFICES) { try { const b = await sparql(`SELECT ?p ?pLabel ?officeLabel ?start ?end (GROUP_CONCAT(DISTINCT ?partyName; separator=" / ") AS ?parties) WHERE { BIND(wd:${o.qid} AS ?office) ?p p:P39 ?st. ?st ps:P39 ?office; pq:P580 ?start; wikibase:rank ?rank. FILTER(?rank != wikibase:DeprecatedRank) OPTIONAL { ?st pq:P582 ?end } OPTIONAL { ?p wdt:P102 ?party. ?party rdfs:label ?partyName. FILTER(lang(?partyName) = "en") } SERVICE wikibase:label { bd:serviceParam wikibase:language "en,mul,nl,ko,pap". } } GROUP BY ?p ?pLabel ?officeLabel ?start ?end`); const seen = new Set(); for (const r of b) { const start = day(r.start); const key = `${qid(r.p)}|${start}`; if (!start || seen.has(key)) continue; seen.add(key); rows.push({ place: o.place, kind: o.kind, office_qid: o.qid, office_label: r.officeLabel?.value ?? o.qid, person_qid: qid(r.p), person_label: r.pLabel?.value ?? qid(r.p), party: r.parties?.value || null, start_date: start, end_date: day(r.end) }); } } catch (error) { note(`office ${o.qid}: ${errText(error)}`); } } if (!rows.length) throw new Error('no office holders'); await sql.begin(async (tx) => { await tx`delete from stats.hist_office`; await insertChunks(tx, 'hist_office', rows); }); return rows.length; } const ELECTIONS = [ { cls: 'Q47566', id: 'wikidata:turnout:uspres:USA', region: 'USA', title: 'US presidential election' }, { cls: 'Q5518574', id: 'wikidata:turnout:krpres:KOR', region: 'KOR', title: 'South Korean presidential election' }, { cls: 'Q5518656', id: 'wikidata:turnout:krleg:KOR', region: 'KOR', title: 'South Korean legislative election' } ]; async function turnoutJob(note: (w: string) => void) { const series: SeriesRow[] = []; const obs: ObsRow[] = []; for (const e of ELECTIONS) { try { const b = await sparql(`SELECT ?e ?d ?elig ?cast WHERE { ?e wdt:P31 wd:${e.cls}; wdt:P585 ?d; wdt:P1867 ?elig; wdt:P1868 ?cast. }`); const points = new Map(); for (const r of b) { const d = day(r.d); const elig = Number(r.elig?.value); const cast = Number(r.cast?.value); // Skip indirect elections (e.g. 1948 by the National Assembly) and bad data. if (!d || !(elig > 100_000) || !(cast > 0) || cast > elig) continue; points.set(d, Math.round((cast / elig) * 1000) / 10); } if (!points.size) continue; series.push({ series_id: e.id, source_id: 'wikidata', source_code: e.cls, title: `Turnout, ${e.title}`, unit: '%', freq: 'A', region_code: e.region, theme: 'elections', meta: { url: `https://www.wikidata.org/wiki/${e.cls}`, note: 'Ballots cast (P1868) / eligible voters (P1867) as recorded on Wikidata; elections without both are missing' } }); for (const [period, value] of points) obs.push({ series_id: e.id, period, value, status: null }); } catch (error) { note(`turnout ${e.cls}: ${errText(error)}`); } } return writeSeries(series, obs); } const PEOPLE = [ { place: 'NLD', where: 'VALUES ?c { wd:Q55 wd:Q29999 wd:Q170072 } ?p wdt:P27 ?c.', min: 60 }, { place: 'USA', where: '?p wdt:P27 wd:Q30.', min: 160 }, { place: 'KOR', where: 'VALUES ?c { wd:Q884 wd:Q28179 wd:Q28233 } ?p wdt:P27 ?c.', min: 50 }, { place: 'GM0513', where: '?p wdt:P19/wdt:P131* wd:Q84125.', min: 0 }, { place: 'CUW', where: '?p wdt:P19/wdt:P131* wd:Q25279.', min: 0 } ]; async function peopleJob(note: (w: string) => void) { const rows: Record[] = []; for (const p of PEOPLE) { try { const b = await sparql(`SELECT ?p ?n WHERE { ${p.where} ?p wdt:P31 wd:Q5; wikibase:sitelinks ?n. FILTER(?n >= ${p.min}) } ORDER BY DESC(?n) LIMIT 60`); const top = [...new Map(b.map((r) => [qid(r.p), Number(r.n?.value)])).entries()].slice(0, 25); if (!top.length) continue; const detail = await sparql(`SELECT ?p ?pLabel ?pDescription (MIN(YEAR(?b)) AS ?by) (MIN(YEAR(?d)) AS ?dy) (SAMPLE(?en) AS ?enwiki) (SAMPLE(?nl) AS ?nlwiki) WHERE { VALUES ?p { ${top.map(([q]) => `wd:${q}`).join(' ')} } OPTIONAL { ?p wdt:P569 ?b } OPTIONAL { ?p wdt:P570 ?d } OPTIONAL { ?en schema:about ?p; schema:isPartOf } OPTIONAL { ?nl schema:about ?p; schema:isPartOf } SERVICE wikibase:label { bd:serviceParam wikibase:language "en,mul,nl,ko". } } GROUP BY ?p ?pLabel ?pDescription`); const info = new Map(detail.map((r) => [qid(r.p), r])); top.forEach(([q, n], i) => { const r = info.get(q); rows.push({ place: p.place, person_qid: q, label: r?.pLabel?.value ?? q, description: r?.pDescription?.value ?? null, birth_year: r?.by ? Number(r.by.value) : null, death_year: r?.dy ? Number(r.dy.value) : null, sitelinks: n, url: r?.enwiki?.value ?? r?.nlwiki?.value ?? `https://www.wikidata.org/wiki/${q}`, rank: i + 1 }); }); } catch (error) { note(`people ${p.place}: ${errText(error)}`); } } if (!rows.length) throw new Error('no people'); await sql.begin(async (tx) => { await tx`delete from stats.hist_person`; await insertChunks(tx, 'hist_person', rows); }); return rows.length; } // ── Holidays and UNESCO ────────────────────────────────────────────────────────────────── const NAGER: Record = { NL: 'NLD', US: 'USA', KR: 'KOR', CW: 'CUW' }; async function holidaysJob(note: (w: string) => void) { const rows: Record[] = []; const y = new Date().getUTCFullYear(); for (const [cc, place] of Object.entries(NAGER)) for (const yr of [y, y + 1]) { try { const list = await getJson< { date: string; localName: string; name: string; global: boolean; types: string[] }[] >(`https://date.nager.at/api/v3/PublicHolidays/${yr}/${cc}`); const seen = new Set(); for (const h of list) { const key = `${h.date}|${h.name}`; if (seen.has(key)) continue; seen.add(key); rows.push({ place, date: h.date, name: h.name, local_name: h.localName, global: h.global, types: h.types }); } } catch (error) { note(`nager ${cc} ${yr}: ${errText(error)}`); } } if (!rows.length) throw new Error('no holidays'); await sql.begin(async (tx) => { await tx`delete from stats.hist_holiday`; await insertChunks(tx, 'hist_holiday', rows); }); return rows.length; } type WhcRecord = { id_no: string | number; name_en: string; iso_codes: string | string[] | null; category: string; date_inscribed: string | number | null; danger: string | boolean | null; short_description_en: string | null; geo_point_2d: { lat: number; lon: number } | null; main_image_url: string | null; }; async function unescoJob() { const records = await getJson( 'https://data.unesco.org/api/explore/v2.1/catalog/datasets/whc001/exports/json?select=id_no,name_en,iso_codes,category,date_inscribed,danger,short_description_en,geo_point_2d,main_image_url' ); const rows: Record[] = []; for (const r of records) { const codes = ( Array.isArray(r.iso_codes) ? r.iso_codes : (String(r.iso_codes ?? '').match(/[A-Za-z]{2}/g) ?? []) ).map((c) => c.toUpperCase()); const places = new Set(); for (const c of codes) { // Willemstad is listed under the Netherlands (Kingdom); split by longitude. if (c === 'NL') places.add((r.geo_point_2d?.lon ?? 0) < -30 ? 'CUW' : 'NLD'); else if (c === 'CW') places.add('CUW'); else if (c === 'US') places.add('USA'); else if (c === 'KR') places.add('KOR'); } if (!places.size) continue; rows.push({ id_no: Number(r.id_no), name: r.name_en, places: [...places], category: r.category, year_inscribed: r.date_inscribed ? Number(String(r.date_inscribed).slice(0, 4)) : null, danger: r.danger === true || r.danger === 'True', description: r.short_description_en ? r.short_description_en.replace(/<[^>]+>/g, '').trim() : null, lat: r.geo_point_2d?.lat ?? null, lon: r.geo_point_2d?.lon ?? null, image_url: r.main_image_url }); } if (rows.length < 10) throw new Error(`only ${rows.length} sites`); await sql.begin(async (tx) => { await tx`delete from stats.unesco_site`; await insertChunks(tx, 'unesco_site', rows); }); return rows.length; } // ── Curated events, facts and transcribed series ───────────────────────────────────────── type Curated = { events: { place: string; slug: string; year: number; year_end: number | null; date_label: string | null; title: string; summary: string; theme: string; source_url: string; }[]; facts: Record[]; series: { series_id: string; title: string; unit: string; region_code: string; source_url: string; note: string; points?: [number, number][]; shares?: Record; year?: number; }[]; }; async function curatedJob() { const data = JSON.parse( readFileSync(new URL('./history-events.json', import.meta.url), 'utf8') ) as Curated; for (const e of data.events) if (!e.source_url?.startsWith('https://')) throw new Error(`event ${e.place}/${e.slug} has no source`); await sql.begin(async (tx) => { await tx`delete from stats.hist_event`; await insertChunks(tx, 'hist_event', data.events); await tx`delete from stats.hist_fact`; await insertChunks(tx, 'hist_fact', data.facts); }); const series: SeriesRow[] = []; const obs: ObsRow[] = []; for (const s of data.series) { const source = s.series_id.startsWith('cbscw:') ? 'cbscw' : 'history'; const meta = { url: s.source_url, note: s.note }; if (s.points) { series.push({ series_id: s.series_id, source_id: source, source_code: s.series_id, title: s.title, unit: s.unit, freq: 'A', region_code: s.region_code, theme: 'history', meta }); for (const [y, v] of s.points) obs.push({ series_id: s.series_id, period: year(y), value: v, status: null }); } for (const [label, v] of Object.entries(s.shares ?? {})) { const id = `${s.series_id}:${label.toLowerCase()}`; series.push({ series_id: id, source_id: source, source_code: id, title: `${s.title}: ${label}`, unit: s.unit, freq: 'A', region_code: s.region_code, theme: 'culture', meta: { ...meta, label } }); obs.push({ series_id: id, period: year(s.year ?? 2023), value: v, status: null }); } } await writeSeries(series, obs); return data.events.length + data.facts.length + obs.length; } const JOBS = { curated: ['history', curatedJob], owid: ['owid', owidJob], religion: ['cbs', religionJob], offices: ['wikidata', officesJob], turnout: ['wikidata', turnoutJob], people: ['wikidata', peopleJob], holidays: ['nager', holidaysJob], unesco: ['unesco', unescoJob] } as const; export async function history(opts: { only?: string; job: string }) { const wanted = opts.only ? opts.only.split(',') : Object.keys(JOBS); let failed = 0; for (const name of wanted) { const entry = JOBS[name as keyof typeof JOBS]; if (!entry) throw new Error(`unknown history job ${name} (${Object.keys(JOBS).join(', ')})`); const [sourceId, fn] = entry; try { await run(sourceId, `history:${name}`, (note) => fn(note)); } catch { failed++; } } log(`history: ${wanted.length - failed}/${wanted.length} jobs ok`); if (failed) throw new Error(`${failed} history jobs failed`); }