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
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
78
scripts/stats/xlsx.ts
Normal file
78
scripts/stats/xlsx.ts
Normal 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
||||
.replace(/&/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;
|
||||
}
|
||||
Reference in New Issue
Block a user