010e8d7ffc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
61 lines
3.0 KiB
JavaScript
61 lines
3.0 KiB
JavaScript
// 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 };
|