// admin-ka — Analytics first-party : visiteurs uniques par site KA. // Un beacon (1px) sur chaque site envoie GET /collect?site=..&vid=..&p=.. // On stocke les hits (SQLite), on compte les uniques (vid distincts) en live, // et on fige un rollup quotidien pour l'historique long terme. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { DatabaseSync } from 'node:sqlite'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.join(__dirname, '..', 'data'); const DB_PATH = path.join(DATA_DIR, 'analytics.sqlite'); let db = null; const RETENTION_DAYS = 45; // GIF transparent 1x1 renvoyé au beacon export const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); function dayKey(ts) { // jour local (Amérique/Montréal ~ heure du serveur) const d = new Date(ts); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } export function initAnalytics() { fs.mkdirSync(DATA_DIR, { recursive: true }); db = new DatabaseSync(DB_PATH); db.exec(` CREATE TABLE IF NOT EXISTS hits (site TEXT, vid TEXT, ts INTEGER, path TEXT); CREATE INDEX IF NOT EXISTS idx_hits_site_ts ON hits(site, ts); CREATE TABLE IF NOT EXISTS daily (site TEXT, day TEXT, uniques INTEGER, pv INTEGER, PRIMARY KEY(site, day)); `); rollup(); setInterval(rollup, 10 * 60 * 1000); // fige les stats quotidiennes setInterval(purge, 6 * 60 * 60 * 1000); // purge le brut ancien purge(); } // enregistre un hit (appelé par /collect) export function recordHit(site, vid, p) { if (!db || !site || !vid) return; site = String(site).slice(0, 40); vid = String(vid).slice(0, 64); p = String(p || '/').slice(0, 200); try { db.prepare('INSERT INTO hits (site,vid,ts,path) VALUES (?,?,?,?)').run(site, vid, Date.now(), p); } catch {} } function purge() { if (!db) return; const cutoff = Date.now() - RETENTION_DAYS * 864e5; try { db.prepare('DELETE FROM hits WHERE ts < ?').run(cutoff); } catch {} } // fige les uniques/pv par jour dans `daily` (sauvegarde long terme) function rollup() { if (!db) return; try { const rows = db.prepare(` SELECT site, ts, vid FROM hits WHERE ts >= ?`).all(Date.now() - 3 * 864e5); const agg = {}; // site|day -> {set, pv} for (const r of rows) { const k = r.site + '|' + dayKey(r.ts); if (!agg[k]) agg[k] = { set: new Set(), pv: 0 }; agg[k].set.add(r.vid); agg[k].pv++; } const up = db.prepare('INSERT OR REPLACE INTO daily (site,day,uniques,pv) VALUES (?,?,?,?)'); for (const k in agg) { const [site, day] = k.split('|'); up.run(site, day, agg[k].set.size, agg[k].pv); } } catch {} } function uniquesSince(site, sinceTs) { try { return db.prepare('SELECT COUNT(DISTINCT vid) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; } } function pvSince(site, sinceTs) { try { return db.prepare('SELECT COUNT(*) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; } } export function analyticsSummary(sites) { const now = Date.now(); const startToday = new Date(); startToday.setHours(0, 0, 0, 0); const t0 = startToday.getTime(); const out = []; let g = { online: 0, today: 0, d7: 0, pv_today: 0 }; for (const s of sites) { const site = s.app || s; const row = { site, label: s.label || site, domain: s.domain || '', online: uniquesSince(site, now - 5 * 60 * 1000), // uniques dans les 5 dernières min today: uniquesSince(site, t0), d7: uniquesSince(site, now - 7 * 864e5), d30: uniquesSince(site, now - 30 * 864e5), pv_today: pvSince(site, t0), pv_total: pvSince(site, now - RETENTION_DAYS * 864e5), }; out.push(row); g.online += row.online; g.today += row.today; g.d7 += row.d7; g.pv_today += row.pv_today; } out.sort((a, b) => b.online - a.online || b.today - a.today); return { sites: out, global: g, updated: now }; } // série quotidienne (uniques) des N derniers jours pour un site export function analyticsSeries(site, days = 30) { const out = []; for (let i = days - 1; i >= 0; i--) { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - i); const key = dayKey(d.getTime()); let u = 0; try { const r = db.prepare('SELECT uniques FROM daily WHERE site=? AND day=?').get(site, key); if (r) u = r.uniques; } catch {} // aujourd'hui : recalcule en direct (le rollup peut être en retard) if (i === 0) u = Math.max(u, uniquesSince(site, d.getTime())); out.push({ day: key, uniques: u }); } return out; }