doxa-maps/plugin-designs/gospel-resources/scripts/fetch-doxa-people-groups.mjs
Doxa Maps 6baf6dba65 Keep the reason, drop the speaker, across all three plugins
The 36 baselined references in gospel-internet (23), gospel-resources (9) and
cities-internet (4) each wrapped a real engineering reason around an attribution.
The reason is what a reader needs; the speaker is process vocabulary that does not
belong in shipped text.

Every one is rewritten in place, keeping the measurement and the date where they
were there: "NUMERALS ARE HELD fleet-wide (2026-09-26)", "(2026-09-27: one more
level of zoom-out was needed)", "Reported from use: it overlapped the bottom of
the page". Where a quote carried the requirement, the requirement is now stated
directly rather than quoted.

Comment-only: 36 insertions against 36 deletions, one line for one line, and no
built text changes. Route test passes — 0 files under library/ or internal/, so
this self-lands rather than going through studio.

Two things I nearly shipped by accident, both caught before committing:
`git add -A` swept in a 1,961-line vite.config timestamp artefact from my own
build, and editing the JSON meta through a parser reformatted "composes" from one
line to three. The artefact is deleted and the JSON is edited as a string, so the
diff is only what I meant to change.
2026-09-29 09:06:28 +00:00

163 lines
8.6 KiB
JavaScript

