d5c9e33e0e
Text placements out-perform banners on this network — 357 text ads average ~171 clicks each against ~150 for banners, on far less inventory — so members can now run them too. The format is measured, not guessed. Across 60 live text ads: Subject caps at 20 characters and Body is ALWAYS exactly three <br>-joined lines of at most 24 characters. Every ad in the sample obeyed it. Emoji: the NAS columns are latin1, but so is the connection, so UTF-8 bytes pass through and render — which is how the existing emoji ads got there. The bridge's clean() would have deleted every emoji via iconv //TRANSLIT//IGNORE, so text copy now uses clean_ad_text(), which preserves them and strips angle brackets instead. The bridge builds the <br> separators itself from a lines[] array, so a member can never inject markup. The engine writes plain text to a tight budget and we apply emoji ourselves: models cannot count characters, least of all with emoji, so decoration is arithmetic we control. It also lets the emoji rotate independently of the copy. The palette carries no money emoji — the network is full of them, but implying earnings outranks matching the neighbours. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
222 lines
9.8 KiB
JavaScript
222 lines
9.8 KiB
JavaScript
// Circle Suite — Traffic Desk. Places member banner ads on the team's own
|
|
// NetworkAdSpace network through the signed bridge API (rmc-api on that host).
|
|
//
|
|
// Two deliberate constraints:
|
|
// 1. Creative comes from the TEAM banner kit only. Members never upload art.
|
|
// That removes moderation risk entirely — every ad on the network carries
|
|
// approved branding — and it means a member can launch in two clicks.
|
|
// 2. Allowances scale with contract level and are counted per calendar month
|
|
// against a local ledger, so a member cannot spend more than their level.
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
let DATA_DIR = null;
|
|
function init(opts) { DATA_DIR = opts.dataDir; }
|
|
function credsFile() { return path.join(DATA_DIR, 'nas-api.json'); }
|
|
function creds() { try { return JSON.parse(fs.readFileSync(credsFile(), 'utf8')); } catch (e) { return null; } }
|
|
function configured() { const c = creds(); return !!(c && c.url && c.secret); }
|
|
|
|
// Monthly impressions by contract level (index = level-1). Marty-approved.
|
|
const ALLOWANCE = [2500, 5000, 10000, 20000, 50000, 75000, 100000, 150000];
|
|
function allowanceFor(level) {
|
|
const n = Math.max(1, Math.min(8, Number(level) || 1));
|
|
return ALLOWANCE[n - 1];
|
|
}
|
|
|
|
// Team banner kit — every ad uses approved creative, so nothing needs review.
|
|
const CREATIVES = {
|
|
'468x60': [ 'rmc-468x60-v1.png', 'rmc-468x60-v2.png', 'rmc-468x60-v3.png', 'rmc-468x60-v4.png',
|
|
'rmc-468x60-v5.png', 'rmc-468x60-v6.png', 'rmc-468x60-v7.png', 'rmc-growing-468x60-bluegreen.png' ],
|
|
'728x90': [ 'rmc-728x90-v1.png', 'rmc-728x90-v2.png', 'rmc-728x90-v3.png' ],
|
|
'300x250': [ 'rmc-300x250-v1.png', 'rmc-300x250-v3.png', 'rmc-300x250-v4.png' ],
|
|
'160x600': [ 'rmc-160x600-v1.png', 'rmc-160x600-v2.png', 'rmc-160x600-v3.png' ],
|
|
'120x600': [ 'rmc-120x600-v1.png', 'rmc-120x600-v2.png' ],
|
|
'125x125': [ 'rmc-banner-125x125.png' ]
|
|
};
|
|
function sizes() { return Object.keys(CREATIVES); }
|
|
|
|
// ── local ledger: what each position has been granted this month ────────────
|
|
function ledgerFile() { return path.join(DATA_DIR, 'traffic-grants.json'); }
|
|
function readLedger() { try { return JSON.parse(fs.readFileSync(ledgerFile(), 'utf8')); } catch (e) { return {}; } }
|
|
function writeLedger(v) { try { fs.writeFileSync(ledgerFile(), JSON.stringify(v)); } catch (e) {} }
|
|
function monthKey() { const d = new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); }
|
|
|
|
function usage(memberId) {
|
|
const all = readLedger();
|
|
const rows = ((all[monthKey()] || {})[String(memberId)]) || [];
|
|
const used = rows.reduce(function (s, r) { return s + (Number(r.impressions) || 0); }, 0);
|
|
return { used: used, campaigns: rows };
|
|
}
|
|
|
|
function status(memberId, level) {
|
|
const limit = allowanceFor(level);
|
|
const u = usage(memberId);
|
|
return {
|
|
limit: limit, used: u.used, remaining: Math.max(0, limit - u.used),
|
|
campaigns: u.campaigns, resets: monthKey(), sizes: sizes(), creatives: CREATIVES
|
|
};
|
|
}
|
|
|
|
function record(memberId, entry) {
|
|
const all = readLedger();
|
|
const mk = monthKey();
|
|
if (!all[mk]) all[mk] = {};
|
|
const id = String(memberId);
|
|
if (!all[mk][id]) all[mk][id] = [];
|
|
all[mk][id].push(entry);
|
|
// keep three months of history; the file stays small forever
|
|
const keep = Object.keys(all).sort().slice(-3);
|
|
const trimmed = {};
|
|
keep.forEach(function (k) { trimmed[k] = all[k]; });
|
|
writeLedger(trimmed);
|
|
}
|
|
|
|
// ── signed call to the NAS bridge ───────────────────────────────────────────
|
|
function callNas(payload) {
|
|
return new Promise(function (resolve, reject) {
|
|
const c = creds();
|
|
if (!c) return reject(new Error('The ad network bridge is not configured yet.'));
|
|
const body = JSON.stringify(payload);
|
|
const ts = String(Math.floor(Date.now() / 1000));
|
|
const sig = crypto.createHmac('sha256', c.secret).update(ts + '.' + body).digest('hex');
|
|
const u = new URL(c.url);
|
|
const lib = u.protocol === 'https:' ? require('https') : require('http');
|
|
const req = lib.request({
|
|
hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
path: u.pathname + (u.search || ''), method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(body),
|
|
'X-RMC-Signature': sig,
|
|
'X-RMC-Timestamp': ts,
|
|
'User-Agent': 'RMCircleSuite/1.0'
|
|
}
|
|
}, function (res) {
|
|
let data = '';
|
|
res.on('data', function (d) { data += d; });
|
|
res.on('end', function () {
|
|
let j = null;
|
|
try { j = JSON.parse(data); } catch (e) { return reject(new Error('The ad network returned something unexpected.')); }
|
|
if (!j.ok) return reject(new Error(j.error || 'The ad network rejected that.'));
|
|
resolve(j);
|
|
});
|
|
});
|
|
req.on('error', function (e) { reject(new Error('Could not reach the ad network: ' + e.message)); });
|
|
req.setTimeout(45000, function () { req.destroy(new Error('The ad network took too long.')); });
|
|
req.write(body); req.end();
|
|
});
|
|
}
|
|
|
|
async function launch(opts) {
|
|
const level = Number(opts.level) || 1;
|
|
const isText = opts.kind === 'text';
|
|
let size = '', file = '', subject = '', lines = [];
|
|
|
|
if (isText) {
|
|
// Limits measured from live network inventory — see suite-textads.js.
|
|
subject = String(opts.subject || '').trim();
|
|
lines = (Array.isArray(opts.lines) ? opts.lines : []).slice(0, 3)
|
|
.map(function (l) { return String(l || '').replace(/[<>]/g, '').trim(); });
|
|
while (lines.length < 3) lines.push('');
|
|
if (!subject || !lines[0]) throw new Error('That text ad is missing its headline or first line.');
|
|
if (Array.from(subject).length > 20) throw new Error('The headline is longer than the network allows.');
|
|
if (lines.some(function (l) { return Array.from(l).length > 24; })) {
|
|
throw new Error('One of those lines is longer than the network allows.');
|
|
}
|
|
} else {
|
|
size = String(opts.size || '');
|
|
if (!CREATIVES[size]) throw new Error('Pick one of the available banner sizes.');
|
|
file = String(opts.creative || '');
|
|
if (CREATIVES[size].indexOf(file) === -1) throw new Error('Pick one of the team banner designs.');
|
|
}
|
|
|
|
const impressions = Math.max(100, Math.min(allowanceFor(level), Number(opts.impressions) || 0));
|
|
const st = status(opts.id, level);
|
|
if (impressions > st.remaining) {
|
|
throw new Error('That is more than your remaining ' + st.remaining.toLocaleString() + ' impressions this month.');
|
|
}
|
|
|
|
// Server-side guard: only allow the personal page as a destination when one
|
|
// actually exists. /p/<id> also redirects to /join/<id> when empty, so a live
|
|
// banner can never dead-end — but we shouldn't create that situation at all.
|
|
let target = 'https://rmcircle.team/join/' + opts.id + (opts.angle ? '?v=' + opts.angle : '');
|
|
if (opts.target === 'page') {
|
|
if (!opts.hasPage) throw new Error('Build your personal page first, then you can point ads at it.');
|
|
target = 'https://rmcircle.team/p/' + opts.id;
|
|
}
|
|
|
|
const idem = 'rmc-' + opts.id + '-' + monthKey() + '-' + crypto.randomBytes(6).toString('hex');
|
|
const payload = {
|
|
action: 'create', member_id: Number(opts.id), idem_key: idem,
|
|
kind: isText ? 'text' : 'banner',
|
|
impressions: impressions, days: 365, target_url: target,
|
|
advertiser_name: (opts.name || 'RM Circle member #' + opts.id).slice(0, 60),
|
|
advertiser_email: '', catid: 5
|
|
};
|
|
if (isText) {
|
|
payload.subject = subject;
|
|
payload.lines = lines;
|
|
} else {
|
|
payload.size = size;
|
|
payload.banner_url = 'https://rmcircle.team/banners/' + file;
|
|
}
|
|
const res = await callNas(payload);
|
|
|
|
const entry = {
|
|
adId: res.ad_id, impressions: impressions,
|
|
size: isText ? 'text' : size, creative: isText ? '' : file,
|
|
kind: isText ? 'text' : 'banner',
|
|
subject: isText ? subject : undefined,
|
|
lines: isText ? lines : undefined,
|
|
target: target, at: new Date().toISOString()
|
|
};
|
|
record(opts.id, entry);
|
|
return entry;
|
|
}
|
|
|
|
// Stop a running banner and return the UNSERVED impressions to the member's
|
|
// monthly balance. Order matters: read the counters BEFORE deactivating,
|
|
// because deactivation zeroes `remaining` and would make it look fully served.
|
|
async function stop(memberId, adId) {
|
|
const all = readLedger();
|
|
const mk = monthKey();
|
|
const rows = ((all[mk] || {})[String(memberId)]) || [];
|
|
const row = rows.find(function (r) { return Number(r.adId) === Number(adId); });
|
|
if (!row) throw new Error('That banner is not one of yours from this month.');
|
|
if (row.stopped) throw new Error('That banner is already stopped.');
|
|
|
|
let served = 0, unserved = 0;
|
|
try {
|
|
const s = await callNas({ action: 'stats', ad_ids: [Number(adId)] });
|
|
const st = (s.stats || [])[0];
|
|
if (st) {
|
|
served = Math.max(0, Number(st.served) || 0);
|
|
unserved = Math.max(0, Number(st.remaining) || 0);
|
|
}
|
|
} catch (e) { /* if stats are unavailable, refund nothing rather than guess */ }
|
|
|
|
await callNas({ action: 'deactivate', ad_id: Number(adId) });
|
|
|
|
// Charge only what actually served; the rest returns to the allowance.
|
|
// `bought` preserves the original order size so the member's history still
|
|
// shows what they launched, not just what it ended up costing them.
|
|
if (row.bought == null) row.bought = row.impressions;
|
|
row.stopped = true;
|
|
row.stoppedAt = new Date().toISOString();
|
|
row.served = served;
|
|
row.refunded = unserved;
|
|
row.impressions = served; // what this campaign counts against the month
|
|
writeLedger(all);
|
|
return { adId: Number(adId), served: served, refunded: unserved };
|
|
}
|
|
|
|
async function stats(adIds) {
|
|
if (!adIds || !adIds.length) return [];
|
|
const r = await callNas({ action: 'stats', ad_ids: adIds.slice(0, 200) });
|
|
return r.stats || [];
|
|
}
|
|
|
|
module.exports = { init, configured, status, launch, stop, stats, allowanceFor, sizes, CREATIVES, ALLOWANCE };
|