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>
194 lines
8.8 KiB
JavaScript
194 lines
8.8 KiB
JavaScript
// Circle Suite — Text Ad generator for the Traffic Desk.
|
|
//
|
|
// Text placements out-perform banners on this network (357 text ads have taken
|
|
// ~171 clicks each against ~150 for banners, on a fraction of the inventory),
|
|
// so members get a generator for them too.
|
|
//
|
|
// The format is not a guess. It was measured across 60 live text ads on the
|
|
// network: Subject is capped at 20 characters, Body is ALWAYS exactly three
|
|
// lines joined by <br>, each capped at 24 characters. Every ad in the sample
|
|
// obeyed that, without exception.
|
|
//
|
|
// Two deliberate design decisions:
|
|
//
|
|
// 1. The engine writes PLAIN text inside a deliberately tight budget, and we
|
|
// apply the emoji ourselves afterward. Language models cannot count
|
|
// characters reliably — especially with emoji, where one glyph can be
|
|
// several code points — so asking for "<=24 chars with emoji" produces
|
|
// overflow every time. Writing to 20 and adding a known pair of 1-code-point
|
|
// emoji is arithmetic we control, and it lets us rotate the emoji
|
|
// independently of the copy so two ads never look alike.
|
|
//
|
|
// 2. The palette carries NO money emoji. The network is full of piles-of-cash
|
|
// ads; ours deliberately do not imply earnings, because the compliance rule
|
|
// against implying income outranks matching the neighbours.
|
|
'use strict';
|
|
|
|
const suiteAi = require('./suite-ai');
|
|
|
|
const MAX_SUBJECT = 20; // measured hard cap on live inventory
|
|
const MAX_LINE = 24; // measured hard cap per body line
|
|
const BODY_LINES = 3; // every single live ad uses exactly three
|
|
|
|
// Budget the engine writes to, leaving room for the emoji we add.
|
|
const SUBJECT_BUDGET = MAX_SUBJECT - 2;
|
|
const LINE_BUDGET = MAX_LINE - 2;
|
|
|
|
// Single-code-point emoji only — no variation selectors, no ZWJ sequences, so
|
|
// the character arithmetic above stays exact.
|
|
const PALETTES = [
|
|
['⚡', '⚡'], // high voltage
|
|
['🔥', '🔥'], // fire
|
|
['🚀', '🚀'], // rocket
|
|
['🔗', '🔗'], // link
|
|
['👥', '👥'], // busts in silhouette
|
|
['🤝', '🤝'], // handshake
|
|
['🎯', '🎯'], // direct hit
|
|
['⭐', '⭐'], // star
|
|
['🧩', '🧩'], // puzzle piece
|
|
['🔓', '🔓'], // unlocked
|
|
['🟣', '🟣'], // purple circle (Polygon)
|
|
['✅', '✅'] // check mark
|
|
];
|
|
|
|
// Angles mirror the ones the landing pages already support, so a click on the
|
|
// ad continues the same hook it started with.
|
|
const ANGLES = {
|
|
general: { label: 'The straight pitch', brief: 'the plain honest pitch: a crypto team build on a public smart contract, you need two people, payments are person to person' },
|
|
two: { label: 'You only need two', brief: 'the whole job is getting two people and helping them get their two — depth, not width, and nothing else to memorise' },
|
|
pocket: { label: 'Pocket change', brief: 'the entry is small and one-time, this is not a monthly subscription that bleeds you' },
|
|
phone: { label: 'Phone only', brief: 'the entire thing runs from a phone — no laptop, no office, no stock, no shipping' },
|
|
graveyard: { label: 'Side hustle graveyard', brief: 'this one does not vanish like every other program, because the contract is public, unchangeable and holds nobody\'s money' },
|
|
verify: { label: 'Verify it yourself', brief: 'do not trust anyone, read the contract on-chain yourself before you put in a cent' }
|
|
};
|
|
function angles() {
|
|
return Object.keys(ANGLES).map(function (k) { return { key: k, label: ANGLES[k].label }; });
|
|
}
|
|
|
|
function cp(s) { return Array.from(String(s || '')); }
|
|
function cpLen(s) { return cp(s).length; }
|
|
|
|
// Trim to a code-point budget on a word boundary where possible, so an ad never
|
|
// ends mid-word. Falls back to a hard cut for a single long token.
|
|
function fit(s, budget) {
|
|
s = String(s || '').replace(/\s+/g, ' ').trim();
|
|
s = s.replace(/[<>]/g, '');
|
|
if (cpLen(s) <= budget) return s;
|
|
const words = s.split(' ');
|
|
let out = '';
|
|
for (let i = 0; i < words.length; i++) {
|
|
const next = out ? out + ' ' + words[i] : words[i];
|
|
if (cpLen(next) > budget) break;
|
|
out = next;
|
|
}
|
|
if (!out) out = cp(s).slice(0, budget).join('');
|
|
return out.replace(/[,;:\-–—]+$/, '').trim();
|
|
}
|
|
|
|
// Strip the decoration models add on their own so our budget maths holds.
|
|
function strip(s) {
|
|
return String(s || '')
|
|
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}]/gu, '')
|
|
.replace(/^["'“”\s*#>\-]+|["'“”\s*]+$/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function parse(text) {
|
|
const out = [];
|
|
// Split on an "AD n" marker, tolerating markdown and missing numbers.
|
|
const blocks = String(text || '').split(/(?:^|\n)[ \t]*[*#>\s]*AD\s*\d*[ \t]*[*#:]*[ \t]*(?=\n)/i);
|
|
blocks.forEach(function (b) {
|
|
const grab = function (label) {
|
|
const m = b.match(new RegExp('(?:^|\\n)[ \\t]*[*#>\\s]*' + label + '[ \\t]*[*#]*[ \\t]*:?[ \\t]*[*#]*[ \\t]*(.+)', 'i'));
|
|
return m ? strip(m[1]) : '';
|
|
};
|
|
const subject = grab('SUBJECT');
|
|
const l1 = grab('L1'), l2 = grab('L2'), l3 = grab('L3');
|
|
if (!subject || !l1) return;
|
|
out.push({ subject: subject, lines: [l1, l2, l3] });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// Apply emoji and enforce the measured limits. Line 3 is left bare on most ads
|
|
// because the live inventory does that too — a plain uppercase closing line
|
|
// reads as a call to action rather than more decoration.
|
|
function decorate(v, index) {
|
|
const pal = PALETTES[index % PALETTES.length];
|
|
const alt = PALETTES[(index + 5) % PALETTES.length];
|
|
|
|
const subject = fit(strip(v.subject), SUBJECT_BUDGET);
|
|
const lines = [];
|
|
const raw = v.lines.map(function (l) { return strip(l); });
|
|
|
|
lines.push(pal[0] + fit(raw[0], LINE_BUDGET) + pal[1]);
|
|
if (raw[1]) lines.push(alt[0] + fit(raw[1], LINE_BUDGET) + alt[1]);
|
|
else lines.push('');
|
|
// Third line: bare, and upper-cased when it is short enough to read as a CTA.
|
|
if (raw[2]) {
|
|
const third = fit(raw[2], MAX_LINE);
|
|
lines.push(cpLen(third) <= 18 ? third.toUpperCase() : third);
|
|
} else lines.push('');
|
|
|
|
return {
|
|
subject: pal[0] + subject + pal[1],
|
|
lines: lines,
|
|
preview: lines.filter(Boolean).join('\n')
|
|
};
|
|
}
|
|
|
|
function valid(v) {
|
|
if (cpLen(v.subject) > MAX_SUBJECT) return false;
|
|
if (v.lines.length !== BODY_LINES) return false;
|
|
if (v.lines.some(function (l) { return cpLen(l) > MAX_LINE; })) return false;
|
|
if (!v.lines[0]) return false;
|
|
return true;
|
|
}
|
|
|
|
// Generate `want` distinct ads. We ask for extras because some come back
|
|
// unusable, and dedupe on the subject so a member never sees two of the same.
|
|
async function generate(opts) {
|
|
const want = Math.max(1, Math.min(8, Number(opts.count) || 5));
|
|
const angle = ANGLES[opts.angle] ? opts.angle : 'general';
|
|
const ask = want + 3;
|
|
|
|
const instruction =
|
|
'You are writing tiny text ads for a rotating advertising network, promoting a crypto team build called The RM Circle.\n\n' +
|
|
'WHAT IT IS: The RM Circle is a team build on a public, verified smart contract on Polygon. The contract cannot be changed and holds no member funds — every payment goes person to person in the same transaction. The whole job is getting two personal referrals and helping them get their two.\n\n' +
|
|
'ANGLE FOR THESE ADS: ' + ANGLES[angle].brief + '\n\n' +
|
|
'ABSOLUTE RULES:\n' +
|
|
'- NEVER promise, guarantee, project or imply income, earnings, returns or profit. No "$", no numbers of money, no "earn", no "profit", no "income", no "passive".\n' +
|
|
'- No hype words: guaranteed, risk-free, get rich, financial freedom.\n' +
|
|
'- Do not invent figures or results.\n' +
|
|
'- Write PLAIN TEXT ONLY. Do NOT use emoji, asterisks, quotes or HTML — decoration is added later by the system.\n\n' +
|
|
'HARD LENGTH LIMITS (count every character, spaces included — going over makes the ad unusable):\n' +
|
|
'- SUBJECT: at most ' + SUBJECT_BUDGET + ' characters.\n' +
|
|
'- L1, L2, L3: at most ' + LINE_BUDGET + ' characters EACH.\n' +
|
|
'These are extremely short. Think billboard, not sentence. Fragments are good. L3 should be a short call to action.\n\n' +
|
|
'Write ' + ask + ' different ads. Vary the wording and the hook across them. Output EXACTLY this shape and nothing else:\n\n' +
|
|
'AD 1\nSUBJECT: <text>\nL1: <text>\nL2: <text>\nL3: <text>\n\nAD 2\nSUBJECT: <text>\nL1: <text>\nL2: <text>\nL3: <text>\n\n(and so on)';
|
|
|
|
const text = await suiteAi.generateRaw(instruction);
|
|
const parsed = parse(text);
|
|
|
|
const seen = {};
|
|
const out = [];
|
|
parsed.forEach(function (v, i) {
|
|
const d = decorate(v, i);
|
|
if (!valid(d)) return;
|
|
const key = d.subject.toLowerCase();
|
|
if (seen[key]) return;
|
|
seen[key] = 1;
|
|
out.push(d);
|
|
});
|
|
|
|
if (!out.length) throw new Error('The engine did not come back with usable ads — try again.');
|
|
return { angle: angle, variants: out.slice(0, want) };
|
|
}
|
|
|
|
module.exports = {
|
|
generate, angles, decorate, fit, parse, valid,
|
|
ANGLES, PALETTES, MAX_SUBJECT, MAX_LINE, BODY_LINES
|
|
};
|