stats §8: CBS Curaçao collector (topic-page scrape, sha256 manifest, CPI since 1970 from PDF, GDP by industry, government finance, population since 1900, LFS, tourism) into cw_cbs_* + stats.obs region CUW; cw_cbcs_bop for manual uploads; weekly timer
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-25 21:38:30 +00:00
parent 27a15f0428
commit ff1520ae8b
10 changed files with 34730 additions and 3 deletions

78
scripts/stats/xlsx.ts Normal file
View File

@@ -0,0 +1,78 @@
// Minimal xlsx reader for the stats collector (no dependency): unzips the workbook with the
// host's `unzip` and reads cell values from the sheet XML. Enough for hand-formatted statistical
// tables: returns each sheet as a grid of strings/numbers (merged cells are not expanded).
export type Cell = string | number | null;
export type Sheet = { name: string; rows: Cell[][] };
async function unzipEntry(path: string, entry: string): Promise<string | null> {
const proc = Bun.spawn(['unzip', '-p', path, entry], { stdout: 'pipe', stderr: 'ignore' });
const text = await new Response(proc.stdout).text();
return (await proc.exited) === 0 ? text : null;
}
const decode = (s: string) =>
s
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&amp;/g, '&');
/** Text of all <t> runs inside a fragment (shared strings and inline strings). */
const textOf = (xml: string) =>
decode([...xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)].map((m) => m[1]).join(''));
function colIndex(ref: string) {
let n = 0;
for (const ch of ref.replace(/\d+$/, '')) n = n * 26 + (ch.charCodeAt(0) - 64);
return n - 1;
}
export async function readXlsx(path: string): Promise<Sheet[]> {
const workbook = await unzipEntry(path, 'xl/workbook.xml');
const rels = await unzipEntry(path, 'xl/_rels/workbook.xml.rels');
if (!workbook || !rels) throw new Error('not an xlsx workbook');
const shared = await unzipEntry(path, 'xl/sharedStrings.xml');
const strings = shared
? [...shared.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => textOf(m[1]))
: [];
const target = new Map(
[...rels.matchAll(/<Relationship\b[^>]*>/g)].map((m) => [
m[0].match(/Id="([^"]+)"/)?.[1],
m[0].match(/Target="([^"]+)"/)?.[1]
])
);
const sheets: Sheet[] = [];
for (const m of workbook.matchAll(/<sheet\b[^>]*>/g)) {
const name = decode(m[0].match(/name="([^"]*)"/)?.[1] ?? '');
const rid = m[0].match(/r:id="([^"]+)"/)?.[1];
let file = target.get(rid) ?? '';
file = file.startsWith('/') ? file.slice(1) : `xl/${file}`;
const xml = await unzipEntry(path, file);
if (!xml) continue;
const rows: Cell[][] = [];
for (const r of xml.matchAll(/<row\b[^>]*?r="(\d+)"[^>]*?(?:\/>|>([\s\S]*?)<\/row>)/g)) {
const row: Cell[] = [];
for (const c of (r[2] ?? '').matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
const ref = c[1].match(/r="([A-Z]+\d+)"/)?.[1];
if (!ref) continue;
const type = c[1].match(/t="(\w+)"/)?.[1];
const body = c[2] ?? '';
const v = body.match(/<v>([\s\S]*?)<\/v>/)?.[1];
let value: Cell = null;
if (type === 's' && v != null) value = strings[Number(v)] ?? null;
else if (type === 'inlineStr') value = textOf(body);
else if (type === 'str' || type === 'e') value = v != null ? decode(v) : null;
else if (type === 'b') value = v === '1' ? 'TRUE' : 'FALSE';
else if (v != null && v !== '') value = Number(v);
if (typeof value === 'string') value = value.trim() || null;
row[colIndex(ref)] = value;
}
rows[Number(r[1]) - 1] = Array.from(row, (x) => x ?? null);
}
sheets.push({ name, rows: Array.from(rows, (x) => x ?? []) });
}
return sheets;
}