/**
* fetch-doxa-people-groups.mjs — the 2,107 people groups DOXA actually prays for.
*
* ═══ WHY THIS EXISTS ════════════════════════════════════════════════════════════════════
* This map was built on the full 12,379-pin people-groups dataset, and the reading
* of it was blunt and correct: it is MISLEADING for the job it is meant to do. Doxa does not
* work 12,379 groups. They work the ~2,100 on pray.doxa.life, and a map that shows every
* group on earth buries the ones they can actually reach — and paints countries by a gap
* that is not theirs.
*
* ═══ THE MEASUREMENT THAT SETTLES THE COLOUR ARGUMENT ═══════════════════════════════════
* Measured 2026-09-25 against the live API:
* 2,107 Doxa people groups across 175 countries
* India 855 · China 154 · Sudan 105 · Brazil 103 · Laos 95
* BUT 106 of those 175 countries hold only ONE OR TWO Doxa groups
* and the United States holds THREE — Coquille, Pequots, Umpqua
* The old choropleth paints a country by the PERCENTAGE of its groups with nothing found.
* Over a denominator of three, one group without a resource is 33% and the whole United
* States turns amber. That is the "mainly yellow map" complaint, and it is not a palette
* problem underneath — it is a statistic that means nothing at n=3 being drawn as if it
* meant something. A prettier ramp over the same arithmetic would be a prettier lie.
*
* ═══ WHAT THIS DOES AND DOES NOT KEEP ═══════════════════════════════════════════════════
* Nothing found for the other 10,000 groups is thrown away — every index, every verified
* link and every sweep result stays exactly where it is in data/tables/. This file adds the
* DOXA ROSTER as its own table so the map can be narrowed to it without losing the research
* behind it. The wider tables remain the record; the map stops being the place they are
* dumped.
*
* ═══ THE JOIN, AND WHAT IT COSTS ════════════════════════════════════════════════════════
* The API carries no coordinates and no ISO 639-3 code, so both come from the pin index by
* (name, country) — the same key scripts/fetch-pray-index.mjs already uses, and the one join
* on this map that is deliberately not by language, because you pray for a people in a
* place. Measured: 1,724 of 2,107 match a pin and EVERY ONE of those carries a language
* code. 383 do not match and are recorded with `mapped: false` rather than dropped — they
* are Doxa's groups whether or not this build can place them, and a roster that silently
* loses 18% of them would be its own kind of lie.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { writeSnapshot } from './_snapshot-guard.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const PINS = resolve(REPO, 'data/tables/people-group-pins.json')
const OUT = resolve(REPO, 'data/tables/doxa-people-groups.json')
const API = 'https://pray.doxa.life/api/people-groups/list'
const SITE = 'https://pray.doxa.life'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
const res = await fetch(API, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(90000) })
if (!res.ok) {
console.error(`DOXA-PG: ABORT — the roster API answered HTTP ${res.status}. Refusing to write a partial roster.`)
process.exit(1)
}
const { posts } = await res.json()
if (!Array.isArray(posts) || posts.length < 1000) {
console.error(`DOXA-PG: ABORT — the roster came back with ${posts?.length ?? 0} entries. That is not the roster.`)
process.exit(1)
}
const { codes, pins } = JSON.parse(readFileSync(PINS, 'utf8'))
const DETAIL = resolve(REPO, 'data/tables/doxa-detail.json')
let detail = {}
try { detail = JSON.parse(readFileSync(DETAIL, 'utf8')).bySlug || {} } catch {
console.error('DOXA-PG: ABORT — no doxa-detail.json. Run scripts/fetch-doxa-detail.mjs first;')
console.error(' without it this build would fall back to guessing a language from a name.')
process.exit(1)
}
/* Tier by CODE, from this build's own research. Many groups share a language, so the first
tier seen for a code is the tier for that language — which is what a tier means. */
const tierByCode = {}
for (const p of pins) if (p[4] && tierByCode[p[4]] === undefined) tierByCode[p[4]] = p[6]
/**
* ═══ THE NAME JOIN IS GONE, AND THAT IS THE POINT ═══════════════════════════════════════
*
* This file used to get coordinates and a language code by matching the group's NAME against
* the pin dataset — exact first, then the same words in another order. It worked for 1,822 of
* 2,107 and left 285 unplaceable AND unsearchable: no code means no key to look them up by in
* any pool, so the access table could only report them as "not searched".
*
* Doxa's own per-group endpoint carries both. scripts/fetch-doxa-detail.mjs reads it, and
* this build now takes the language and the coordinates from THERE. A name match was always
* a guess wearing a join's clothes — Akha in Laos sat next to Akha Jipea, Akha Jaweuy and
* Akha Piso in the pins, and any pick among them would have invented a language for a people.
* The publisher saying "this group speaks ahk" ends the guessing rather than improving it.
*
* The pin dataset is still read, for one thing only: the TIER, which is this build's own
* finding about what that language has. It is looked up by the code Doxa gave, never by name.
*/
const val = (f) => (f && typeof f === 'object' ? f.value : f) || ''
const lab = (f) => (f && typeof f === 'object' ? f.label : f) || ''
let mapped = 0, unmapped = 0, noCode = 0
const rows = posts.map((q) => {
const iso3 = val(q.country_code)
const d = detail[q.slug] || null
const code = d?.iso639_3 || ''
const placed = !!(d && d.lat != null && d.lng != null)
if (placed) mapped++; else unmapped++
if (!code) noCode++
const c = code ? codes[code] : null
return {
name: q.name,
slug: q.slug,
pray_url: `${SITE}/${q.slug}?source=doxalife`,
iso3,
country: lab(q.country_code),
region: lab(q.wagf_region),
block: lab(q.wagf_block),
rop1: lab(q.rop1),
religion: lab(q.religion),
population: Number(q.population) || 0,
people_praying: Number(q.people_praying) || 0,
people_committed: Number(q.people_committed) || 0,
adopted_by_churches: Number(q.adopted_by_churches) || 0,
/* From Doxa's own record, not from a name match. */
mapped: placed,
matched_by: placed ? 'doxa detail' : '',
lat: d?.lat ?? null,
lng: d?.lng ?? null,
iso639_3: code,
language: d?.language || (c ? c.l || '' : ''),
tier: code ? (tierByCode[code] ?? 0) : null,
/* IMB's own view, carried alongside rather than merged into ours. */
imb_total_resources: d?.imb?.total ?? null,
engagement: d?.engagement || '',
}
})
const byCountry = {}
for (const r of rows) byCountry[r.iso3] = (byCountry[r.iso3] || 0) + 1
const thin = Object.values(byCountry).filter((n) => n <= 2).length
writeSnapshot(
OUT,
`${JSON.stringify({
_note:
'The people groups DOXA prays for, from pray.doxa.life. THIS is what the gospel map ' +
'draws — not the full 12,379-group dataset, which stays in people-group-pins.json as ' +
'the research record. mapped:false means the roster carries them but this build has no ' +
'coordinates for them yet; they are kept, never dropped.',
source: API,
generated: new Date().toISOString().slice(0, 10),
counts: {
groups: rows.length,
countries: Object.keys(byCountry).length,
mapped,
unmapped,
withoutLanguageCode: noCode,
countriesWithTwoOrFewer: thin,
},
rows,
}, null, 1)}\n`,
(j) => (j.rows || []).length,
'Doxa people group(s)',
)
console.log(`DOXA-PG: ${rows.length} group(s) across ${Object.keys(byCountry).length} countries`)
console.log(` placed from Doxa's own coordinates: ${mapped} without coordinates: ${unmapped}`)
console.log(` without a language code: ${noCode}`)
console.log(` countries holding 2 or fewer Doxa groups: ${thin} — a percentage over that denominator is not a measurement`)
console.log(`wrote ${OUT}`)