Export: Formelschutz nur fuer Text (inkl. Tab/CR), CSV mit Rechten 0600, Runbook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
495c3a2c40
commit
546d3c84d0
2 files changed
+28
-3
No files matched your search
@@ -1,5 +1,10 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// CSV-Export einer Website (Tageswerte, Einzelereignisse, Klicks). Öffnet die Datenbank nur lesend.
|
// CSV-Export einer Website (Tageswerte, Einzelereignisse, Klicks). Öffnet die Datenbank nur lesend.
|
||||||
|
//
|
||||||
|
// Ablauf (Runbook): Im Container webklar-analytics ausführen (Code liegt dort unter /analytics, schreibbar ist nur /srv/webklar-analytics):
|
||||||
|
// docker exec webklar-analytics node /analytics/scripts/export-site.mjs <siteKey> --out /srv/webklar-analytics/exports/<siteKey>
|
||||||
|
// Die CSV-Dateien enthalten Besucher-Hashes (Rechte 0600) und müssen dem Kunden als Kopie innerhalb von zwei Wochen
|
||||||
|
// übergeben werden (AVV Nr. 9); danach den Export-Ordner wieder löschen.
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -11,7 +16,7 @@ const iso = (ts) => new Date(ts).toISOString();
|
|||||||
function cell(v) {
|
function cell(v) {
|
||||||
if (v === null || v === undefined) return '';
|
if (v === null || v === undefined) return '';
|
||||||
let s = String(v);
|
let s = String(v);
|
||||||
if (/^[=+\-@]/.test(s)) s = `'${s}`;
|
if (typeof v === 'string' && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
||||||
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
}
|
}
|
||||||
const csv = (head, rows) => [head, ...rows].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
|
const csv = (head, rows) => [head, ...rows].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
|
||||||
@@ -31,7 +36,7 @@ export function exportSite({ dbPath, siteKey, outDir }) {
|
|||||||
ev.map((r) => [iso(r.ts), r.type, r.path, r.vp, r.ref, r.country, r.device, r.visitor, r.sec, r.scroll, r.kind, r.host])),
|
ev.map((r) => [iso(r.ts), r.type, r.path, r.vp, r.ref, r.country, r.device, r.visitor, r.sec, r.scroll, r.kind, r.host])),
|
||||||
[`${siteKey}-klicks.csv`]: csv(['time', 'path', 'vp', 'x', 'y', 'sel'], cl.map((r) => [iso(r.ts), r.path, r.vp, r.x, r.y, r.sel])),
|
[`${siteKey}-klicks.csv`]: csv(['time', 'path', 'vp', 'x', 'y', 'sel'], cl.map((r) => [iso(r.ts), r.path, r.vp, r.x, r.y, r.sel])),
|
||||||
};
|
};
|
||||||
for (const [n, c] of Object.entries(files)) writeFileSync(join(outDir, n), c, 'utf8');
|
for (const [n, c] of Object.entries(files)) writeFileSync(join(outDir, n), c, { encoding: 'utf8', mode: 0o600 });
|
||||||
return Object.keys(files).map((n) => join(outDir, n));
|
return Object.keys(files).map((n) => join(outDir, n));
|
||||||
} finally { db.close(); }
|
} finally { db.close(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs';
|
import { mkdtempSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@@ -71,3 +71,23 @@ test('CLI: unbekannter oder ungültiger Schlüssel => Exit 1 ohne Dateien', () =
|
|||||||
assert.equal(ok.status, 0);
|
assert.equal(ok.status, 0);
|
||||||
assert.equal(readdirSync(join(dir, 'ok')).length, 3);
|
assert.equal(readdirSync(join(dir, 'ok')).length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Export: Formelschutz nur fuer Text (Tab, CR, Minus), Zahlen bleiben unveraendert, Dateien 0600', () => {
|
||||||
|
const { dir, dbPath } = setup();
|
||||||
|
const db = new DatabaseSync(dbPath);
|
||||||
|
const ts = Date.UTC(2026, 8, 1, 10);
|
||||||
|
const ins = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?,?,?,?,?,?,?)');
|
||||||
|
ins.run(KEY, ts, '/', 'd', -3, -4.5, '\tcmd');
|
||||||
|
ins.run(KEY, ts, '/', 'd', 1, 2, '\rcmd');
|
||||||
|
ins.run(KEY, ts, '/', 'd', 1, 2, '-cmd');
|
||||||
|
db.close();
|
||||||
|
const out = join(dir, 'out');
|
||||||
|
const files = exportSite({ dbPath, siteKey: KEY, outDir: out });
|
||||||
|
const c = readFileSync(join(out, `${KEY}-klicks.csv`), 'utf8');
|
||||||
|
assert.ok(c.includes(',-3,-4.5,'), 'negative Zahlen ohne Praefix');
|
||||||
|
assert.ok(!c.includes("'-3"));
|
||||||
|
assert.ok(c.includes(",\"'\tcmd\"") || c.includes(",'\tcmd"), 'Tab gesichert');
|
||||||
|
assert.ok(c.includes(`"'\rcmd"`), 'CR gesichert');
|
||||||
|
assert.ok(c.includes(",'-cmd"));
|
||||||
|
for (const f of files) assert.equal(statSync(f).mode & 0o777, 0o600);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user