// Circle Suite — Network Intelligence (Corona, L8). // // Every member running ads is generating an answer to the same question: which // wording actually makes a stranger click. Until now each of them only saw // their own slice, which on display inventory is far too small to learn from — // a member with 5,000 impressions and 4 clicks knows nothing. // // Pooled across the whole team it becomes a real asset, and one that cannot be // bought or copied: it is the team's own accumulated result. It also gets // better every month without anyone building anything, which is exactly what // the top of a ladder should feel like. // // Two rules it holds to: // 1. NOTHING identifies a member. No ids, no names, no per-person numbers. // This is a picture of what works, not a leaderboard of who is working. // 2. It refuses to report anything that has not earned the right to be // reported. Display click rates are tiny, so a row with 300 impressions // behind it is noise. Under-evidenced rows are counted and disclosed, not // quietly dropped, so the reader knows what was left out. 'use strict'; const fs = require('fs'); const path = require('path'); const suiteTraffic = require('./suite-traffic'); let DATA_DIR = null; function init(opts) { DATA_DIR = opts.dataDir; } const MIN_LEVEL = 8; const MIN_SERVED = 2000; // per row, before we will report a rate const MIN_ROWS = 2; // before we will compare rows at all function readJson(f) { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')); } catch (e) { return {}; } } // Pull every campaign the team has run, from every month we still hold. function allCampaigns() { const led = readJson('traffic-grants.json'); const out = []; Object.keys(led).forEach(function (mk) { const byMember = led[mk] || {}; Object.keys(byMember).forEach(function (mid) { (byMember[mid] || []).forEach(function (c) { out.push({ adId: Number(c.adId), kind: c.kind || 'banner', size: c.size || '', creative: c.creative || '', subject: c.subject || '', target: c.target || '', month: mk }); }); }); }); return out; } // The angle is encoded in the destination (?v=pocket etc). Personal pages carry // no angle, so they group separately rather than being lumped into "general". function angleOf(target) { const m = String(target || '').match(/[?&]v=([a-z]+)/i); if (m) return m[1].toLowerCase(); if (String(target).indexOf('/p/') !== -1) return 'personal page'; return 'general'; } function blankRow(label) { return { label: label, served: 0, clicks: 0, ads: 0 }; } function finish(map) { const rows = Object.keys(map).map(function (k) { const r = map[k]; r.rate = r.served > 0 ? r.clicks / r.served : 0; r.enough = r.served >= MIN_SERVED; return r; }); const solid = rows.filter(function (r) { return r.enough; }).sort(function (a, b) { return b.rate - a.rate; }); const thin = rows.filter(function (r) { return !r.enough; }); return { rows: solid, thin: thin.length, thinServed: thin.reduce(function (s, r) { return s + r.served; }, 0), comparable: solid.length >= MIN_ROWS }; } async function report() { const camps = allCampaigns(); if (!camps.length) return { ready: false, reason: 'No campaigns have run yet — this fills in as the team advertises.' }; // NAS caps a stats call, so batch. const ids = camps.map(function (c) { return c.adId; }).filter(Boolean); const stats = {}; for (let i = 0; i < ids.length; i += 150) { try { const chunk = await suiteTraffic.stats(ids.slice(i, i + 150)); chunk.forEach(function (s) { stats[s.ad_id] = s; }); } catch (e) { /* partial data still beats none */ } } if (!Object.keys(stats).length) { return { ready: false, reason: 'The ad network did not return counters just now — try again shortly.' }; } const byFormat = {}, bySize = {}, byAngle = {}, byCreative = {}, byHeadline = {}; let totalServed = 0, totalClicks = 0, counted = 0; camps.forEach(function (c) { const s = stats[c.adId]; if (!s) return; const served = Math.max(0, Number(s.served) || 0); const clicks = Math.max(0, Number(s.hits) || 0); if (served <= 0) return; counted++; totalServed += served; totalClicks += clicks; const add = function (map, key, label) { if (!key) return; if (!map[key]) map[key] = blankRow(label || key); map[key].served += served; map[key].clicks += clicks; map[key].ads++; }; add(byFormat, c.kind === 'text' ? 'text' : 'banner', c.kind === 'text' ? 'Text ads' : 'Banner ads'); add(byAngle, angleOf(c.target)); if (c.kind === 'text') { if (c.subject) add(byHeadline, c.subject.toLowerCase(), c.subject); } else { if (c.size) add(bySize, c.size); if (c.creative) add(byCreative, c.creative); } }); return { ready: true, totals: { ads: counted, served: totalServed, clicks: totalClicks, rate: totalServed > 0 ? totalClicks / totalServed : 0, months: Object.keys(readJson('traffic-grants.json')).length }, format: finish(byFormat), angle: finish(byAngle), size: finish(bySize), creative: finish(byCreative), headline: finish(byHeadline), minServed: MIN_SERVED }; } // One plain-English read of the numbers. Deliberately written here rather than // generated: these are conclusions about money, and a model paraphrasing a // table is exactly where an invented number would slip in. function readout(rep) { if (!rep || !rep.ready) return []; const out = []; const pct = function (r) { return (r * 100).toFixed(3) + '%'; }; out.push('Across ' + rep.totals.ads.toLocaleString() + ' team ads: ' + rep.totals.served.toLocaleString() + ' impressions served, ' + rep.totals.clicks.toLocaleString() + ' clicks — ' + pct(rep.totals.rate) + ' overall.'); if (rep.format.comparable) { const top = rep.format.rows[0], next = rep.format.rows[1]; const edge = next.rate > 0 ? ((top.rate - next.rate) / next.rate) * 100 : 0; out.push(top.label + ' are pulling ' + pct(top.rate) + ' against ' + pct(next.rate) + ' for ' + next.label.toLowerCase() + (edge >= 20 ? ' — a ' + Math.round(edge) + '% edge worth acting on.' : ' — close enough to call it a tie.')); } if (rep.angle.comparable) { const a = rep.angle.rows[0]; out.push('The "' + a.label + '" angle is the strongest performer at ' + pct(a.rate) + ' across ' + a.served.toLocaleString() + ' impressions.'); } if (rep.creative.comparable) { const c = rep.creative.rows[0]; out.push('Best banner: ' + c.label + ' at ' + pct(c.rate) + '.'); } const thinTotal = rep.format.thin + rep.angle.thin + rep.size.thin + rep.creative.thin + rep.headline.thin; if (thinTotal) { out.push(thinTotal + ' grouping(s) are held back for now — under ' + rep.minServed.toLocaleString() + ' impressions each, which is not enough to read anything into.'); } return out; } module.exports = { init, report, readout, MIN_LEVEL, MIN_SERVED };