tacilytics 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/README.md +66 -2
- package/auth.js +188 -8
- package/importers.js +223 -0
- package/package.json +6 -1
- package/public/account.js +245 -10
- package/public/app.js +216 -25
- package/public/assets/icons.svg +1 -0
- package/public/i18n.js +242 -0
- package/public/index.html +120 -6
- package/public/style.css +101 -0
- package/public/tacilytics.js +41 -1
- package/public/theme.js +14 -0
- package/report.js +300 -0
- package/server.js +540 -68
- package/tz.js +96 -0
- package/webhook.js +109 -0
package/importers.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* Tacilytics — importers for historical data.
|
|
3
|
+
*
|
|
4
|
+
* Imported data is daily aggregates, never visitor rows: one row per (day, dimension, value).
|
|
5
|
+
* dim '' → totals of the day dim 'page' → path
|
|
6
|
+
* dim 'source' → referrer / source dim 'country' → ISO-2 code
|
|
7
|
+
* dim 'browser' | 'os' | 'device' dim 'event' → custom event name
|
|
8
|
+
*
|
|
9
|
+
* Supported inputs
|
|
10
|
+
* - Plausible: the export ZIP (Site settings → Imports & Exports → Export) or its CSV files.
|
|
11
|
+
* Format: https://plausible.io/docs/csv-import
|
|
12
|
+
* - Google Analytics 4: CSV downloads of reports / explorations that have a DATE column,
|
|
13
|
+
* optionally one more dimension (page path, session source, country, browser, device
|
|
14
|
+
* category, operating system, event name). English or German column names.
|
|
15
|
+
*/
|
|
16
|
+
const zlib = require('zlib');
|
|
17
|
+
|
|
18
|
+
// ── CSV (RFC 4180: quotes, doubled quotes, newlines inside quotes) ──
|
|
19
|
+
function parseCSV(text) {
|
|
20
|
+
const s = String(text).replace(/^\uFEFF/, '');
|
|
21
|
+
// separator = whichever of , ; TAB appears most in the first line (outside quotes)
|
|
22
|
+
const first = s.slice(0, s.search(/\r?\n/) >>> 0 || s.length).replace(/"[^"]*"/g, '');
|
|
23
|
+
const sep = [',', ';', '\t'].map((c) => [c, first.split(c).length]).sort((a, b) => b[1] - a[1])[0][0];
|
|
24
|
+
const rows = []; let row = [], field = '', q = false;
|
|
25
|
+
for (let i = 0; i < s.length; i++) {
|
|
26
|
+
const c = s[i];
|
|
27
|
+
if (q) {
|
|
28
|
+
if (c === '"') { if (s[i + 1] === '"') { field += '"'; i++; } else q = false; }
|
|
29
|
+
else field += c;
|
|
30
|
+
} else if (c === '"') q = true;
|
|
31
|
+
else if (c === sep) { row.push(field); field = ''; }
|
|
32
|
+
else if (c === '\n' || c === '\r') {
|
|
33
|
+
if (c === '\r' && s[i + 1] === '\n') i++;
|
|
34
|
+
row.push(field); field = '';
|
|
35
|
+
if (row.length > 1 || row[0] !== '') rows.push(row);
|
|
36
|
+
row = [];
|
|
37
|
+
} else field += c;
|
|
38
|
+
}
|
|
39
|
+
if (field !== '' || row.length) { row.push(field); if (row.length > 1 || row[0] !== '') rows.push(row); }
|
|
40
|
+
return rows;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── ZIP (stored + deflate, via the central directory; enough for analytics exports) ──
|
|
44
|
+
// async: inflating a big export must not block the event loop (tracking keeps running meanwhile)
|
|
45
|
+
const inflateRaw = require('util').promisify(zlib.inflateRaw);
|
|
46
|
+
async function unzip(buf, { maxFiles = 50, maxBytes = 200 * 1024 * 1024 } = {}) {
|
|
47
|
+
const out = [];
|
|
48
|
+
let eocd = -1;
|
|
49
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
50
|
+
if (eocd < 0) throw new Error('Not a ZIP file');
|
|
51
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
52
|
+
let p = buf.readUInt32LE(eocd + 16);
|
|
53
|
+
if (count > maxFiles) throw new Error(`Too many files in the ZIP (${count})`);
|
|
54
|
+
let total = 0;
|
|
55
|
+
for (let n = 0; n < count; n++) {
|
|
56
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('Broken ZIP directory');
|
|
57
|
+
const method = buf.readUInt16LE(p + 10), csize = buf.readUInt32LE(p + 20), usize = buf.readUInt32LE(p + 24);
|
|
58
|
+
const nlen = buf.readUInt16LE(p + 28), elen = buf.readUInt16LE(p + 30), clen = buf.readUInt16LE(p + 32), off = buf.readUInt32LE(p + 42);
|
|
59
|
+
const name = buf.slice(p + 46, p + 46 + nlen).toString('utf8');
|
|
60
|
+
p += 46 + nlen + elen + clen;
|
|
61
|
+
if (name.endsWith('/')) continue;
|
|
62
|
+
total += usize;
|
|
63
|
+
if (total > maxBytes) throw new Error('ZIP content is too large');
|
|
64
|
+
const lnlen = buf.readUInt16LE(off + 26), lelen = buf.readUInt16LE(off + 28);
|
|
65
|
+
const data = buf.slice(off + 30 + lnlen + lelen, off + 30 + lnlen + lelen + csize);
|
|
66
|
+
let content;
|
|
67
|
+
if (method === 0) content = data;
|
|
68
|
+
else if (method === 8) content = await inflateRaw(data, { maxOutputLength: Math.max(usize, 1) + 1024 });
|
|
69
|
+
else throw new Error('Unsupported ZIP compression in ' + name);
|
|
70
|
+
out.push({ name: name.split('/').pop(), text: content.toString('utf8') });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function int(v) {
|
|
76
|
+
let t = String(v == null ? '' : v).trim().replace(/\s/g, '');
|
|
77
|
+
if (/^\d{1,3}([.,]\d{3})+$/.test(t)) t = t.replace(/[.,]/g, ''); // 1.234 / 1,234 = thousand separators
|
|
78
|
+
else t = t.replace(',', '.');
|
|
79
|
+
const n = Math.round(Number(t));
|
|
80
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
81
|
+
}
|
|
82
|
+
function normDay(v) {
|
|
83
|
+
const s = String(v || '').trim();
|
|
84
|
+
let m;
|
|
85
|
+
if ((m = s.match(/^(\d{4})-(\d{2})-(\d{2})/))) return `${m[1]}-${m[2]}-${m[3]}`;
|
|
86
|
+
if ((m = s.match(/^(\d{4})(\d{2})(\d{2})$/))) return `${m[1]}-${m[2]}-${m[3]}`; // GA4: 20261015
|
|
87
|
+
if ((m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/))) return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`; // 15.10.2026
|
|
88
|
+
if ((m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/))) return `${m[3]}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`; // 10/15/2026
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const validDay = (d) => d && !isNaN(Date.parse(d + 'T00:00:00Z')) && new Date(d + 'T00:00:00Z').toISOString().slice(0, 10) === d;
|
|
92
|
+
const DEVICE = { mobile: 'Mobile', tablet: 'Tablet', desktop: 'Desktop', laptop: 'Desktop', smarttv: 'Desktop', 'smart tv': 'Desktop' };
|
|
93
|
+
const normDevice = (d) => DEVICE[String(d || '').toLowerCase()] || (d ? String(d) : null);
|
|
94
|
+
function normSource(s) {
|
|
95
|
+
const v = String(s || '').trim();
|
|
96
|
+
if (!v || /^\(?(direct|none|not set)\)?$/i.test(v) || v === '(direct)') return 'Direct';
|
|
97
|
+
return v.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase().slice(0, 120) || 'Direct';
|
|
98
|
+
}
|
|
99
|
+
// country names GA4 writes in place of codes (most common ones; unknown names are kept)
|
|
100
|
+
const COUNTRY = { germany: 'DE', deutschland: 'DE', austria: 'AT', österreich: 'AT', switzerland: 'CH', schweiz: 'CH', 'united states': 'US', 'vereinigte staaten': 'US', 'united kingdom': 'GB', 'vereinigtes königreich': 'GB', france: 'FR', frankreich: 'FR', netherlands: 'NL', niederlande: 'NL', italy: 'IT', italien: 'IT', spain: 'ES', spanien: 'ES', poland: 'PL', polen: 'PL', belgium: 'BE', belgien: 'BE', luxembourg: 'LU', luxemburg: 'LU', denmark: 'DK', dänemark: 'DK', sweden: 'SE', schweden: 'SE', norway: 'NO', norwegen: 'NO', 'czech republic': 'CZ', czechia: 'CZ', tschechien: 'CZ', canada: 'CA', kanada: 'CA', australia: 'AU', australien: 'AU' };
|
|
101
|
+
const normCountry = (c) => { const v = String(c || '').trim(); if (/^[A-Za-z]{2}$/.test(v)) return v.toUpperCase(); return COUNTRY[v.toLowerCase()] || v.slice(0, 60) || null; };
|
|
102
|
+
|
|
103
|
+
// ── Plausible ──
|
|
104
|
+
const PLAUSIBLE = {
|
|
105
|
+
imported_visitors: (r) => ({ dim: '', name: '', visitors: int(r.visitors), pageviews: int(r.pageviews), visits: int(r.visits), bounces: int(r.bounces), duration: int(r.visit_duration) }),
|
|
106
|
+
imported_pages: (r) => ({ dim: 'page', name: r.path || r.page, visitors: int(r.visitors), pageviews: int(r.pageviews) }),
|
|
107
|
+
imported_sources: (r) => ({ dim: 'source', name: normSource(r.source || r.referrer), visitors: int(r.visitors), pageviews: int(r.pageviews) }),
|
|
108
|
+
imported_locations: (r) => ({ dim: 'country', name: normCountry(r.country), visitors: int(r.visitors), pageviews: int(r.pageviews) }),
|
|
109
|
+
imported_browsers: (r) => ({ dim: 'browser', name: r.browser, visitors: int(r.visitors), pageviews: int(r.pageviews) }),
|
|
110
|
+
imported_operating_systems: (r) => ({ dim: 'os', name: r.operating_system, visitors: int(r.visitors), pageviews: int(r.pageviews) }),
|
|
111
|
+
imported_devices: (r) => ({ dim: 'device', name: normDevice(r.device), visitors: int(r.visitors), pageviews: int(r.pageviews) }),
|
|
112
|
+
imported_custom_events: (r) => ({ dim: 'event', name: r.name, visitors: int(r.visitors), pageviews: int(r.events) }),
|
|
113
|
+
};
|
|
114
|
+
function fromPlausible(files) {
|
|
115
|
+
const rows = []; const used = [];
|
|
116
|
+
for (const f of files) {
|
|
117
|
+
const kind = Object.keys(PLAUSIBLE).find((k) => f.name.startsWith(k + '_') || f.name === k + '.csv');
|
|
118
|
+
if (!kind) continue;
|
|
119
|
+
const table = parseCSV(f.text);
|
|
120
|
+
if (table.length < 2) continue;
|
|
121
|
+
const head = table[0].map((h) => h.trim().toLowerCase());
|
|
122
|
+
for (const line of table.slice(1)) {
|
|
123
|
+
const r = Object.fromEntries(head.map((h, i) => [h, line[i]]));
|
|
124
|
+
const day = normDay(r.date);
|
|
125
|
+
if (!validDay(day)) continue;
|
|
126
|
+
// Plausible stores custom-event props in sub-rows; we aggregate per event name
|
|
127
|
+
const x = PLAUSIBLE[kind](r);
|
|
128
|
+
if (x.dim !== '' && !x.name) continue;
|
|
129
|
+
rows.push({ day, ...x, name: String(x.name || '').slice(0, 300) });
|
|
130
|
+
}
|
|
131
|
+
used.push(kind);
|
|
132
|
+
}
|
|
133
|
+
if (!used.length) throw new Error('No Plausible CSV files found (names like imported_visitors_20240101_20241231.csv)');
|
|
134
|
+
return { rows, files: used };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Google Analytics 4 (CSV downloads) ──
|
|
138
|
+
const GA_COL = {
|
|
139
|
+
date: ['date', 'datum', 'day', 'tag'],
|
|
140
|
+
users: ['total users', 'users', 'active users', 'nutzer insgesamt', 'nutzer', 'aktive nutzer', 'totalusers', 'activeusers'],
|
|
141
|
+
views: ['views', 'screen page views', 'aufrufe', 'seitenaufrufe', 'screenpageviews', 'pageviews', 'event count', 'ereignisanzahl', 'anzahl der ereignisse', 'eventcount'],
|
|
142
|
+
sessions: ['sessions', 'sitzungen'],
|
|
143
|
+
bounce: ['bounce rate', 'absprungrate'],
|
|
144
|
+
};
|
|
145
|
+
const GA_DIM = [
|
|
146
|
+
['page', ['page path and screen class', 'page path', 'seitenpfad und bildschirmklasse', 'seitenpfad', 'pagepath', 'landing page', 'landingpage', 'zielseite', 'page location', 'seitenpfad + abfragestring', 'page path + query string']],
|
|
147
|
+
['source', ['session source', 'source', 'sitzungsquelle', 'quelle', 'session source / medium', 'sitzungsquelle/-medium', 'first user source', 'quelle der ersten nutzer']],
|
|
148
|
+
['country', ['country', 'land', 'country id', 'länder-id']],
|
|
149
|
+
['browser', ['browser']],
|
|
150
|
+
['os', ['operating system', 'betriebssystem', 'operatingsystem']],
|
|
151
|
+
['device', ['device category', 'gerätekategorie', 'devicecategory']],
|
|
152
|
+
['event', ['event name', 'ereignisname', 'eventname']],
|
|
153
|
+
];
|
|
154
|
+
function fromGA(files) {
|
|
155
|
+
const rows = []; const used = [];
|
|
156
|
+
for (const f of files) {
|
|
157
|
+
// GA4 puts "# ----" comment blocks above (and between) tables
|
|
158
|
+
const lines = String(f.text).replace(/^\uFEFF/, '').split(/\r?\n/).filter((l) => !/^\s*#/.test(l));
|
|
159
|
+
const table = parseCSV(lines.join('\n'));
|
|
160
|
+
if (table.length < 2) continue;
|
|
161
|
+
const head = table[0].map((h) => h.trim().toLowerCase());
|
|
162
|
+
const col = (names) => head.findIndex((h) => names.includes(h));
|
|
163
|
+
const di = col(GA_COL.date);
|
|
164
|
+
if (di < 0) continue;
|
|
165
|
+
const ui = col(GA_COL.users), vi = col(GA_COL.views), si = col(GA_COL.sessions);
|
|
166
|
+
if (ui < 0 && vi < 0) continue;
|
|
167
|
+
let dim = '', xi = -1;
|
|
168
|
+
for (const [d, names] of GA_DIM) { const i = col(names); if (i >= 0) { dim = d; xi = i; break; } }
|
|
169
|
+
for (const line of table.slice(1)) {
|
|
170
|
+
const day = normDay(line[di]);
|
|
171
|
+
if (!validDay(day)) continue; // GA4 adds a "Grand total" line without a date
|
|
172
|
+
let name = xi >= 0 ? String(line[xi] || '').trim() : '';
|
|
173
|
+
if (dim === 'source') name = normSource(name.split('/')[0]);
|
|
174
|
+
if (dim === 'country') name = normCountry(name);
|
|
175
|
+
if (dim === 'device') name = normDevice(name);
|
|
176
|
+
if (dim === 'page') { try { if (/^https?:/.test(name)) name = new URL(name).pathname; } catch {} }
|
|
177
|
+
if (dim && (!name || /^\(not set\)$/i.test(name))) continue;
|
|
178
|
+
const visitors = ui >= 0 ? int(line[ui]) : 0;
|
|
179
|
+
const pageviews = vi >= 0 ? int(line[vi]) : 0;
|
|
180
|
+
rows.push({ day, dim, name: name.slice(0, 300), visitors, pageviews, visits: si >= 0 ? int(line[si]) : 0 });
|
|
181
|
+
}
|
|
182
|
+
used.push(f.name + (dim ? ` (${dim})` : ' (totals)'));
|
|
183
|
+
}
|
|
184
|
+
if (!used.length) throw new Error('No usable Google Analytics CSV found — the report needs a Date column (add "Date" as a dimension) and Users or Views');
|
|
185
|
+
return { rows, files: used };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** merge duplicate (day, dim, name) rows by summing — GA "source / medium" splits one source over many rows */
|
|
189
|
+
function aggregate(rows) {
|
|
190
|
+
const m = new Map();
|
|
191
|
+
for (const r of rows) {
|
|
192
|
+
const k = r.day + '\u0001' + r.dim + '\u0001' + r.name;
|
|
193
|
+
const e = m.get(k);
|
|
194
|
+
if (!e) m.set(k, { ...r, visits: r.visits || 0, bounces: r.bounces || 0, duration: r.duration || 0 });
|
|
195
|
+
else { e.visitors += r.visitors; e.pageviews += r.pageviews; e.visits += r.visits || 0; e.bounces += r.bounces || 0; e.duration += r.duration || 0; }
|
|
196
|
+
}
|
|
197
|
+
return [...m.values()];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** GA4 often has no "totals" file: derive daily totals from the best single dimension if missing */
|
|
201
|
+
function ensureTotals(rows) {
|
|
202
|
+
if (rows.some((r) => r.dim === '')) return rows;
|
|
203
|
+
const pick = ['device', 'country', 'browser', 'os', 'source', 'page'].find((d) => rows.some((r) => r.dim === d));
|
|
204
|
+
if (!pick) return rows;
|
|
205
|
+
const tot = new Map();
|
|
206
|
+
for (const r of rows) if (r.dim === pick) {
|
|
207
|
+
const e = tot.get(r.day) || { day: r.day, dim: '', name: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, duration: 0 };
|
|
208
|
+
e.visitors += r.visitors; e.pageviews += r.pageviews; tot.set(r.day, e);
|
|
209
|
+
}
|
|
210
|
+
return rows.concat([...tot.values()]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function parseImport(source, input) {
|
|
214
|
+
// input: { zip: Buffer } or { files: [{ name, text }] }
|
|
215
|
+
let files = input.files || [];
|
|
216
|
+
if (input.zip) files = await unzip(input.zip);
|
|
217
|
+
if (!files.length) throw new Error('No files');
|
|
218
|
+
const r = source === 'plausible' ? fromPlausible(files) : source === 'ga' ? fromGA(files) : null;
|
|
219
|
+
if (!r) throw new Error('source must be plausible or ga');
|
|
220
|
+
return { rows: ensureTotals(aggregate(r.rows)), files: r.files };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { parseImport, _test: { parseCSV, unzip, int, normDay, normSource, normCountry, aggregate, ensureTotals } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tacilytics",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Privacy-first, cookieless analytics for websites and apps. Self-hosted: one Node process, one SQLite file.",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"@simplewebauthn/server": "^14.0.3",
|
|
16
16
|
"better-sqlite3": "^12.10.0",
|
|
17
17
|
"express": "^4.21.2",
|
|
18
|
+
"nodemailer": "^10.1.1",
|
|
18
19
|
"qrcode": "^1.5.4"
|
|
19
20
|
},
|
|
20
21
|
"bin": {
|
|
@@ -49,7 +50,11 @@
|
|
|
49
50
|
"cli.js",
|
|
50
51
|
"day-salt.js",
|
|
51
52
|
"auth.js",
|
|
53
|
+
"importers.js",
|
|
54
|
+
"report.js",
|
|
55
|
+
"webhook.js",
|
|
52
56
|
"tz-country.js",
|
|
57
|
+
"tz.js",
|
|
53
58
|
"public/",
|
|
54
59
|
"sdk/flutter/",
|
|
55
60
|
"!sdk/flutter/.dart_tool/",
|