Admin Traffic tab: referring domains, landing pages, angles, by day
traffic.js logs public page views by referring domain (30 s buffered; page_hits table or traffic.json), coach exposes all join-page views, and /api/admin/traffic merges page views, join-page views, signups, registrations and $20+ buyers by first-touch source for 7/30/90/365-day ranges. Also: line-tree chips no longer truncate own-position labels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
// 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 || /instantadpay\.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 };
|
||||
Reference in New Issue
Block a user