1// admin-ka — Analytics first-party : visiteurs uniques par site KA.2// Un beacon (1px) sur chaque site envoie GET /collect?site=..&vid=..&p=..3// On stocke les hits (SQLite), on compte les uniques (vid distincts) en live,4// et on fige un rollup quotidien pour l'historique long terme.5import fs from 'node:fs';6import path from 'node:path';7import { fileURLToPath } from 'node:url';8import { DatabaseSync } from 'node:sqlite';910const __dirname = path.dirname(fileURLToPath(import.meta.url));11const DATA_DIR = path.join(__dirname, '..', 'data');12const DB_PATH = path.join(DATA_DIR, 'analytics.sqlite');1314let db = null;15const RETENTION_DAYS = 45;1617// GIF transparent 1x1 renvoyé au beacon18export const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');1920function dayKey(ts) {21 // jour local (Amérique/Montréal ~ heure du serveur)22 const d = new Date(ts);23 return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');24}2526export function initAnalytics() {27 fs.mkdirSync(DATA_DIR, { recursive: true });28 db = new DatabaseSync(DB_PATH);29 db.exec(`30 CREATE TABLE IF NOT EXISTS hits (site TEXT, vid TEXT, ts INTEGER, path TEXT);31 CREATE INDEX IF NOT EXISTS idx_hits_site_ts ON hits(site, ts);32 CREATE TABLE IF NOT EXISTS daily (site TEXT, day TEXT, uniques INTEGER, pv INTEGER, PRIMARY KEY(site, day));33 `);34 rollup();35 setInterval(rollup, 10 * 60 * 1000); // fige les stats quotidiennes36 setInterval(purge, 6 * 60 * 60 * 1000); // purge le brut ancien37 purge();38}3940// enregistre un hit (appelé par /collect)41export function recordHit(site, vid, p) {42 if (!db || !site || !vid) return;43 site = String(site).slice(0, 40);44 vid = String(vid).slice(0, 64);45 p = String(p || '/').slice(0, 200);46 try { db.prepare('INSERT INTO hits (site,vid,ts,path) VALUES (?,?,?,?)').run(site, vid, Date.now(), p); } catch {}47}4849function purge() {50 if (!db) return;51 const cutoff = Date.now() - RETENTION_DAYS * 864e5;52 try { db.prepare('DELETE FROM hits WHERE ts < ?').run(cutoff); } catch {}53}5455// fige les uniques/pv par jour dans `daily` (sauvegarde long terme)56function rollup() {57 if (!db) return;58 try {59 const rows = db.prepare(`60 SELECT site, ts, vid FROM hits WHERE ts >= ?`).all(Date.now() - 3 * 864e5);61 const agg = {}; // site|day -> {set, pv}62 for (const r of rows) {63 const k = r.site + '|' + dayKey(r.ts);64 if (!agg[k]) agg[k] = { set: new Set(), pv: 0 };65 agg[k].set.add(r.vid); agg[k].pv++;66 }67 const up = db.prepare('INSERT OR REPLACE INTO daily (site,day,uniques,pv) VALUES (?,?,?,?)');68 for (const k in agg) { const [site, day] = k.split('|'); up.run(site, day, agg[k].set.size, agg[k].pv); }69 } catch {}70}7172function uniquesSince(site, sinceTs) {73 try { return db.prepare('SELECT COUNT(DISTINCT vid) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; }74}75function pvSince(site, sinceTs) {76 try { return db.prepare('SELECT COUNT(*) c FROM hits WHERE site=? AND ts>=?').get(site, sinceTs).c; } catch { return 0; }77}7879export function analyticsSummary(sites) {80 const now = Date.now();81 const startToday = new Date(); startToday.setHours(0, 0, 0, 0);82 const t0 = startToday.getTime();83 const out = [];84 let g = { online: 0, today: 0, d7: 0, pv_today: 0 };85 for (const s of sites) {86 const site = s.app || s;87 const row = {88 site, label: s.label || site, domain: s.domain || '',89 online: uniquesSince(site, now - 5 * 60 * 1000), // uniques dans les 5 dernières min90 today: uniquesSince(site, t0),91 d7: uniquesSince(site, now - 7 * 864e5),92 d30: uniquesSince(site, now - 30 * 864e5),93 pv_today: pvSince(site, t0),94 pv_total: pvSince(site, now - RETENTION_DAYS * 864e5),95 };96 out.push(row);97 g.online += row.online; g.today += row.today; g.d7 += row.d7; g.pv_today += row.pv_today;98 }99 out.sort((a, b) => b.online - a.online || b.today - a.today);100 return { sites: out, global: g, updated: now };101}102103// série quotidienne (uniques) des N derniers jours pour un site104export function analyticsSeries(site, days = 30) {105 const out = [];106 for (let i = days - 1; i >= 0; i--) {107 const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - i);108 const key = dayKey(d.getTime());109 let u = 0;110 try { const r = db.prepare('SELECT uniques FROM daily WHERE site=? AND day=?').get(site, key); if (r) u = r.uniques; } catch {}111 // aujourd'hui : recalcule en direct (le rollup peut être en retard)112 if (i === 0) u = Math.max(u, uniquesSince(site, d.getTime()));113 out.push({ day: key, uniques: u });114 }115 return out;116}117