// Banc d'essai analytics v2 — valide les scénarios de classification et les // invariants des métriques. À lancer depuis une COPIE du repo (il écrit dans // data/analytics.sqlite) : `node server/test-analytics.mjs` 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 = path.join(__dirname, '..', 'data'); const DB = path.join(DATA, 'analytics.sqlite'); fs.mkdirSync(DATA, { recursive: true }); try { fs.unlinkSync(DB); } catch {} try { fs.unlinkSync(DB + '-wal'); } catch {} try { fs.unlinkSync(DB + '-shm'); } catch {} if (!fs.existsSync(path.join(DATA, 'config.json'))) { fs.writeFileSync(path.join(DATA, 'config.json'), JSON.stringify({ salt: '00', passwordHash: '00' })); } const { initAnalytics, recordHit, analyticsSummary, analyticsRealtime, analyticsInspect, setAnalyticsConfig } = await import('./analytics.js'); initAnalytics(); setAnalyticsConfig({ activeSec: 2 }); // fenêtre « en ligne » courte pour tester l'expiration const CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'; const GOOGLEBOT = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'; const UPTIME = 'Mozilla/5.0 (compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)'; const CURL = 'curl/8.6.0'; function hit(params, ua, ip) { const q = new URLSearchParams(params); recordHit(q, { headers: { 'user-agent': ua, 'x-forwarded-for': ip }, socket: { remoteAddress: ip } }); } const SITES = [{ app: 'lou-ka', label: 'Lou·Ka' }]; const sum = () => analyticsSummary(SITES).sites[0]; let pass = 0, fail = 0; function check(name, cond, detail) { if (cond) { pass++; console.log(' ✓ ' + name); } else { fail++; console.log(' ✗ ' + name + (detail !== undefined ? ' — obtenu : ' + JSON.stringify(detail) : '')); } } // IP AWS pré-classifiée hosting (simule le cache ip-api) const db = new DatabaseSync(DB); db.prepare('INSERT OR REPLACE INTO ipinfo (ip,asn,org,isp,cc,country,region,city,hosting,proxy,mobile,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)') .run('54.230.1.9', 16509, 'Amazon.com, Inc.', 'Amazon Technologies', 'US', 'United States', 'Virginia', 'Ashburn', 1, 0, 0, Date.now()); console.log('\nCas 1 — humain ouvre Lou-KA (beacon v2)'); hit({ site: 'lou-ka', vid: 'hum1', sid: 'sess1', ev: 'pv', p: '/', d: 'd' }, CHROME, '24.48.100.10'); let s = sum(); check('+1 visiteur humain', s.today === 1, s.today); check('+1 session', s.sessions === 1, s.sessions); check('+1 page vue', s.pv === 1, s.pv); check('+1 en ligne', s.online === 1, s.online); console.log('\nCas 2 — même humain rafraîchit la page'); hit({ site: 'lou-ka', vid: 'hum1', sid: 'sess1', ev: 'pv', p: '/', d: 'd' }, CHROME, '24.48.100.10'); s = sum(); check('même visiteur', s.today === 1, s.today); check('même session', s.sessions === 1, s.sessions); check('+1 page vue (2)', s.pv === 2, s.pv); console.log('\nCas 3 — même humain navigue (SPA) + heartbeat'); hit({ site: 'lou-ka', vid: 'hum1', sid: 'sess1', ev: 'pv', p: '/logement/abc', d: 'd' }, CHROME, '24.48.100.10'); hit({ site: 'lou-ka', vid: 'hum1', sid: 'sess1', ev: 'hb', p: '/logement/abc', d: 'd' }, CHROME, '24.48.100.10'); s = sum(); check('toujours 1 visiteur / 1 session', s.today === 1 && s.sessions === 1, [s.today, s.sessions]); check('+1 page vue (3) — heartbeat non compté', s.pv === 3, s.pv); console.log('\nCas 4 — Googlebot rend 300 URLs (JS exécuté, beacon v1, vid changeant)'); for (let i = 0; i < 300; i++) hit({ site: 'lou-ka', vid: 'gb' + i, p: '/page' + i }, GOOGLEBOT, '66.249.66.' + (i % 250)); s = sum(); check('0 visiteur humain ajouté', s.today === 1, s.today); check('300 hits crawler', s.crawler === 300, s.crawler); check('0 page vue humaine ajoutée', s.pv === 3, s.pv); console.log('\nCas 5 — uptime monitor ping (UptimeRobot)'); for (let i = 0; i < 20; i++) hit({ site: 'lou-ka', vid: 'up1', p: '/' }, UPTIME, '69.162.124.1'); s = sum(); check('0 humain ajouté, visible en crawler', s.today === 1 && s.crawler === 320, [s.today, s.crawler]); console.log('\nCas 6 — IP AWS (headless Chrome, UA navigateur) fait 100 requêtes'); for (let i = 0; i < 100; i++) hit({ site: 'lou-ka', vid: 'aws' + i, sid: 'as' + i, ev: 'pv', p: '/x' + i, d: 'd' }, CHROME, '54.230.1.9'); s = sum(); check('0 humain ajouté (datacenter)', s.today === 1, s.today); check('100 hits datacenter', s.datacenter === 100, s.datacenter); console.log('\nCas 6b — script curl → automation ; trafic interne → internal'); hit({ site: 'lou-ka', vid: 'cu1', p: '/' }, CURL, '203.0.113.5'); hit({ site: 'lou-ka', vid: 'in1', p: '/' }, CHROME, '192.168.2.40'); s = sum(); check('1 hit automation', s.automation === 1, s.automation); check('1 hit interne', s.internal === 1, s.internal); check('toujours 1 seul humain', s.today === 1, s.today); console.log('\nCas 7 — beacon v1 : dédup des pings 60 s côté serveur'); const now = Date.now(); hit({ site: 'lou-ka', vid: 'v1user', p: '/appartement' }, CHROME, '70.80.1.2'); // pv (nouvelle session) // simule un ping d'intervalle : on triche sur l'horloge de sessState en réinsérant à ~60 s // (ici on vérifie surtout : 2e ping immédiat même page = reload = pv, page différente = pv) hit({ site: 'lou-ka', vid: 'v1user', p: '/appartement' }, CHROME, '70.80.1.2'); // reload rapide → pv hit({ site: 'lou-ka', vid: 'v1user', p: '/contact' }, CHROME, '70.80.1.2'); // nouvelle page → pv s = sum(); check('v1 : 1 visiteur, 1 session', s.today === 2 && s.sessions === 2, [s.today, s.sessions]); check('v1 : 3 pv (pas de hb en rafale)', s.pv === 6, s.pv); console.log('\nInspecteur — raisons de classification'); const insp = analyticsInspect({ ip: '54.230.1.9' }); check('classe stockée datacenter', insp.storedClass === 'datacenter', insp.storedClass); check('raison mentionne le hosting/Amazon', (insp.reasons || []).join(' ').toLowerCase().includes('hosting') || (insp.reasons || []).join(' ').includes('Amazon'), insp.reasons); console.log('\nInvariants'); s = sum(); check('uniques <= sessions', s.today <= s.sessions, [s.today, s.sessions]); check('sessions <= pages vues', s.sessions <= s.pv, [s.sessions, s.pv]); check('en ligne <= uniques du jour', s.online <= s.today, [s.online, s.today]); console.log('\nTemps réel + expiration du « en ligne » (fenêtre 2 s)'); let rt = analyticsRealtime(SITES); check('humains actifs présents', rt.active.length >= 1, rt.active.length); check('bots absents du temps réel', rt.active.every(a => !a.vid.startsWith('gb') && !a.vid.startsWith('aws')), rt.active.map(a => a.vid)); await new Promise(r => setTimeout(r, 2600)); s = sum(); rt = analyticsRealtime(SITES); check('en ligne retombe à 0 (expiration auto)', s.online === 0 && rt.active.length === 0, [s.online, rt.active.length]); console.log(`\n${pass} réussis, ${fail} échoués`); process.exit(fail ? 1 : 0);