doxa-maps/plugin-designs/gospel-resources/scripts/fetch-ebible-index.mjs
doxa-maps-88655c70 0e0b142719 A half-finished catalogue fetch can no longer overwrite a good one
The FCBH fetcher read per-fileset video scope for 1,666 languages and that file was
committed. A later run, while live.bible.is was rate-limiting, produced 1,103 and
overwrote the good file with the degraded one. Nothing failed, nothing warned, and the
artefact silently lost a third of its content. It was caught only because git status
happened to be read, and the fix was a git checkout.

That is the third time this shape has appeared here:
  - a VERIFY_LIMIT smoke run replaced the full verification table with 30 rows;
  - dropping a dead link removed it from the corpus, so the next run forgot it was
    dead and the build put it straight back;
  - this one.
Every snapshot in this bundle is built from a network read, and a network read has a bad
day. The dangerous case is never the run that throws - that leaves the old file alone -
it is the run that half-succeeds and writes anyway.

scripts/_snapshot-guard.mjs now stands in front of every catalogue write. A snapshot may
grow freely and may shrink a little, but losing more than a tenth of the previous run is
treated as a failed fetch: the existing file is left untouched and the script exits 1.
It is deliberately not an equality check, because catalogues do genuinely lose entries
when a publisher retires something, and a gate nobody can satisfy gets switched off.
SNAPSHOT_FORCE=1 is the one loud way past it.

Proven by replaying the exact failure: feeding it the 1,103-entry version refuses the
write, names the real numbers (563 fewer, 13.8%, below the 90% floor) and leaves the
file byte-identical to the good one. SNAPSHOT_FORCE=1 writes it. An equal write passes.

All six fetchers are wired: ebible, arclight, arclight-share-urls, door43, zume, fcbh.
Re-running zume, door43 and ebible for real passes the guard at 52, 208 and 1,237 codes
and produces byte-identical data, so the guard perturbs nothing.

The one exception is the checkpoint write in the resumable share-urls fetcher, which
starts from the existing file as a cache and can only grow. It must now declare itself
with a CHECKPOINT-WRITE-OK marker, so the exemption is a statement rather than an
oversight.

New gate check:snapshot-guard fails if any fetch-*.mjs writes OUT directly without that
marker, or never calls writeSnapshot at all - a guard that is easy to bypass is a guard
that will be bypassed. It refuses to run against an empty file list rather than passing
vacuously, and --poison injects a raw write and requires the check to catch it. Chained
into build:unlocked and its doc copy kept in sync.
2026-09-24 11:59:48 +00:00

