// Explicit maintenance only: importing this module never uses the network or writes. import * as fs from 'node:fs/promises'; import { constants } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { safePath } from './files.mjs'; const ROOT = fileURLToPath(new URL('../', import.meta.url)); const CATALOGUE = path.join(ROOT, 'mods/hearth/botany.lua'); const OUTPUT = path.join(ROOT, 'mods/hearth/wiki_articles.lua'); export const LIMITS = Object.freeze({ species: 80, batch: 10, concurrency: 2, responseBytes: 2 * 1024 * 1024, catalogueBytes: 128 * 1024, characters: 8000, requestMs: 15000, totalMs: 90000, redirects: 2 }); export const USER_AGENT = 'LastHearthSpeciesBundle/1.0 (offline educational game references; explicit manual maintenance)'; const ORIGIN = 'https://en.wikipedia.org'; const LICENSE = 'CC BY-SA 4.0'; const LICENSE_URL = 'https://creativecommons.org/licenses/by-sa/4.0/'; const NOTE_LICENSE = 'CC0 1.0'; const NOTE_LICENSE_URL = 'https://creativecommons.org/publicdomain/zero/1.0/'; const HEADER = '-- Generated by scripts/wiki-species.mjs; Wikipedia contributors, CC BY-SA 4.0.\n' + '-- Plaintext introduction excerpts, not full articles. See each revision/history and docs/species-references.md.\n'; const FIELDS = Object.freeze(['latin', 'title', 'url', 'pageid', 'revision', 'revision_url', 'history_url', 'revision_timestamp', 'retrieved', 'text', 'kind', 'excerpt', 'truncated', 'attribution', 'license', 'license_url', 'changes']); function requireValue(ok, message) { if (!ok) throw new Error(message); } function latinName(value) { requireValue(typeof value === 'string' && value.length <= 100 && /^[A-Z][a-z]+ [a-z]+(?:-[a-z]+)*$/.test(value), 'Invalid Latin name (not a URL or query).'); return value; } function validateCatalogue(records) { requireValue(Array.isArray(records) && records.length > 0 && records.length <= LIMITS.species, 'Catalogue scope must be 1..80.'); const ids = new Set(), names = new Set(); for (const r of records) { requireValue(r && typeof r.id === 'string' && /^[a-z][a-z_]{0,39}$/.test(r.id) && !['constructor', 'prototype', '__proto__'].includes(r.id) && !ids.has(r.id), 'Invalid or duplicate species ID.'); latinName(r.latin); requireValue(!names.has(r.latin), 'Duplicate Latin name.'); ids.add(r.id); names.add(r.latin); } return records; } // Parse only literal leading add(id,common,latin,...) rows. Never evaluate Lua. export function parseCatalogue(source) { requireValue(typeof source === 'string' && Buffer.byteLength(source) <= LIMITS.catalogueBytes, 'Catalogue too large.'); const rows = source.split(/\r?\n/).filter(line => /^\s*add\(/.test(line)); const records = rows.map(line => { const m = /^\s*add\("([a-z_]+)","[^"\\\r\n]{1,100}","([^"\\\r\n]+)",/.exec(line); requireValue(m, 'Unsupported catalogue literal.'); return { id: m[1], latin: m[2] }; }); requireValue(records.length === LIMITS.species, 'Expected exactly 80 botany catalogue rows.'); return validateCatalogue(records); } export function apiURL(names) { requireValue(Array.isArray(names) && names.length > 0 && names.length <= LIMITS.batch, 'Invalid batch size.'); names.forEach(latinName); const u = new URL('/w/api.php', ORIGIN); u.search = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2', prop: 'extracts|info|revisions|pageprops', exintro: '1', explaintext: '1', inprop: 'url', rvprop: 'ids|timestamp', redirects: '1', titles: names.join('|'), maxlag: '5' }); return u; } export function allowedURL(value) { const u = new URL(value); requireValue(u.origin === ORIGIN && u.protocol === 'https:' && !u.username && !u.password && !u.hash && !u.port, 'Only credential-free HTTPS en.wikipedia.org URLs are allowed.'); return u; } function allowedRequest(value, expected) { const u = allowedURL(value); requireValue(u.pathname === '/w/api.php' && u.search === expected.search, 'Unexpected API redirect or query.'); return u; } function abortable(promise, signal) { if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { const abort = () => reject(signal.reason); signal.addEventListener('abort', abort, { once: true }); Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); }); } async function boundedJSON(response, signal) { requireValue(/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') || ''), 'Expected JSON response.'); const length = response.headers.get('content-length'); requireValue(length === null || (/^\d+$/.test(length) && Number(length) <= LIMITS.responseBytes), 'Response exceeds byte limit.'); requireValue(response.body && typeof response.body.getReader === 'function', 'Missing response stream.'); const reader = response.body.getReader(), chunks = []; let bytes = 0, done = false; try { while (true) { const part = await abortable(reader.read(), signal); if (part.done) { done = true; break; } bytes += part.value.byteLength; requireValue(bytes <= LIMITS.responseBytes, 'Response exceeds byte limit.'); chunks.push(part.value); } return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); } finally { if (!done) void reader.cancel().catch(() => {}); reader.releaseLock(); } } export async function fetchBatch(names, { fetchImpl = globalThis.fetch, signal: parent } = {}) { const expected = apiURL(names); const signal = AbortSignal.any([AbortSignal.timeout(LIMITS.requestMs), ...(parent ? [parent] : [])]); let u = expected; for (let hop = 0; hop <= LIMITS.redirects; hop++) { allowedRequest(u, expected); signal.throwIfAborted(); const pending = Promise.resolve(fetchImpl(u.href, { redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer', signal, headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } })).then(response => { if (signal.aborted) { void response.body?.cancel().catch(() => {}); signal.throwIfAborted(); } return response; }); const response = await abortable(pending, signal); if (response.url) allowedRequest(response.url, expected); if ([301, 302, 303, 307, 308].includes(response.status)) { void response.body?.cancel().catch(() => {}); requireValue(hop < LIMITS.redirects && response.headers.get('location'), 'Too many or invalid redirects.'); u = allowedRequest(new URL(response.headers.get('location'), u).href, expected); continue; } if (response.status !== 200) { void response.body?.cancel().catch(() => {}); throw new Error(`Wikipedia HTTP ${response.status}; no cache written.`); } return boundedJSON(response, signal); } } function positiveInteger(n, label) { requireValue(Number.isSafeInteger(n) && n > 0, `Invalid ${label}.`); return n; } function timestamp(s) { requireValue(typeof s === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(s) && Number.isFinite(Date.parse(s)) && new Date(s).toISOString() === s.replace(/Z$/, s.includes('.') ? 'Z' : '.000Z'), 'Invalid timestamp.'); return s; } export function excerpt(text) { requireValue(typeof text === 'string' && text.trim().length > 30 && !/[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]/u.test(text) && !/<\/?[A-Za-z][^>]*>|\[\[|\]\]/u.test(text), 'Missing or non-plaintext introduction.'); text = text.trim(); const points = Array.from(text); if (points.length <= LIMITS.characters) return { text, truncated: false }; const head = points.slice(0, LIMITS.characters).join(''); // Keep the copied prefix verbatim; prefer paragraph, sentence, then word boundary. const paragraph = head.lastIndexOf('\n'); const sentences = [...head.matchAll(/[.!?](?=\s)/g)]; let end = paragraph >= head.length / 2 ? paragraph : (sentences.at(-1)?.index ?? -1) + 1; if (end < head.length / 2) end = head.lastIndexOf(' '); requireValue(end > 0, 'No safe excerpt boundary.'); return { text: head.slice(0, end).trimEnd(), truncated: true }; } function articleTitle(s) { requireValue(typeof s === 'string' && s.length > 0 && s.length <= 200 && !/[\x00-\x1f\x7f<>\[\]{}|#]/.test(s) && !s.includes(':'), 'Invalid article title.'); return s; } function wikiTitle(s) { return encodeURIComponent(s.replaceAll(' ', '_')); } function verifiedArticleURL(value, title) { const u = allowedURL(value); requireValue(u.pathname.startsWith('/wiki/') && !u.search && decodeURIComponent(u.pathname.slice(6)).replaceAll('_', ' ') === title, 'Canonical title/URL mismatch.'); return u.href; } function missingEntry(r, title, url, retrieved) { return { id: r.id, latin: r.latin, title, url, history_url: `${ORIGIN}/w/index.php?title=${wikiTitle(title)}&action=history`, retrieved, text: `No English Wikipedia species article was available for ${r.latin} when this bundle was retrieved. This is an original availability note, not a Wikipedia excerpt. No broader taxon or similarly named species has been substituted. The game catalogue is not evidence for real-world ecological, food or medicinal effects.`, kind: 'original_notes', excerpt: false, truncated: false, attribution: 'The Last Hearth contributors (original availability note)', license: NOTE_LICENSE, license_url: NOTE_LICENSE_URL, changes: 'Original availability note dedicated to CC0; no Wikipedia article text copied.' }; } export function entriesFromResponse(records, data, retrieved) { validateCatalogue(records); timestamp(retrieved); requireValue(data && !data.error && !data.warnings && data.batchcomplete === true && Array.isArray(data.query?.pages) && data.query.pages.length <= records.length, 'Incomplete or invalid Wikipedia response.'); const q = data.query, aliases = new Map(), pages = new Map(); for (const rule of [...(q.normalized || []), ...(q.redirects || [])]) { articleTitle(rule.from); articleTitle(rule.to); requireValue(!aliases.has(rule.from) && !rule.tofragment, 'Ambiguous or fragment redirect.'); aliases.set(rule.from, rule.to); } requireValue(aliases.size <= records.length * 3, 'Too many title aliases.'); for (const page of q.pages) { articleTitle(page.title); requireValue(!pages.has(page.title), 'Duplicate article title.'); pages.set(page.title, page); } return records.map(r => { let title = r.latin; const seen = new Set(); while (aliases.has(title)) { requireValue(!seen.has(title) && seen.size < 5, 'Cyclic or excessive title redirects.'); seen.add(title); title = aliases.get(title); } const p = pages.get(title); requireValue(p && p.ns === 0 && !('invalid' in p) && !Object.hasOwn(p.pageprops || {}, 'disambiguation') && p.contentmodel === 'wikitext' && p.pagelanguage === 'en', `Unverified species article: ${r.latin}.`); const url = verifiedArticleURL(p.canonicalurl, title); if (p.missing === true) { requireValue(!p.pageid && !p.revisions && title === r.latin, 'Ambiguous missing article.'); return missingEntry(r, title, url, retrieved); } requireValue(!('missing' in p), 'Unexpected missing marker.'); const pageid = positiveInteger(p.pageid, 'page ID'); requireValue(Array.isArray(p.revisions) && p.revisions.length === 1, 'Expected one source revision.'); const rev = p.revisions[0], revision = positiveInteger(rev.revid, 'revision'); requireValue(p.lastrevid === revision, 'Revision mismatch; retry maintenance later.'); timestamp(rev.timestamp); requireValue(Date.parse(rev.timestamp) <= Date.parse(retrieved), 'Source revision is in the future.'); const copied = excerpt(p.extract); return { id: r.id, latin: r.latin, title, url, pageid, revision, revision_url: `${ORIGIN}/w/index.php?oldid=${revision}`, history_url: `${ORIGIN}/w/index.php?title=${wikiTitle(title)}&action=history`, revision_timestamp: rev.timestamp, retrieved, ...copied, kind: 'wikipedia_intro', excerpt: true, attribution: `Wikipedia contributors, "${title}"`, license: LICENSE, license_url: LICENSE_URL, changes: copied.truncated ? 'Plaintext introduction; shortened at a text boundary. No translation.' : 'Plaintext introduction only; article body omitted. No translation.' }; }); } export async function fetchEntries(records, { fetchImpl = globalThis.fetch, now = () => new Date() } = {}) { validateCatalogue(records); const batches = []; for (let i = 0; i < records.length; i += LIMITS.batch) batches.push(records.slice(i, i + LIMITS.batch)); const controller = new AbortController(); const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(LIMITS.totalMs)]); const results = new Array(batches.length); let cursor = 0; async function worker() { while (!signal.aborted) { const i = cursor++; if (i >= batches.length) return; const batch = batches[i]; const data = await fetchBatch(batch.map(r => r.latin), { fetchImpl, signal }); results[i] = entriesFromResponse(batch, data, now().toISOString()); } signal.throwIfAborted(); } try { await Promise.all(Array.from({ length: Math.min(LIMITS.concurrency, batches.length) }, worker)); } catch (error) { controller.abort(error); throw error; } return results.flat(); } // Every UTF-8 byte is a fixed-width Lua decimal escape (Lua 5.1+). Article text // cannot close a quote, inject a comment/long-string delimiter or execute code. export function luaString(value) { requireValue(typeof value === 'string', 'Expected string.'); return '"' + [...Buffer.from(value, 'utf8')].map(b => '\\' + String(b).padStart(3, '0')).join('') + '"'; } export function serializeBundle(entries) { validateCatalogue(entries); return HEADER + 'return {\n' + entries.map(e => `\t[${luaString(e.id)}] = {\n` + FIELDS.map(key => { const value = e[key]; const absent = e.kind === 'original_notes' && ['pageid', 'revision', 'revision_url', 'revision_timestamp'].includes(key) && value === undefined; requireValue(absent || (['pageid', 'revision'].includes(key) ? Number.isSafeInteger(value) && value > 0 : ['excerpt', 'truncated'].includes(key) ? typeof value === 'boolean' : typeof value === 'string'), `Invalid bundle field ${key}.`); return `\t\t${key} = ${value === undefined ? 'nil' : typeof value === 'string' ? luaString(value) : value},\n`; }).join('') + '\t},\n').join('') + '}\n'; } // This narrow parser reads ONLY our generated data grammar, not general Lua. export function parseBundle(source) { requireValue(typeof source === 'string' && Buffer.byteLength(source) <= 16 * 1024 * 1024 && source.startsWith(HEADER + 'return {\n') && source.endsWith('}\n'), 'Invalid generated bundle.'); const decode = s => new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from( [...s.matchAll(/\\(\d{3})/g)].map(m => { const n = Number(m[1]); requireValue(n <= 255, 'Invalid Lua byte.'); return n; }))); const lines = source.slice((HEADER + 'return {\n').length, -2).split('\n'); lines.pop(); const entries = []; let i = 0; while (i < lines.length) { const start = /^\t\["((?:\\\d{3})+)"\] = \{$/.exec(lines[i++]); requireValue(start, 'Invalid Lua record.'); const entry = { id: decode(start[1]) }; for (const key of FIELDS) { const m = new RegExp(`^\\t\\t${key} = ("(?:\\\\\\d{3})*"|true|false|nil|[1-9]\\d*),$`).exec(lines[i++]); requireValue(m, 'Invalid Lua field.'); if (m[1] !== 'nil') entry[key] = m[1].startsWith('"') ? decode(m[1]) : m[1] === 'true' ? true : m[1] === 'false' ? false : Number(m[1]); } requireValue(lines[i++] === '\t},', 'Invalid Lua record end.'); entries.push(entry); } validateCatalogue(entries); requireValue(serializeBundle(entries) === source, 'Noncanonical generated bundle.'); return entries; } async function readCatalogue() { await safePath(CATALOGUE); const stat = await fs.stat(CATALOGUE); requireValue(stat.isFile() && stat.size <= LIMITS.catalogueBytes, 'Invalid catalogue file.'); return parseCatalogue(await fs.readFile(CATALOGUE, 'utf8')); } export async function main(args = process.argv.slice(2)) { requireValue(args.length === 1 && ['--refresh', '--check', '--help'].includes(args[0]), 'Usage: node scripts/wiki-species.mjs --refresh | --check | --help (no URLs/output paths accepted)'); if (args[0] === '--help') { console.log('--refresh: query all 80 species; replace the fixed Lua cache with verified intros or explicit missing-page notes after full validation.\n--check: verify fixed offline cache/catalogue without network.'); return; } const records = await readCatalogue(); if (args[0] === '--check') { await safePath(OUTPUT); requireValue((await fs.stat(OUTPUT)).size <= 16 * 1024 * 1024, 'Bundle too large.'); const entries = parseBundle(await fs.readFile(OUTPUT, 'utf8')); requireValue(JSON.stringify(entries.map(({ id, latin }) => ({ id, latin }))) === JSON.stringify(records), 'Catalogue/cache mismatch.'); for (const e of entries) { if (e.kind === 'original_notes') { timestamp(e.retrieved); verifiedArticleURL(e.url, e.latin); requireValue(serializeBundle([e]) === serializeBundle([missingEntry(e, e.latin, e.url, e.retrieved)]), 'Invalid original availability note.'); continue; } entriesFromResponse([{ id: e.id, latin: e.latin }], { batchcomplete: true, query: { redirects: e.title === e.latin ? [] : [{ from: e.latin, to: e.title }], pages: [{ ns: 0, pageid: e.pageid, title: e.title, canonicalurl: e.url, contentmodel: 'wikitext', pagelanguage: 'en', lastrevid: e.revision, revisions: [{ revid: e.revision, timestamp: e.revision_timestamp }], extract: e.text }] } }, e.retrieved); requireValue(e.kind === 'wikipedia_intro' && e.excerpt === true && e.license === LICENSE && e.license_url === LICENSE_URL && e.revision_url === `${ORIGIN}/w/index.php?oldid=${e.revision}` && e.history_url === `${ORIGIN}/w/index.php?title=${wikiTitle(e.title)}&action=history` && e.attribution === `Wikipedia contributors, "${e.title}"` && Array.from(e.text).length <= LIMITS.characters, 'Invalid cached attribution/excerpt.'); } console.log(`Verified ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes; network requests: 0.`); return; } const entries = await fetchEntries(records); const source = serializeBundle(entries); requireValue(parseBundle(source).length === LIMITS.species, 'Incomplete generated bundle.'); // Fixed output only; trusted local parents required, consistent with files.mjs. // All network/validation work finishes BEFORE opening/truncating the old cache. await safePath(OUTPUT); const handle = await fs.open(OUTPUT, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW || 0), 0o644); try { await handle.writeFile(source, 'utf8'); } finally { await handle.close(); } console.log(`Cached ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes (${entries.filter(e => e.truncated).length} shortened); ${Math.ceil(records.length / LIMITS.batch)} API batches, concurrency <=2.`); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch(error => { console.error(error.message); process.exitCode = 1; }); }