// Page-hit log for the admin Traffic tab (Marty, 2026-09-12): which referring domains send // visitors to the public pages, per day and per landing page. Counters are buffered in memory // and flushed every 30 s: MySQL table page_hits (day, host, path, n) when the DB is on, else // DATA_DIR/traffic.json. Obvious crawlers are skipped. Our own pages as referrer = 'direct'. const fs = require('fs'); const path = require('path'); const db = require('./db'); let DATA_DIR = null, buf = {}, jsonStore = null, timer = null; const BOT = /bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|curl\/|wget|python-requests|headless/i; function init(opts) { DATA_DIR = opts.dataDir; if (!timer) { timer = setInterval(() => flush().catch(() => {}), 30 * 1000); timer.unref(); } } function host(referer) { try { const h = new URL(String(referer || '')).hostname.replace(/^www\./, '').toLowerCase(); return (!h || /linkspin\.com$/.test(h)) ? 'direct' : h.slice(0, 80); } catch (e) { return 'direct'; } } function family(p) { if (p === '/' || p === '') return 'home'; if (p.startsWith('/join/')) return 'join'; if (p.startsWith('/from/')) return p.slice(1, 40); // from/faucetwave, from/tieroneads if (p.startsWith('/wall/')) return 'wall'; return p.replace(/^\//, '').slice(0, 40) || 'home'; // ledger, contract, shorts, plays ... } const day = ts => new Date(ts).toISOString().slice(0, 10); function hit(p, referer, ua) { if (BOT.test(String(ua || ''))) return; const k = day(Date.now()) + '|' + host(referer) + '|' + family(p); buf[k] = (buf[k] || 0) + 1; } function loadJson() { if (jsonStore) return jsonStore; try { jsonStore = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'traffic.json'), 'utf8')); } catch (e) { jsonStore = {}; } return jsonStore; } async function flush() { const pending = buf; buf = {}; const keys = Object.keys(pending); if (!keys.length) return; if (db.enabled()) { for (const k of keys) { const [d, h, f] = k.split('|'); await db.q('INSERT INTO page_hits (day,host,path,n) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE n=n+VALUES(n)', [d, h, f, pending[k]]); } return; } const J = loadJson(); for (const k of keys) J[k] = (J[k] || 0) + pending[k]; // keep the JSON store bounded: drop days older than 400 const cutoff = day(Date.now() - 400 * 86400000); for (const k of Object.keys(J)) if (k.slice(0, 10) < cutoff) delete J[k]; fs.writeFileSync(path.join(DATA_DIR, 'traffic.json'), JSON.stringify(J)); } // rows since a day (inclusive): [{day, host, path, n}] async function rows(sinceDay) { await flush(); if (db.enabled()) { const r = await db.q('SELECT day, host, path, n FROM page_hits WHERE day>=?', [sinceDay]); return r.map(x => ({ day: x.day, host: x.host, path: x.path, n: Number(x.n) })); } const J = loadJson(); return Object.keys(J).filter(k => k.slice(0, 10) >= sinceDay).map(k => { const [d, h, p] = k.split('|'); return { day: d, host: h, path: p, n: J[k] }; }); } module.exports = { init, hit, flush, rows, host, family };