149 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* fetch-ebible-index.mjs — cache eBible.org's translation catalogue as an ISO 639-3 index.
*
* THE FINDING THIS EXISTS FOR. eBible was ALREADY one of the two pools this repo checked,
* but only 120 language codes carried an eBible link. The catalogue lists 1,550 translations
* across 1,239 codes with canonical books — the pool had been harvested at roughly a tenth
* of its content, and ~1,600 people groups were reading "nothing found" because of it.
*
* CANON TEST: a row is kept only if OTbooks + NTbooks > 0. A publication that is
* deuterocanonical ONLY is not the 66-book canon and is dropped. (Measured on this
* snapshot: 1,547 of 1,550 rows pass, 0 are DC-only — the 3 dropped carry no books at all.)
* DCbooks alongside OT/NT is NOT a reason to drop: many canon publications append them, and
* the 66 books are present either way. The type is what matters, not the appendix.
*
* DOCTRINE SCREEN ON THE PUBLICATION DOMAIN, not just on eBible's reputation — publicationURL
* does not always point at ebible.org. Four domains appear in this catalogue and each was
* checked by fetching it:
* ebible.org 1242 — the pool already in use, public-domain and openly-licensed Bibles
* png.bible 302 — "Papua New Guinea Scriptures", Holy Bible in PNG languages
* alkitab.pw 3 — serves eBible.org's own site; a mirror
* inscript.org 1 — EXCLUDED. "inScript Bible Browser" and nothing else; its page
* establishes no publisher or doctrinal provenance, and the standing
* rule is that an unclear source is left out. Cost: one row.
* Any domain not on the allow-list below is dropped, so a future catalogue update cannot
* silently introduce a publisher nobody screened.
*
* ONE TRANSLATION PER LANGUAGE: the one with the most canonical books, tie-broken by verses.
* A language with a full Bible should not be represented by a single-gospel portion.
*/
import { writeSnapshot } from './_snapshot-guard.mjs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const OUT = resolve(HERE, '../../..', 'data/tables/ebible-index.json')
const SRC = 'https://ebible.org/Scriptures/translations.csv'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
const ALLOWED = new Set(['ebible.org', 'png.bible', 'alkitab.pw'])
const res = await fetch(SRC, { headers: { 'User-Agent': UA } })
if (!res.ok) throw new Error(`ebible catalogue: HTTP ${res.status}`)
const text = await res.text()
// minimal RFC4180 parse — titles contain commas and quoted quotes
function parseCsv(s) {
const rows = []; let row = [], f = '', q = false
for (let i = 0; i < s.length; i++) {
const c = s[i]
if (q) {
if (c === '"' && s[i + 1] === '"') { f += '"'; i++ }
else if (c === '"') q = false
else f += c
} else if (c === '"') q = true
else if (c === ',') { row.push(f); f = '' }
else if (c === '\n') { row.push(f); f = ''; rows.push(row); row = [] }
else if (c !== '\r') f += c
}
if (f || row.length) { row.push(f); rows.push(row) }
const head = rows.shift().map((h) => h.replace(/^/, '').trim())
return rows.filter((r) => r.length > 1).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ''])))
}
// HTTPS, and the host lower-cased. The catalogue publishes plain http:// for all 1,237
// of these and spells the host `eBible.org` on 6 of them. Every host here serves https
// directly (verified 12/12 on a sample) and redirects http there anyway, so the plain
// link only buys a redirect hop and a downgraded scheme in a field worker's browser.
// ONLY the scheme and the HOST are normalised — the path is left exactly as published,
// because a path is case-sensitive and "fixing" it would break the link.
const httpsify = (u) => {
const m = /^https?:\/\/([^/]+)(\/.*)?$/i.exec(String(u || '').trim())
if (!m) return String(u || '').trim()
return `https://${m[1].toLowerCase()}${m[2] || ''}`
}
const num = (v) => (String(v).trim().match(/^\d+$/) ? Number(v) : 0)
const rows = parseCsv(text)
// badUrl is kept SEPARATE from badDomain: they are different defects and folding them
// together hid one. MEASURED in this snapshot: nko and nlx publish 'http:///nkoNT/' — a
// malformed URL with an EMPTY host, broken in eBible's own catalogue. A loose check for
// the 'http://' prefix passes it happily; requiring a non-empty host is what rejects it.
// Two broken links out of 1,550 is the difference between shipping real resources and
// shipping a 'real' link that resolves nowhere.
const stats = { total: rows.length, noCanon: 0, badUrl: 0, badDomain: 0, kept: 0 }
const best = {}
for (const r of rows) {
const code = (r.languageCode || '').trim()
const url = httpsify(r.publicationURL || '')
if (!code || code.length !== 3 || !url) continue
const books = num(r.OTbooks) + num(r.NTbooks)
if (books === 0) { stats.noCanon++; continue }
const host = (url.match(/^https?:\/\/([^/]+)/) || [])[1]?.toLowerCase()
if (!host) { stats.badUrl++; (stats.malformed ||= []).push(`${code} ${url}`); continue }
if (!ALLOWED.has(host)) {
stats.badDomain++
;(stats.excluded ||= []).push(`${code} ${host} ${(r.shortTitle || '').slice(0, 30)}`)
continue
}
const verses = num(r.OTverses) + num(r.NTverses)
const cur = best[code]
if (!cur || books > cur.books || (books === cur.books && verses > cur.verses)) {
best[code] = {
u: url,
t: (r.shortTitle || r.title || '').trim(),
// The LANGUAGE name, distinct from the translation title. Without it a language
// that no people group speaks has no name anywhere, and its table row reads as a
// bare ISO code — which tells a reader searching for that language nothing.
ln: (r.languageNameInEnglish || r.languageName || '').trim(),
books,
verses,
nt: num(r.NTbooks) > 0,
ot: num(r.OTbooks) > 0,
full: num(r.OTbooks) >= 39 && num(r.NTbooks) >= 27,
}
}
}
stats.kept = Object.keys(best).length
// ALSO KEYED BY URL. The language index answers "what is the best translation for this
// code"; it cannot answer "what is THIS link". 24 curated scripture_urls point at an
// older edition than the catalogue's current pick — tel2010 where the catalogue says
// tel2017 — and both resolve, so the curated choice is kept. Indexed by URL they can
// still be NAMED and SCOPED, instead of being the only rows on the map with no scope.
const byUrl = {}
for (const r of rows) {
const url = httpsify(r.publicationURL || '')
const host = (url.match(/^https?:\/\/([^/]+)/) || [])[1]
if (!url || !host || !ALLOWED.has(host)) continue
const ot = num(r.OTbooks), nt = num(r.NTbooks)
if (ot + nt === 0) continue
byUrl[url] = {
t: (r.shortTitle || r.title || '').trim(),
ln: (r.languageNameInEnglish || r.languageName || '').trim(),
nt: nt > 0, ot: ot > 0, full: ot >= 39 && nt >= 27,
}
}
const out = {
_note: 'eBible.org translation catalogue, keyed by ISO 639-3. One translation per language (most canonical books). Rows with no OT/NT books are dropped; publication domains are allow-listed after a doctrine screen. inscript.org excluded: no established provenance.',
generated: new Date().toISOString().slice(0, 10),
source: SRC,
allowed_domains: [...ALLOWED],
stats,
codes: best,
byUrl,
}
writeSnapshot(OUT, JSON.stringify(out), (j) => Object.keys(j.codes || {}).length, 'eBible ISO 639-3 codes')
console.log(JSON.stringify(stats))
console.log(`${stats.kept} ISO 639-3 codes -> ${OUT}`)