3b3423d15e
Each line gets its own emoji bookends, so a sentence running across lines reads as broken rather than as three fragments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
195 lines
9.0 KiB
JavaScript
195 lines
9.0 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' +
|
|
'EACH LINE MUST STAND ALONE. Every line is decorated separately, so a sentence must never run on from one line into the next. "Get two and teach / them to do the / SAME THING" is WRONG. Three self-contained fragments is RIGHT.\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
|
|
};
|