content-grade 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +198 -0
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/bin/content-grade.js +1033 -0
- package/bin/telemetry.js +230 -0
- package/dist/assets/index-BUN69TiT.js +78 -0
- package/dist/index.html +22 -0
- package/dist-server/server/app.js +36 -0
- package/dist-server/server/claude.js +22 -0
- package/dist-server/server/db.js +79 -0
- package/dist-server/server/index.js +62 -0
- package/dist-server/server/routes/demos.js +1701 -0
- package/dist-server/server/routes/stripe.js +136 -0
- package/dist-server/server/services/claude.js +55 -0
- package/dist-server/server/services/stripe.js +109 -0
- package/package.json +84 -0
|
@@ -0,0 +1,1701 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import * as https from 'https';
|
|
3
|
+
import * as http from 'http';
|
|
4
|
+
import { getDb } from '../db.js';
|
|
5
|
+
import { askClaude } from '../claude.js';
|
|
6
|
+
import { hasActiveSubscriptionLive, hasPurchased } from '../services/stripe.js';
|
|
7
|
+
// ── Usage tracking utilities ──────────────────────────────────────
|
|
8
|
+
const FREE_TIER_LIMIT = 3;
|
|
9
|
+
const PRO_TIER_LIMIT = 100;
|
|
10
|
+
const HEADLINE_GRADER_ENDPOINT = 'headline-grader';
|
|
11
|
+
const UPGRADE_URL = 'https://contentgrade.ai/#pricing';
|
|
12
|
+
function freeGateMsg(what) {
|
|
13
|
+
return `Free daily limit reached (${FREE_TIER_LIMIT}/day). ${what} — upgrade to Pro at ${UPGRADE_URL}`;
|
|
14
|
+
}
|
|
15
|
+
function proGateMsg() {
|
|
16
|
+
return `Pro daily limit reached (${PRO_TIER_LIMIT}/day). Resets at midnight UTC.`;
|
|
17
|
+
}
|
|
18
|
+
function hashIp(ip) {
|
|
19
|
+
return createHash('sha256').update(ip).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
function todayUTC() {
|
|
22
|
+
return new Date().toISOString().slice(0, 10);
|
|
23
|
+
}
|
|
24
|
+
function getUsageCount(ipHash, endpoint) {
|
|
25
|
+
const db = getDb();
|
|
26
|
+
const row = db.prepare('SELECT count FROM usage_tracking WHERE ip_hash = ? AND endpoint = ? AND date = ?').get(ipHash, endpoint, todayUTC());
|
|
27
|
+
return row?.count ?? 0;
|
|
28
|
+
}
|
|
29
|
+
function incrementUsage(ipHash, endpoint) {
|
|
30
|
+
const db = getDb();
|
|
31
|
+
db.prepare(`
|
|
32
|
+
INSERT INTO usage_tracking (ip_hash, endpoint, date, count) VALUES (?, ?, ?, 1)
|
|
33
|
+
ON CONFLICT(ip_hash, endpoint, date) DO UPDATE SET count = count + 1
|
|
34
|
+
`).run(ipHash, endpoint, todayUTC());
|
|
35
|
+
return getUsageCount(ipHash, endpoint);
|
|
36
|
+
}
|
|
37
|
+
function resetUsage(ipHash, endpoint) {
|
|
38
|
+
const db = getDb();
|
|
39
|
+
db.prepare('INSERT INTO usage_tracking (ip_hash, endpoint, date, count) VALUES (?, ?, ?, 0) ON CONFLICT(ip_hash, endpoint, date) DO UPDATE SET count = 0').run(ipHash, endpoint, todayUTC());
|
|
40
|
+
}
|
|
41
|
+
// Checks free vs Pro tier. Pass email from request body when available.
|
|
42
|
+
// Pro users (active subscription) get PRO_TIER_LIMIT instead of FREE_TIER_LIMIT.
|
|
43
|
+
// AudienceDecoder passes productKey='audiencedecoder_report' to check one-time purchase.
|
|
44
|
+
// Verifies subscription status against Stripe API directly (5-min cache) instead of a stale DB flag.
|
|
45
|
+
async function checkRateLimit(ipHash, endpoint, email, productKey) {
|
|
46
|
+
if (email) {
|
|
47
|
+
const isPro = productKey
|
|
48
|
+
? hasPurchased(email, productKey)
|
|
49
|
+
: await hasActiveSubscriptionLive(email);
|
|
50
|
+
if (isPro) {
|
|
51
|
+
const count = getUsageCount(ipHash, endpoint);
|
|
52
|
+
if (count >= PRO_TIER_LIMIT) {
|
|
53
|
+
return { allowed: false, remaining: 0, limit: PRO_TIER_LIMIT, isPro: true };
|
|
54
|
+
}
|
|
55
|
+
return { allowed: true, remaining: PRO_TIER_LIMIT - count, limit: PRO_TIER_LIMIT, isPro: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const count = getUsageCount(ipHash, endpoint);
|
|
59
|
+
if (count >= FREE_TIER_LIMIT) {
|
|
60
|
+
return { allowed: false, remaining: 0, limit: FREE_TIER_LIMIT, isPro: false };
|
|
61
|
+
}
|
|
62
|
+
return { allowed: true, remaining: FREE_TIER_LIMIT - count, limit: FREE_TIER_LIMIT, isPro: false };
|
|
63
|
+
}
|
|
64
|
+
function parseResult(raw) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(raw);
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
70
|
+
if (!m)
|
|
71
|
+
throw new Error('Could not parse analysis response.');
|
|
72
|
+
return JSON.parse(m[0]);
|
|
73
|
+
}
|
|
74
|
+
export function registerDemoRoutes(app) {
|
|
75
|
+
// ── Headline Grader ──────────────────────────────────────
|
|
76
|
+
app.post('/api/demos/headline-grader/unlock', async (req, reply) => {
|
|
77
|
+
const body = req.body;
|
|
78
|
+
const email = (body?.email ?? '').trim().toLowerCase();
|
|
79
|
+
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
80
|
+
reply.status(400);
|
|
81
|
+
return { error: 'Valid email address required.' };
|
|
82
|
+
}
|
|
83
|
+
const ipHash = hashIp(req.ip);
|
|
84
|
+
const db = getDb();
|
|
85
|
+
try {
|
|
86
|
+
db.prepare('INSERT OR IGNORE INTO email_captures (email, tool, score, ip_hash, source) VALUES (?, ?, ?, ?, ?)').run(email, 'headline-grader', '', ipHash, 'headline-grader');
|
|
87
|
+
}
|
|
88
|
+
catch (_err) {
|
|
89
|
+
// already captured — that's fine, still grant the reset
|
|
90
|
+
}
|
|
91
|
+
resetUsage(ipHash, HEADLINE_GRADER_ENDPOINT);
|
|
92
|
+
return { unlocked: true, remaining: FREE_TIER_LIMIT };
|
|
93
|
+
});
|
|
94
|
+
app.post('/api/demos/headline-grader', async (req, reply) => {
|
|
95
|
+
const body = req.body;
|
|
96
|
+
const headline = (body?.headline ?? '').trim();
|
|
97
|
+
const context = (['email', 'ad', 'landing', 'blog', 'social'].includes((body?.context ?? '').toLowerCase())
|
|
98
|
+
? body.context.toLowerCase()
|
|
99
|
+
: 'general');
|
|
100
|
+
if (!headline || headline.length < 3) {
|
|
101
|
+
reply.status(400);
|
|
102
|
+
return { error: 'Headline must be at least 3 characters.' };
|
|
103
|
+
}
|
|
104
|
+
if (headline.length > 500) {
|
|
105
|
+
reply.status(400);
|
|
106
|
+
return { error: 'Headline must be under 500 characters.' };
|
|
107
|
+
}
|
|
108
|
+
const ipHash = hashIp(req.ip);
|
|
109
|
+
const email = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
110
|
+
const rate = await checkRateLimit(ipHash, HEADLINE_GRADER_ENDPOINT, email);
|
|
111
|
+
if (!rate.allowed) {
|
|
112
|
+
return {
|
|
113
|
+
gated: true,
|
|
114
|
+
isPro: rate.isPro,
|
|
115
|
+
remaining: 0,
|
|
116
|
+
limit: rate.limit,
|
|
117
|
+
message: rate.isPro ? proGateMsg() : freeGateMsg('Get 100 analyses/day with Pro'),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const wordCountByContext = {
|
|
121
|
+
email: '4–9 words ideal (penalize beyond 12)',
|
|
122
|
+
social: '10–20 words acceptable when curiosity gap is strong',
|
|
123
|
+
landing: '8–18 words acceptable — positioning headlines are legitimately longer',
|
|
124
|
+
ad: '6–12 words ideal',
|
|
125
|
+
blog: '6–14 words ideal (numbered lists get specificity bonus)',
|
|
126
|
+
general: '6–12 words ideal',
|
|
127
|
+
};
|
|
128
|
+
const wordCountGuide = wordCountByContext[context] ?? wordCountByContext['general'];
|
|
129
|
+
const systemPrompt = `You are a world-class direct response copywriting analyst. You evaluate headlines using four proven conversion frameworks.
|
|
130
|
+
|
|
131
|
+
CONTENT TYPE: ${context}
|
|
132
|
+
This headline is a ${context === 'general' ? 'web headline' : context + ' headline'}. Apply context-appropriate standards throughout your evaluation.
|
|
133
|
+
|
|
134
|
+
SCORING SYSTEM (total 100 points):
|
|
135
|
+
|
|
136
|
+
**Pillar 1: Masterson's Rule of One + 4 U's (30 points)**
|
|
137
|
+
- Single dominant idea — one big promise, not multiple competing claims (scores 0–7.5)
|
|
138
|
+
- Urgent: time pressure or implied scarcity (0–7.5)
|
|
139
|
+
- Unique: novel angle or differentiated claim (0–7.5)
|
|
140
|
+
- Ultra-Specific: concrete numbers, timeframes, named methods (0–7.5)
|
|
141
|
+
|
|
142
|
+
**Pillar 2: Hormozi's Value Equation (30 points)**
|
|
143
|
+
Formula: Value = (Dream Outcome × Perceived Likelihood) / (Time Delay × Effort & Sacrifice)
|
|
144
|
+
- Dream Outcome communicated: what is the big prize? (0–10)
|
|
145
|
+
- Perceived Likelihood: believable? proof elements present? (0–10)
|
|
146
|
+
- Low Time Delay implied: speed of result suggested? (0–5)
|
|
147
|
+
- Low Effort/Sacrifice implied: ease suggested? (0–5)
|
|
148
|
+
|
|
149
|
+
**Pillar 3: Readability & Clarity (20 points)**
|
|
150
|
+
- Flesch-Kincaid grade level — target 5th grade or lower (0–10)
|
|
151
|
+
- Word count: ${wordCountGuide} (0–5)
|
|
152
|
+
- No passive voice, no jargon, no corporate speak (0–5)
|
|
153
|
+
|
|
154
|
+
**Pillar 4: Proof + Promise + Plan (20 points)**
|
|
155
|
+
- Proof element: number, credential, named source, social proof signal (0–7)
|
|
156
|
+
— For landing page or new-product positioning, a clear competitor differentiation claim counts as implicit proof (score up to 5/7 without explicit data)
|
|
157
|
+
- Clear promise: specific outcome or benefit stated (0–7)
|
|
158
|
+
- Plan hint: method, framework, or "how" implied (0–6)
|
|
159
|
+
|
|
160
|
+
GRADING SCALE:
|
|
161
|
+
90–100: A+ | 85–89: A | 80–84: A- | 75–79: B+ | 70–74: B | 65–69: B- | 60–64: C+ | 55–59: C | 50–54: C- | 40–49: D | 0–39: F
|
|
162
|
+
|
|
163
|
+
CALIBRATION ANCHORS — compare the headline under review against these reference examples:
|
|
164
|
+
95+: "I asked 2,347 SaaS founders what killed their first startup — #1 answer shocked me" [data authority + curiosity gap + specific number + single reveal]
|
|
165
|
+
88–92: "I analyzed 10,000 landing pages and found that 73% fail the same test — here's the 30-second fix" [strong proof + promise + plan, dream outcome slightly implied]
|
|
166
|
+
75–84: "Cut Your AWS Bill by 40% in 30 Days — No Migration Required" [clear value equation, objection handling, lacks social proof]
|
|
167
|
+
65–74: "The Headline Grader That Uses Real Conversion Frameworks Instead of Word Counting" [clear differentiator/positioning, no urgency, proof is implicit — valid for landing page H1]
|
|
168
|
+
50–64: "7 Ways to Write Better Headlines" [decent structure — number + benefit — but vague and unspecific]
|
|
169
|
+
35–49: "Don't Miss Our Sale" [generic cliché, no specifics, no proof, no real promise]
|
|
170
|
+
20–30: "Tips for Better Marketing" [zero specifics, zero proof, zero urgency, commodity topic]
|
|
171
|
+
10–25: "Some Thoughts on Marketing" [corporate filler, zero hook, zero promise, zero reason to click]
|
|
172
|
+
|
|
173
|
+
SPECIAL CASE — DIFFERENTIATOR HEADLINES:
|
|
174
|
+
If the headline uses the pattern "The [X] that does [Y] instead of [Z]" or "The [X] for [audience] who [pain]", treat it as a valid positioning strategy (not a failure). Score the CLARITY and SPECIFICITY of the differentiation, not the absence of data proof. These headlines belong in the 65–78 range when the comparison is clear and the audience benefit is inferable.
|
|
175
|
+
|
|
176
|
+
CRITICAL SCORING RULES:
|
|
177
|
+
1. USE THE FULL RANGE. Scores below 30 and above 90 MUST exist. If every headline you score lands between 50-80, you are compressing and failing at your job.
|
|
178
|
+
2. A headline that hits 3+ calibration anchor criteria at the 88-92 level MUST score 88-92, not 75.
|
|
179
|
+
3. A generic headline like "Tips for Better Marketing" MUST score below 35, not 55.
|
|
180
|
+
4. Score the headline FIRST against the calibration anchors by finding the closest match, THEN adjust ±5 based on framework analysis. Do NOT score frameworks first and average — that causes compression.
|
|
181
|
+
5. The grade letter MUST match the score: 90-100=A+, 85-89=A, 80-84=A-, etc. Never assign a grade that doesn't match.
|
|
182
|
+
|
|
183
|
+
Respond ONLY with valid JSON matching this exact schema — no markdown, no extra text:
|
|
184
|
+
{
|
|
185
|
+
"total_score": <number 0-100>,
|
|
186
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">,
|
|
187
|
+
"framework_scores": {
|
|
188
|
+
"rule_of_one": { "score": <0-30>, "max": 30, "feedback": "<1-2 sentences>" },
|
|
189
|
+
"value_equation": { "score": <0-30>, "max": 30, "feedback": "<1-2 sentences>" },
|
|
190
|
+
"readability": { "score": <0-20>, "max": 20, "feedback": "<1-2 sentences>" },
|
|
191
|
+
"proof_promise_plan": { "score": <0-20>, "max": 20, "feedback": "<1-2 sentences>" }
|
|
192
|
+
},
|
|
193
|
+
"diagnosis": "<One sharp sentence: the single biggest weakness or strength>",
|
|
194
|
+
"rewrites": [
|
|
195
|
+
{ "text": "<rewritten headline>", "predicted_score": <number>, "optimized_for": "rule_of_one", "technique": "<specific technique applied, e.g. 'Added number + timeframe'>" },
|
|
196
|
+
{ "text": "<rewritten headline>", "predicted_score": <number>, "optimized_for": "value_equation", "technique": "<specific technique applied>" },
|
|
197
|
+
{ "text": "<rewritten headline>", "predicted_score": <number>, "optimized_for": "proof_promise_plan", "technique": "<specific technique applied>" }
|
|
198
|
+
],
|
|
199
|
+
"upgrade_hook": "<One sentence teasing what a full above-the-fold audit would reveal — vary based on what's actually missing: subhead clarity, CTA friction, proof density, or social signal placement>"
|
|
200
|
+
}`;
|
|
201
|
+
try {
|
|
202
|
+
const raw = await askClaude(`Grade this headline: "${headline}"`, {
|
|
203
|
+
systemPrompt,
|
|
204
|
+
model: 'haiku',
|
|
205
|
+
});
|
|
206
|
+
let parsed;
|
|
207
|
+
try {
|
|
208
|
+
parsed = JSON.parse(raw);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
212
|
+
if (!jsonMatch)
|
|
213
|
+
throw new Error('Could not parse scoring response.');
|
|
214
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
215
|
+
}
|
|
216
|
+
const newCount = incrementUsage(ipHash, HEADLINE_GRADER_ENDPOINT);
|
|
217
|
+
const remaining = Math.max(0, rate.limit - newCount);
|
|
218
|
+
return { ...parsed, usage: { remaining, limit: rate.limit, isPro: rate.isPro, gated: false } };
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
console.error('headline_grader_demo', err);
|
|
222
|
+
reply.status(500);
|
|
223
|
+
return { error: `Scoring failed: ${err.message}` };
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
app.post('/api/demos/headline-grader/compare', async (req, reply) => {
|
|
227
|
+
const body = req.body;
|
|
228
|
+
const headlineA = (body?.headlineA ?? '').trim();
|
|
229
|
+
const headlineB = (body?.headlineB ?? '').trim();
|
|
230
|
+
if (!headlineA || headlineA.length < 3 || !headlineB || headlineB.length < 3) {
|
|
231
|
+
reply.status(400);
|
|
232
|
+
return { error: 'Both headlines must be at least 3 characters.' };
|
|
233
|
+
}
|
|
234
|
+
if (headlineA.length > 500 || headlineB.length > 500) {
|
|
235
|
+
reply.status(400);
|
|
236
|
+
return { error: 'Headlines must be under 500 characters each.' };
|
|
237
|
+
}
|
|
238
|
+
const hgcIpHash = hashIp(req.ip);
|
|
239
|
+
const hgcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
240
|
+
const hgcRate = await checkRateLimit(hgcIpHash, HEADLINE_GRADER_ENDPOINT, hgcEmail);
|
|
241
|
+
if (!hgcRate.allowed) {
|
|
242
|
+
return {
|
|
243
|
+
gated: true,
|
|
244
|
+
isPro: hgcRate.isPro,
|
|
245
|
+
remaining: 0,
|
|
246
|
+
limit: hgcRate.limit,
|
|
247
|
+
message: hgcRate.isPro
|
|
248
|
+
? proGateMsg() : freeGateMsg('Get 100 comparisons/day with Pro'),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const scoringSystemPrompt = `You are a world-class direct response copywriting analyst. You evaluate headlines using four proven conversion frameworks.
|
|
252
|
+
|
|
253
|
+
SCORING SYSTEM (total 100 points):
|
|
254
|
+
|
|
255
|
+
**Pillar 1: Masterson's Rule of One + 4 U's (30 points)**
|
|
256
|
+
- Single dominant idea — one big promise, not multiple competing claims (scores 0–7.5)
|
|
257
|
+
- Urgent: time pressure or implied scarcity (0–7.5)
|
|
258
|
+
- Unique: novel angle or differentiated claim (0–7.5)
|
|
259
|
+
- Ultra-Specific: concrete numbers, timeframes, named methods (0–7.5)
|
|
260
|
+
|
|
261
|
+
**Pillar 2: Hormozi's Value Equation (30 points)**
|
|
262
|
+
Formula: Value = (Dream Outcome × Perceived Likelihood) / (Time Delay × Effort & Sacrifice)
|
|
263
|
+
- Dream Outcome communicated: what is the big prize? (0–10)
|
|
264
|
+
- Perceived Likelihood: believable? proof elements present? (0–10)
|
|
265
|
+
- Low Time Delay implied: speed of result suggested? (0–5)
|
|
266
|
+
- Low Effort/Sacrifice implied: ease suggested? (0–5)
|
|
267
|
+
|
|
268
|
+
**Pillar 3: Readability & Clarity (20 points)**
|
|
269
|
+
- Flesch-Kincaid grade level — target 5th grade or lower (0–10)
|
|
270
|
+
- Word count: 6–12 words ideal for web headlines (0–5)
|
|
271
|
+
- No passive voice, no jargon, no corporate speak (0–5)
|
|
272
|
+
|
|
273
|
+
**Pillar 4: Proof + Promise + Plan (20 points)**
|
|
274
|
+
- Proof element: number, credential, named source, social proof signal (0–7)
|
|
275
|
+
- Clear promise: specific outcome or benefit stated (0–7)
|
|
276
|
+
- Plan hint: method, framework, or "how" implied (0–6)
|
|
277
|
+
|
|
278
|
+
GRADING SCALE:
|
|
279
|
+
90–100: A+ | 85–89: A | 80–84: A- | 75–79: B+ | 70–74: B | 65–69: B- | 60–64: C+ | 55–59: C | 50–54: C- | 40–49: D | 0–39: F
|
|
280
|
+
|
|
281
|
+
Respond ONLY with valid JSON matching this exact schema — no markdown, no extra text:
|
|
282
|
+
{
|
|
283
|
+
"total_score": <number 0-100>,
|
|
284
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">,
|
|
285
|
+
"framework_scores": {
|
|
286
|
+
"rule_of_one": { "score": <0-30>, "max": 30, "feedback": "<1-2 sentences>" },
|
|
287
|
+
"value_equation": { "score": <0-30>, "max": 30, "feedback": "<1-2 sentences>" },
|
|
288
|
+
"readability": { "score": <0-20>, "max": 20, "feedback": "<1-2 sentences>" },
|
|
289
|
+
"proof_promise_plan": { "score": <0-20>, "max": 20, "feedback": "<1-2 sentences>" }
|
|
290
|
+
},
|
|
291
|
+
"diagnosis": "<One sharp sentence: the single biggest weakness or strength>"
|
|
292
|
+
}`;
|
|
293
|
+
const compareSystemPrompt = `You are a world-class direct response copywriting analyst. You will be given two scored headlines with their framework breakdowns. Your job is to:
|
|
294
|
+
1. Write a sharp 2-sentence verdict explaining WHY the winner is stronger — reference the specific framework scores and gaps (e.g. "Headline A's Rule of One score (24/30 vs 12/30) shows a single dominant promise...")
|
|
295
|
+
2. Write a suggested hybrid headline that steals the best element from each — be specific about what was borrowed
|
|
296
|
+
|
|
297
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
298
|
+
{
|
|
299
|
+
"verdict": "<2 sentences citing specific framework score gaps — name the frameworks and numbers>",
|
|
300
|
+
"suggested_hybrid": "<the rewritten headline itself — punchy, direct, no quotes> | <one sentence: what was taken from A and what was taken from B>"
|
|
301
|
+
}`;
|
|
302
|
+
function parseScore(raw) {
|
|
303
|
+
try {
|
|
304
|
+
return JSON.parse(raw);
|
|
305
|
+
}
|
|
306
|
+
catch { }
|
|
307
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
308
|
+
if (!m)
|
|
309
|
+
throw new Error('Could not parse scoring response.');
|
|
310
|
+
return JSON.parse(m[0]);
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const [rawA, rawB] = await Promise.all([
|
|
314
|
+
askClaude(`Grade this headline: "${headlineA}"`, {
|
|
315
|
+
systemPrompt: scoringSystemPrompt,
|
|
316
|
+
model: 'haiku',
|
|
317
|
+
}),
|
|
318
|
+
askClaude(`Grade this headline: "${headlineB}"`, {
|
|
319
|
+
systemPrompt: scoringSystemPrompt,
|
|
320
|
+
model: 'haiku',
|
|
321
|
+
}),
|
|
322
|
+
]);
|
|
323
|
+
const scoreA = parseScore(rawA);
|
|
324
|
+
const scoreB = parseScore(rawB);
|
|
325
|
+
// Compute winner/margin/framework_winners deterministically — never trust the model for arithmetic
|
|
326
|
+
const fA = scoreA.framework_scores;
|
|
327
|
+
const fB = scoreB.framework_scores;
|
|
328
|
+
const computedMargin = Math.abs(scoreA.total_score - scoreB.total_score);
|
|
329
|
+
const computedWinner = scoreA.total_score > scoreB.total_score ? 'A' :
|
|
330
|
+
scoreB.total_score > scoreA.total_score ? 'B' : 'tie';
|
|
331
|
+
const fwWin = (a, b) => a > b ? 'A' : b > a ? 'B' : 'tie';
|
|
332
|
+
const computedFwWinners = {
|
|
333
|
+
rule_of_one: fwWin(fA.rule_of_one.score, fB.rule_of_one.score),
|
|
334
|
+
value_equation: fwWin(fA.value_equation.score, fB.value_equation.score),
|
|
335
|
+
readability: fwWin(fA.readability.score, fB.readability.score),
|
|
336
|
+
proof_promise_plan: fwWin(fA.proof_promise_plan.score, fB.proof_promise_plan.score),
|
|
337
|
+
};
|
|
338
|
+
const comparisonPrompt = `Headline A: "${headlineA}"
|
|
339
|
+
Total: ${scoreA.total_score}/100 | Rule of One: ${fA.rule_of_one.score}/30 | Value Equation: ${fA.value_equation.score}/30 | Readability: ${fA.readability.score}/20 | Proof+Promise+Plan: ${fA.proof_promise_plan.score}/20
|
|
340
|
+
|
|
341
|
+
Headline B: "${headlineB}"
|
|
342
|
+
Total: ${scoreB.total_score}/100 | Rule of One: ${fB.rule_of_one.score}/30 | Value Equation: ${fB.value_equation.score}/30 | Readability: ${fB.readability.score}/20 | Proof+Promise+Plan: ${fB.proof_promise_plan.score}/20
|
|
343
|
+
|
|
344
|
+
Overall winner: Headline ${computedWinner}${computedWinner !== 'tie' ? ` by ${computedMargin} points` : ' (tied)'}
|
|
345
|
+
Framework winners: Rule of One → ${computedFwWinners.rule_of_one} | Value Equation → ${computedFwWinners.value_equation} | Readability → ${computedFwWinners.readability} | Proof+Promise+Plan → ${computedFwWinners.proof_promise_plan}
|
|
346
|
+
|
|
347
|
+
Write the verdict and suggested hybrid.`;
|
|
348
|
+
const rawComp = await askClaude(comparisonPrompt, {
|
|
349
|
+
systemPrompt: compareSystemPrompt,
|
|
350
|
+
model: 'haiku',
|
|
351
|
+
});
|
|
352
|
+
const compParsed = parseScore(rawComp);
|
|
353
|
+
const comparison = {
|
|
354
|
+
winner: computedWinner,
|
|
355
|
+
margin: computedMargin,
|
|
356
|
+
verdict: compParsed.verdict ?? '',
|
|
357
|
+
framework_winners: computedFwWinners,
|
|
358
|
+
suggested_hybrid: compParsed.suggested_hybrid ?? '',
|
|
359
|
+
};
|
|
360
|
+
const hgcNewCount = incrementUsage(hgcIpHash, HEADLINE_GRADER_ENDPOINT);
|
|
361
|
+
const hgcRemaining = Math.max(0, hgcRate.limit - hgcNewCount);
|
|
362
|
+
return { headlineA: scoreA, headlineB: scoreB, comparison, usage: { remaining: hgcRemaining, limit: hgcRate.limit, isPro: hgcRate.isPro, gated: false } };
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
console.error('headline_grader_compare', err);
|
|
366
|
+
reply.status(500);
|
|
367
|
+
return { error: `Comparison failed: ${err.message}` };
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
// ── Page Roast ──────────────────────────────────────
|
|
371
|
+
app.post('/api/demos/page-roast', async (req, reply) => {
|
|
372
|
+
const body = req.body;
|
|
373
|
+
const rawUrl = (body?.url ?? '').trim();
|
|
374
|
+
if (!rawUrl) {
|
|
375
|
+
reply.status(400);
|
|
376
|
+
return { error: 'URL is required.' };
|
|
377
|
+
}
|
|
378
|
+
let parsedUrl;
|
|
379
|
+
try {
|
|
380
|
+
parsedUrl = new URL(rawUrl.startsWith('http') ? rawUrl : `https://${rawUrl}`);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
reply.status(400);
|
|
384
|
+
return { error: 'Invalid URL.' };
|
|
385
|
+
}
|
|
386
|
+
const _prIpHash = hashIp(req.ip);
|
|
387
|
+
const _prEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
388
|
+
const _prRate = await checkRateLimit(_prIpHash, 'page-roast', _prEmail);
|
|
389
|
+
if (!_prRate.allowed) {
|
|
390
|
+
return {
|
|
391
|
+
gated: true,
|
|
392
|
+
isPro: _prRate.isPro,
|
|
393
|
+
remaining: 0,
|
|
394
|
+
limit: _prRate.limit,
|
|
395
|
+
message: _prRate.isPro ? proGateMsg() : freeGateMsg('Get 100 analyses/day with Pro'),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
let pageText;
|
|
399
|
+
try {
|
|
400
|
+
const html = await new Promise((resolve, reject) => {
|
|
401
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
402
|
+
const req = protocol.get(parsedUrl.toString(), {
|
|
403
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PageRoast/1.0)' },
|
|
404
|
+
rejectUnauthorized: false,
|
|
405
|
+
timeout: 15000,
|
|
406
|
+
}, (res) => {
|
|
407
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
408
|
+
const redirectUrl = new URL(res.headers.location, parsedUrl.toString());
|
|
409
|
+
const rProtocol = redirectUrl.protocol === 'https:' ? https : http;
|
|
410
|
+
const rReq = rProtocol.get(redirectUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PageRoast/1.0)' }, rejectUnauthorized: false }, (rRes) => {
|
|
411
|
+
let data = '';
|
|
412
|
+
rRes.on('data', (c) => data += c.toString());
|
|
413
|
+
rRes.on('end', () => resolve(data));
|
|
414
|
+
});
|
|
415
|
+
rReq.on('error', reject);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (!res.statusCode || res.statusCode >= 400) {
|
|
419
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
let data = '';
|
|
423
|
+
res.on('data', (c) => data += c.toString());
|
|
424
|
+
res.on('end', () => resolve(data));
|
|
425
|
+
});
|
|
426
|
+
req.on('error', reject);
|
|
427
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
428
|
+
});
|
|
429
|
+
pageText = html
|
|
430
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
431
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
432
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
433
|
+
.replace(/<[^>]+>/g, ' ')
|
|
434
|
+
.replace(/\s+/g, ' ')
|
|
435
|
+
.trim()
|
|
436
|
+
.slice(0, 8000);
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
reply.status(400);
|
|
440
|
+
return { error: `Could not fetch page: ${err.message}` };
|
|
441
|
+
}
|
|
442
|
+
if (pageText.length < 50) {
|
|
443
|
+
reply.status(400);
|
|
444
|
+
return { error: 'Page has too little text content to audit.' };
|
|
445
|
+
}
|
|
446
|
+
const systemPrompt = `You are a brutally honest landing page conversion expert. You audit pages using four proven CRO frameworks.
|
|
447
|
+
|
|
448
|
+
SCORING SYSTEM (total 100 points):
|
|
449
|
+
|
|
450
|
+
**Section 1: Hero Section (25 points)**
|
|
451
|
+
- Headline clarity — does it communicate the value prop in under 8 words? (0–8)
|
|
452
|
+
- Subheadline — does it expand on the promise with specifics? (0–7)
|
|
453
|
+
- CTA visibility — is there a clear, contrasting call-to-action above the fold? (0–5)
|
|
454
|
+
- Visual hierarchy — does the eye flow naturally headline → subhead → CTA? (0–5)
|
|
455
|
+
|
|
456
|
+
**Section 2: Social Proof (25 points)**
|
|
457
|
+
- Testimonials present with names/photos/companies? (0–8)
|
|
458
|
+
- Trust logos — recognizable brands, press mentions, certifications? (0–7)
|
|
459
|
+
- Quantified proof — user counts, revenue numbers, success metrics? (0–5)
|
|
460
|
+
- Risk reversal — guarantees, free trials, money-back signals? (0–5)
|
|
461
|
+
|
|
462
|
+
**Section 3: Clarity & Persuasion (25 points)**
|
|
463
|
+
- 5-second test — can a visitor understand what this is and who it's for in 5 seconds? (0–8)
|
|
464
|
+
- Benefits over features — does it lead with outcomes, not capabilities? (0–7)
|
|
465
|
+
- Readability — short sentences, simple words, scannable formatting? (0–5)
|
|
466
|
+
- Objection handling — are common doubts addressed before the CTA? (0–5)
|
|
467
|
+
|
|
468
|
+
**Section 4: Conversion Architecture (25 points)**
|
|
469
|
+
- CTA frequency — multiple CTAs without being pushy? (0–7)
|
|
470
|
+
- Friction reduction — minimal form fields, clear next step? (0–7)
|
|
471
|
+
- Urgency/scarcity — legitimate time or supply constraints? (0–5)
|
|
472
|
+
- Page speed/load signals — bloated content, excessive scripts mentioned? (0–6)
|
|
473
|
+
|
|
474
|
+
GRADING SCALE:
|
|
475
|
+
90–100: A+ | 85–89: A | 80–84: A- | 75–79: B+ | 70–74: B | 65–69: B- | 60–64: C+ | 55–59: C | 50–54: C- | 40–49: D | 0–39: F
|
|
476
|
+
|
|
477
|
+
Respond ONLY with valid JSON matching this exact schema — no markdown, no extra text:
|
|
478
|
+
{
|
|
479
|
+
"total_score": <number 0-100>,
|
|
480
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">,
|
|
481
|
+
"section_scores": {
|
|
482
|
+
"hero": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>", "fixes": ["<specific fix>"] },
|
|
483
|
+
"social_proof": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>", "fixes": ["<specific fix>"] },
|
|
484
|
+
"clarity": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>", "fixes": ["<specific fix>"] },
|
|
485
|
+
"conversion": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>", "fixes": ["<specific fix>"] }
|
|
486
|
+
},
|
|
487
|
+
"roast": "<One savage but constructive sentence roasting the biggest flaw — make it memorable>",
|
|
488
|
+
"top_fixes": [
|
|
489
|
+
"<Highest-impact fix with specific instructions>",
|
|
490
|
+
"<Second-highest fix>",
|
|
491
|
+
"<Third fix>"
|
|
492
|
+
],
|
|
493
|
+
"competitor_edge": "<One sentence about what a full audit with rewritten copy would unlock>"
|
|
494
|
+
}`;
|
|
495
|
+
try {
|
|
496
|
+
const raw = await askClaude(`Audit this landing page (URL: ${parsedUrl.toString()}).\n\nPage content:\n${pageText}`, {
|
|
497
|
+
systemPrompt,
|
|
498
|
+
model: 'haiku',
|
|
499
|
+
});
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(raw);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
506
|
+
if (!jsonMatch)
|
|
507
|
+
throw new Error('Could not parse audit response.');
|
|
508
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
509
|
+
}
|
|
510
|
+
const _prNewCount = incrementUsage(_prIpHash, 'page-roast');
|
|
511
|
+
const _prRemaining = Math.max(0, _prRate.limit - _prNewCount);
|
|
512
|
+
return { ...parsed, usage: { remaining: _prRemaining, limit: _prRate.limit, isPro: _prRate.isPro, gated: false } };
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
console.error('page_roast_demo', err);
|
|
516
|
+
reply.status(500);
|
|
517
|
+
return { error: `Roast failed: ${err.message}` };
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
app.post('/api/demos/page-roast/compare', async (req, reply) => {
|
|
521
|
+
const body = req.body;
|
|
522
|
+
const rawUrlA = (body?.url_a ?? '').trim();
|
|
523
|
+
const rawUrlB = (body?.url_b ?? '').trim();
|
|
524
|
+
if (!rawUrlA || !rawUrlB) {
|
|
525
|
+
reply.status(400);
|
|
526
|
+
return { error: 'Both URLs are required.' };
|
|
527
|
+
}
|
|
528
|
+
const prcIpHash = hashIp(req.ip);
|
|
529
|
+
const prcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
530
|
+
const prcRate = await checkRateLimit(prcIpHash, 'page-roast', prcEmail);
|
|
531
|
+
if (!prcRate.allowed) {
|
|
532
|
+
return {
|
|
533
|
+
gated: true,
|
|
534
|
+
isPro: prcRate.isPro,
|
|
535
|
+
remaining: 0,
|
|
536
|
+
limit: prcRate.limit,
|
|
537
|
+
message: prcRate.isPro ? proGateMsg() : freeGateMsg('Get 100 roasts/day with Pro'),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function parseUrl(raw) {
|
|
541
|
+
return new URL(raw.startsWith('http') ? raw : `https://${raw}`);
|
|
542
|
+
}
|
|
543
|
+
let parsedUrlA, parsedUrlB;
|
|
544
|
+
try {
|
|
545
|
+
parsedUrlA = parseUrl(rawUrlA);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
reply.status(400);
|
|
549
|
+
return { error: 'Invalid URL A.' };
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
parsedUrlB = parseUrl(rawUrlB);
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
reply.status(400);
|
|
556
|
+
return { error: 'Invalid URL B.' };
|
|
557
|
+
}
|
|
558
|
+
async function fetchPage(parsedUrl) {
|
|
559
|
+
const html = await new Promise((resolve, reject) => {
|
|
560
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
|
561
|
+
const req = protocol.get(parsedUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PageRoast/1.0)' }, rejectUnauthorized: false, timeout: 15000 }, (res) => {
|
|
562
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
563
|
+
const redirectUrl = new URL(res.headers.location, parsedUrl.toString());
|
|
564
|
+
const rProtocol = redirectUrl.protocol === 'https:' ? https : http;
|
|
565
|
+
const rReq = rProtocol.get(redirectUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PageRoast/1.0)' }, rejectUnauthorized: false }, (rRes) => {
|
|
566
|
+
let data = '';
|
|
567
|
+
rRes.on('data', (c) => data += c.toString());
|
|
568
|
+
rRes.on('end', () => resolve(data));
|
|
569
|
+
});
|
|
570
|
+
rReq.on('error', reject);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (!res.statusCode || res.statusCode >= 400) {
|
|
574
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
let data = '';
|
|
578
|
+
res.on('data', (c) => data += c.toString());
|
|
579
|
+
res.on('end', () => resolve(data));
|
|
580
|
+
});
|
|
581
|
+
req.on('error', reject);
|
|
582
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
583
|
+
});
|
|
584
|
+
return html
|
|
585
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
586
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
587
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
588
|
+
.replace(/<[^>]+>/g, ' ')
|
|
589
|
+
.replace(/\s+/g, ' ')
|
|
590
|
+
.trim()
|
|
591
|
+
.slice(0, 8000);
|
|
592
|
+
}
|
|
593
|
+
let pageTextA, pageTextB;
|
|
594
|
+
try {
|
|
595
|
+
[pageTextA, pageTextB] = await Promise.all([fetchPage(parsedUrlA), fetchPage(parsedUrlB)]);
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
reply.status(400);
|
|
599
|
+
return { error: `Could not fetch pages: ${err.message}` };
|
|
600
|
+
}
|
|
601
|
+
if (pageTextA.length < 50) {
|
|
602
|
+
reply.status(400);
|
|
603
|
+
return { error: 'Page A has too little text content to audit.' };
|
|
604
|
+
}
|
|
605
|
+
if (pageTextB.length < 50) {
|
|
606
|
+
reply.status(400);
|
|
607
|
+
return { error: 'Page B has too little text content to audit.' };
|
|
608
|
+
}
|
|
609
|
+
const scoringSystemPrompt = `You are a brutally honest landing page conversion expert. You audit pages using four proven CRO frameworks.
|
|
610
|
+
|
|
611
|
+
SCORING SYSTEM (total 100 points):
|
|
612
|
+
|
|
613
|
+
**Section 1: Hero Section (25 points)**
|
|
614
|
+
- Headline clarity — does it communicate the value prop in under 8 words? (0–8)
|
|
615
|
+
- Subheadline — does it expand on the promise with specifics? (0–7)
|
|
616
|
+
- CTA visibility — is there a clear, contrasting call-to-action above the fold? (0–5)
|
|
617
|
+
- Visual hierarchy — does the eye flow naturally headline → subhead → CTA? (0–5)
|
|
618
|
+
|
|
619
|
+
**Section 2: Social Proof (25 points)**
|
|
620
|
+
- Testimonials present with names/photos/companies? (0–8)
|
|
621
|
+
- Trust logos — recognizable brands, press mentions, certifications? (0–7)
|
|
622
|
+
- Quantified proof — user counts, revenue numbers, success metrics? (0–5)
|
|
623
|
+
- Risk reversal — guarantees, free trials, money-back signals? (0–5)
|
|
624
|
+
|
|
625
|
+
**Section 3: Clarity & Persuasion (25 points)**
|
|
626
|
+
- 5-second test — can a visitor understand what this is and who it's for in 5 seconds? (0–8)
|
|
627
|
+
- Benefits over features — does it lead with outcomes, not capabilities? (0–7)
|
|
628
|
+
- Readability — short sentences, simple words, scannable formatting? (0–5)
|
|
629
|
+
- Objection handling — are common doubts addressed before the CTA? (0–5)
|
|
630
|
+
|
|
631
|
+
**Section 4: Conversion Architecture (25 points)**
|
|
632
|
+
- CTA frequency — multiple CTAs without being pushy? (0–7)
|
|
633
|
+
- Friction reduction — minimal form fields, clear next step? (0–7)
|
|
634
|
+
- Urgency/scarcity — legitimate time or supply constraints? (0–5)
|
|
635
|
+
- Page speed/load signals — bloated content, excessive scripts mentioned? (0–6)
|
|
636
|
+
|
|
637
|
+
GRADING SCALE:
|
|
638
|
+
90–100: A+ | 85–89: A | 80–84: A- | 75–79: B+ | 70–74: B | 65–69: B- | 60–64: C+ | 55–59: C | 50–54: C- | 40–49: D | 0–39: F
|
|
639
|
+
|
|
640
|
+
Respond ONLY with valid JSON matching this exact schema — no markdown, no extra text:
|
|
641
|
+
{
|
|
642
|
+
"total_score": <number 0-100>,
|
|
643
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">,
|
|
644
|
+
"section_scores": {
|
|
645
|
+
"hero": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>", "fixes": ["<specific fix>"] },
|
|
646
|
+
"social_proof": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>", "fixes": ["<specific fix>"] },
|
|
647
|
+
"clarity": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>", "fixes": ["<specific fix>"] },
|
|
648
|
+
"conversion": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>", "fixes": ["<specific fix>"] }
|
|
649
|
+
},
|
|
650
|
+
"roast": "<One savage but constructive sentence roasting the biggest flaw — make it memorable>",
|
|
651
|
+
"top_fixes": ["<Highest-impact fix>", "<Second fix>", "<Third fix>"],
|
|
652
|
+
"competitor_edge": "<One sentence about what a full audit would unlock>"
|
|
653
|
+
}`;
|
|
654
|
+
const compareSystemPrompt = `You are a world-class landing page conversion expert. You will be given two scored landing pages with their section breakdowns. Your job is to determine which page converts better and why.
|
|
655
|
+
|
|
656
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
657
|
+
{
|
|
658
|
+
"verdict": "<2-3 sentences citing specific section score gaps — name the sections and numbers, explain WHY the winner converts better>",
|
|
659
|
+
"analysis": "<3-4 sentences of deeper strategic analysis: what each page does well, what the loser could steal from the winner>"
|
|
660
|
+
}`;
|
|
661
|
+
function parseScore(raw) {
|
|
662
|
+
try {
|
|
663
|
+
return JSON.parse(raw);
|
|
664
|
+
}
|
|
665
|
+
catch { }
|
|
666
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
667
|
+
if (!m)
|
|
668
|
+
throw new Error('Could not parse scoring response.');
|
|
669
|
+
return JSON.parse(m[0]);
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
const [rawA, rawB] = await Promise.all([
|
|
673
|
+
askClaude(`Audit this landing page (URL: ${parsedUrlA.toString()}).\n\nPage content:\n${pageTextA}`, {
|
|
674
|
+
systemPrompt: scoringSystemPrompt,
|
|
675
|
+
model: 'haiku',
|
|
676
|
+
}),
|
|
677
|
+
askClaude(`Audit this landing page (URL: ${parsedUrlB.toString()}).\n\nPage content:\n${pageTextB}`, {
|
|
678
|
+
systemPrompt: scoringSystemPrompt,
|
|
679
|
+
model: 'haiku',
|
|
680
|
+
}),
|
|
681
|
+
]);
|
|
682
|
+
const scoreA = parseScore(rawA);
|
|
683
|
+
const scoreB = parseScore(rawB);
|
|
684
|
+
const sA = scoreA.section_scores;
|
|
685
|
+
const sB = scoreB.section_scores;
|
|
686
|
+
const computedMargin = Math.abs(scoreA.total_score - scoreB.total_score);
|
|
687
|
+
const computedWinner = scoreA.total_score > scoreB.total_score ? 'A' :
|
|
688
|
+
scoreB.total_score > scoreA.total_score ? 'B' : 'tie';
|
|
689
|
+
const sWin = (a, b) => a > b ? 'A' : b > a ? 'B' : 'tie';
|
|
690
|
+
const computedSectionWinners = {
|
|
691
|
+
hero: sWin(sA.hero.score, sB.hero.score),
|
|
692
|
+
social_proof: sWin(sA.social_proof.score, sB.social_proof.score),
|
|
693
|
+
clarity: sWin(sA.clarity.score, sB.clarity.score),
|
|
694
|
+
conversion: sWin(sA.conversion.score, sB.conversion.score),
|
|
695
|
+
};
|
|
696
|
+
const comparisonPrompt = `Page A: ${parsedUrlA.toString()}
|
|
697
|
+
Total: ${scoreA.total_score}/100 | Hero: ${sA.hero.score}/25 | Social Proof: ${sA.social_proof.score}/25 | Clarity: ${sA.clarity.score}/25 | Conversion: ${sA.conversion.score}/25
|
|
698
|
+
|
|
699
|
+
Page B: ${parsedUrlB.toString()}
|
|
700
|
+
Total: ${scoreB.total_score}/100 | Hero: ${sB.hero.score}/25 | Social Proof: ${sB.social_proof.score}/25 | Clarity: ${sB.clarity.score}/25 | Conversion: ${sB.conversion.score}/25
|
|
701
|
+
|
|
702
|
+
Overall winner: Page ${computedWinner}${computedWinner !== 'tie' ? ` by ${computedMargin} points` : ' (tied)'}
|
|
703
|
+
Section winners: Hero → ${computedSectionWinners.hero} | Social Proof → ${computedSectionWinners.social_proof} | Clarity → ${computedSectionWinners.clarity} | Conversion → ${computedSectionWinners.conversion}
|
|
704
|
+
|
|
705
|
+
Write the verdict and analysis.`;
|
|
706
|
+
const rawComp = await askClaude(comparisonPrompt, {
|
|
707
|
+
systemPrompt: compareSystemPrompt,
|
|
708
|
+
model: 'haiku',
|
|
709
|
+
});
|
|
710
|
+
const compParsed = parseScore(rawComp);
|
|
711
|
+
const comparison = {
|
|
712
|
+
winner: computedWinner,
|
|
713
|
+
margin: computedMargin,
|
|
714
|
+
section_winners: computedSectionWinners,
|
|
715
|
+
analysis: compParsed.analysis ?? '',
|
|
716
|
+
verdict: compParsed.verdict ?? '',
|
|
717
|
+
};
|
|
718
|
+
const prcNewCount = incrementUsage(prcIpHash, 'page-roast');
|
|
719
|
+
const prcRemaining = Math.max(0, prcRate.limit - prcNewCount);
|
|
720
|
+
return { score_a: scoreA, score_b: scoreB, comparison, usage: { remaining: prcRemaining, limit: prcRate.limit, isPro: prcRate.isPro, gated: false } };
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
console.error('page_roast_compare', err);
|
|
724
|
+
reply.status(500);
|
|
725
|
+
return { error: `Comparison failed: ${err.message}` };
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
// ── Ad Scorer ──────────────────────────────────────
|
|
729
|
+
app.post('/api/demos/ad-scorer', async (req, reply) => {
|
|
730
|
+
const body = req.body;
|
|
731
|
+
const adCopy = (body?.adCopy ?? '').trim();
|
|
732
|
+
const platform = (body?.platform ?? 'facebook').toLowerCase();
|
|
733
|
+
if (!adCopy || adCopy.length < 10) {
|
|
734
|
+
reply.status(400);
|
|
735
|
+
return { error: 'Ad copy must be at least 10 characters.' };
|
|
736
|
+
}
|
|
737
|
+
if (adCopy.length > 2000) {
|
|
738
|
+
reply.status(400);
|
|
739
|
+
return { error: 'Ad copy must be under 2000 characters.' };
|
|
740
|
+
}
|
|
741
|
+
const _asIpHash = hashIp(req.ip);
|
|
742
|
+
const _asEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
743
|
+
const _asRate = await checkRateLimit(_asIpHash, 'ad-scorer', _asEmail);
|
|
744
|
+
if (!_asRate.allowed) {
|
|
745
|
+
return {
|
|
746
|
+
gated: true,
|
|
747
|
+
isPro: _asRate.isPro,
|
|
748
|
+
remaining: 0,
|
|
749
|
+
limit: _asRate.limit,
|
|
750
|
+
message: _asRate.isPro ? proGateMsg() : freeGateMsg('Get 100 analyses/day with Pro'),
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
const systemPrompt = `You are a world-class performance ad copywriting analyst. You evaluate ad copy for paid platforms (Facebook, Google, LinkedIn) using proven direct response frameworks.
|
|
754
|
+
|
|
755
|
+
SCORING SYSTEM (total 100 points):
|
|
756
|
+
|
|
757
|
+
**Pillar 1: Hook Strength (25 points)**
|
|
758
|
+
- Pattern interrupt — does the opening line break the scroll? (0–8)
|
|
759
|
+
- Curiosity gap — does it create an open loop the reader must close? (0–7)
|
|
760
|
+
- Audience callout — does it immediately qualify who this is for? (0–5)
|
|
761
|
+
- First-line readability — under 12 words, punchy, no jargon? (0–5)
|
|
762
|
+
|
|
763
|
+
**Pillar 2: Value Proposition (25 points)**
|
|
764
|
+
- Specificity — concrete numbers, timeframes, outcomes vs vague promises? (0–8)
|
|
765
|
+
- Hormozi Value Equation — dream outcome high, perceived likelihood high, time delay low, effort/sacrifice low? (0–7)
|
|
766
|
+
- Differentiation — what makes this different from every other ad? (0–5)
|
|
767
|
+
- Proof element — testimonial, case study, credential, or social proof embedded? (0–5)
|
|
768
|
+
|
|
769
|
+
**Pillar 3: Emotional Architecture (25 points)**
|
|
770
|
+
- Pain/desire identification — does it name a specific pain or desire the reader recognizes? (0–8)
|
|
771
|
+
- Story element — micro-narrative, transformation arc, or before/after? (0–7)
|
|
772
|
+
- Tone match — does the voice match the target audience (formal for B2B, casual for D2C, etc)? (0–5)
|
|
773
|
+
- Objection handling — does it preempt the top reason someone would scroll past? (0–5)
|
|
774
|
+
|
|
775
|
+
**Pillar 4: CTA & Conversion (25 points)**
|
|
776
|
+
- CTA clarity — one clear action, no ambiguity about what happens next? (0–8)
|
|
777
|
+
- Urgency/scarcity — legitimate reason to act now? (0–7)
|
|
778
|
+
- Risk reversal — free trial, guarantee, or low-commitment entry point? (0–5)
|
|
779
|
+
- Platform compliance — no flagged terms, follows ${platform} ad policies? (0–5)
|
|
780
|
+
|
|
781
|
+
GRADING SCALE:
|
|
782
|
+
90–100: A+ | 85–89: A | 80–84: A- | 75–79: B+ | 70–74: B | 65–69: B- | 60–64: C+ | 55–59: C | 50–54: C- | 40–49: D | 0–39: F
|
|
783
|
+
|
|
784
|
+
Respond ONLY with valid JSON matching this exact schema — no markdown, no extra text:
|
|
785
|
+
{
|
|
786
|
+
"total_score": <number 0-100>,
|
|
787
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">,
|
|
788
|
+
"pillar_scores": {
|
|
789
|
+
"hook": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" },
|
|
790
|
+
"value_prop": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" },
|
|
791
|
+
"emotional": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" },
|
|
792
|
+
"cta_conversion": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" }
|
|
793
|
+
},
|
|
794
|
+
"verdict": "<One sharp sentence: the single biggest weakness or strength>",
|
|
795
|
+
"rewrites": [
|
|
796
|
+
{ "text": "<rewritten ad copy — full replacement, not just the hook>", "predicted_score": <number>, "optimized_for": "hook" },
|
|
797
|
+
{ "text": "<rewritten ad copy>", "predicted_score": <number>, "optimized_for": "value_prop" },
|
|
798
|
+
{ "text": "<rewritten ad copy>", "predicted_score": <number>, "optimized_for": "emotional" }
|
|
799
|
+
],
|
|
800
|
+
"upgrade_hook": "<One sentence teasing what a full ad creative audit (copy + visuals + targeting) would reveal>"
|
|
801
|
+
}`;
|
|
802
|
+
try {
|
|
803
|
+
const raw = await askClaude(`Score this ${platform} ad copy:\n\n"${adCopy}"`, {
|
|
804
|
+
systemPrompt,
|
|
805
|
+
model: 'haiku',
|
|
806
|
+
});
|
|
807
|
+
let parsed;
|
|
808
|
+
try {
|
|
809
|
+
parsed = JSON.parse(raw);
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
813
|
+
if (!jsonMatch)
|
|
814
|
+
throw new Error('Could not parse scoring response.');
|
|
815
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
816
|
+
}
|
|
817
|
+
const _asNewCount = incrementUsage(_asIpHash, 'ad-scorer');
|
|
818
|
+
const _asRemaining = Math.max(0, _asRate.limit - _asNewCount);
|
|
819
|
+
return { ...parsed, usage: { remaining: _asRemaining, limit: _asRate.limit, isPro: _asRate.isPro, gated: false } };
|
|
820
|
+
}
|
|
821
|
+
catch (err) {
|
|
822
|
+
console.error('ad_scorer_demo', err);
|
|
823
|
+
reply.status(500);
|
|
824
|
+
return { error: `Scoring failed: ${err.message}` };
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
app.post('/api/demos/ad-scorer/compare', async (req, reply) => {
|
|
828
|
+
const body = req.body;
|
|
829
|
+
const adCopyA = (body?.adCopyA ?? '').trim();
|
|
830
|
+
const adCopyB = (body?.adCopyB ?? '').trim();
|
|
831
|
+
const platform = (body?.platform ?? 'facebook').toLowerCase();
|
|
832
|
+
if (!adCopyA || adCopyA.length < 10 || !adCopyB || adCopyB.length < 10) {
|
|
833
|
+
reply.status(400);
|
|
834
|
+
return { error: 'Both ad copies must be at least 10 characters.' };
|
|
835
|
+
}
|
|
836
|
+
if (adCopyA.length > 2000 || adCopyB.length > 2000) {
|
|
837
|
+
reply.status(400);
|
|
838
|
+
return { error: 'Ad copies must be under 2000 characters each.' };
|
|
839
|
+
}
|
|
840
|
+
const ascIpHash = hashIp(req.ip);
|
|
841
|
+
const ascEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
842
|
+
const ascRate = await checkRateLimit(ascIpHash, 'ad-scorer', ascEmail);
|
|
843
|
+
if (!ascRate.allowed) {
|
|
844
|
+
return {
|
|
845
|
+
gated: true,
|
|
846
|
+
isPro: ascRate.isPro,
|
|
847
|
+
remaining: 0,
|
|
848
|
+
limit: ascRate.limit,
|
|
849
|
+
message: ascRate.isPro ? proGateMsg() : freeGateMsg('Get 100 scores/day with Pro'),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const scoringSystemPrompt = `You are a world-class performance ad copywriting analyst. You evaluate ad copy for paid platforms (Facebook, Google, LinkedIn) using proven direct response frameworks.
|
|
853
|
+
|
|
854
|
+
SCORING SYSTEM (total 100 points):
|
|
855
|
+
|
|
856
|
+
**Pillar 1: Hook Strength (25 points)**
|
|
857
|
+
- Pattern interrupt — does the opening line break the scroll? (0–8)
|
|
858
|
+
- Curiosity gap — does it create an open loop the reader must close? (0–7)
|
|
859
|
+
- Audience callout — does it immediately qualify who this is for? (0–5)
|
|
860
|
+
- First-line readability — under 12 words, punchy, no jargon? (0–5)
|
|
861
|
+
|
|
862
|
+
**Pillar 2: Value Proposition (25 points)**
|
|
863
|
+
- Specificity — concrete numbers, timeframes, outcomes vs vague promises? (0–8)
|
|
864
|
+
- Hormozi Value Equation — dream outcome high, perceived likelihood high, time delay low, effort/sacrifice low? (0–7)
|
|
865
|
+
- Differentiation — what makes this different from every other ad? (0–5)
|
|
866
|
+
- Proof element — testimonial, case study, credential, or social proof embedded? (0–5)
|
|
867
|
+
|
|
868
|
+
**Pillar 3: Emotional Architecture (25 points)**
|
|
869
|
+
- Pain/desire identification — does it name a specific pain or desire the reader recognizes? (0–8)
|
|
870
|
+
- Story element — micro-narrative, transformation arc, or before/after? (0–7)
|
|
871
|
+
- Tone match — does the voice match the target audience (formal for B2B, casual for D2C, etc)? (0–5)
|
|
872
|
+
- Objection handling — does it preempt the top reason someone would scroll past? (0–5)
|
|
873
|
+
|
|
874
|
+
**Pillar 4: CTA & Conversion (25 points)**
|
|
875
|
+
- CTA clarity — one clear action, no ambiguity about what happens next? (0–8)
|
|
876
|
+
- Urgency/scarcity — legitimate reason to act now? (0–7)
|
|
877
|
+
- Risk reversal — free trial, guarantee, or low-commitment entry point? (0–5)
|
|
878
|
+
- Platform compliance — no flagged terms, follows ${platform} ad policies? (0–5)
|
|
879
|
+
|
|
880
|
+
GRADING SCALE:
|
|
881
|
+
90–100: A+ | 85–89: A | 80–84: A- | 75–79: B+ | 70–74: B | 65–69: B- | 60–64: C+ | 55–59: C | 50–54: C- | 40–49: D | 0–39: F
|
|
882
|
+
|
|
883
|
+
Respond ONLY with valid JSON matching this exact schema — no markdown, no extra text:
|
|
884
|
+
{
|
|
885
|
+
"total_score": <number 0-100>,
|
|
886
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">,
|
|
887
|
+
"pillar_scores": {
|
|
888
|
+
"hook": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" },
|
|
889
|
+
"value_prop": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" },
|
|
890
|
+
"emotional": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" },
|
|
891
|
+
"cta_conversion": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" }
|
|
892
|
+
},
|
|
893
|
+
"verdict": "<One sharp sentence: the single biggest weakness or strength>"
|
|
894
|
+
}`;
|
|
895
|
+
const compareSystemPrompt = `You are a world-class direct response ad copywriting analyst. You will be given two scored ad copies with their pillar breakdowns. Your job is to:
|
|
896
|
+
1. Write a sharp 2-sentence verdict explaining WHY the winner is stronger — reference the specific pillar scores and gaps (e.g. "Copy A's Hook score (22/25 vs 10/25) shows a pattern interrupt that stops the scroll...")
|
|
897
|
+
2. Write a suggested hybrid ad copy that steals the best element from each — be specific about what was borrowed from each
|
|
898
|
+
3. Write a one-sentence strategic analysis: what this result means for the advertiser's next test
|
|
899
|
+
|
|
900
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
901
|
+
{
|
|
902
|
+
"verdict": "<2 sentences citing specific pillar score gaps — name the pillars and numbers>",
|
|
903
|
+
"suggested_hybrid": "<full rewritten ad copy combining best elements> | <one sentence: what was taken from A and what was taken from B>",
|
|
904
|
+
"strategic_analysis": "<one sentence tactical recommendation for the next iteration>"
|
|
905
|
+
}`;
|
|
906
|
+
function parseScore(raw) {
|
|
907
|
+
try {
|
|
908
|
+
return JSON.parse(raw);
|
|
909
|
+
}
|
|
910
|
+
catch { }
|
|
911
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
912
|
+
if (!m)
|
|
913
|
+
throw new Error('Could not parse scoring response.');
|
|
914
|
+
return JSON.parse(m[0]);
|
|
915
|
+
}
|
|
916
|
+
try {
|
|
917
|
+
const [rawA, rawB] = await Promise.all([
|
|
918
|
+
askClaude(`Score this ${platform} ad copy:\n\n"${adCopyA}"`, {
|
|
919
|
+
systemPrompt: scoringSystemPrompt,
|
|
920
|
+
model: 'haiku',
|
|
921
|
+
}),
|
|
922
|
+
askClaude(`Score this ${platform} ad copy:\n\n"${adCopyB}"`, {
|
|
923
|
+
systemPrompt: scoringSystemPrompt,
|
|
924
|
+
model: 'haiku',
|
|
925
|
+
}),
|
|
926
|
+
]);
|
|
927
|
+
const scoreA = parseScore(rawA);
|
|
928
|
+
const scoreB = parseScore(rawB);
|
|
929
|
+
const pA = scoreA.pillar_scores;
|
|
930
|
+
const pB = scoreB.pillar_scores;
|
|
931
|
+
const computedMargin = Math.abs(scoreA.total_score - scoreB.total_score);
|
|
932
|
+
const computedWinner = scoreA.total_score > scoreB.total_score ? 'A' :
|
|
933
|
+
scoreB.total_score > scoreA.total_score ? 'B' : 'tie';
|
|
934
|
+
const pillarWin = (a, b) => a > b ? 'A' : b > a ? 'B' : 'tie';
|
|
935
|
+
const computedPillarWinners = {
|
|
936
|
+
hook: pillarWin(pA.hook.score, pB.hook.score),
|
|
937
|
+
value_prop: pillarWin(pA.value_prop.score, pB.value_prop.score),
|
|
938
|
+
emotional: pillarWin(pA.emotional.score, pB.emotional.score),
|
|
939
|
+
cta_conversion: pillarWin(pA.cta_conversion.score, pB.cta_conversion.score),
|
|
940
|
+
};
|
|
941
|
+
const comparisonPrompt = `Ad Copy A: "${adCopyA}"
|
|
942
|
+
Total: ${scoreA.total_score}/100 | Hook: ${pA.hook.score}/25 | Value Prop: ${pA.value_prop.score}/25 | Emotional: ${pA.emotional.score}/25 | CTA: ${pA.cta_conversion.score}/25
|
|
943
|
+
|
|
944
|
+
Ad Copy B: "${adCopyB}"
|
|
945
|
+
Total: ${scoreB.total_score}/100 | Hook: ${pB.hook.score}/25 | Value Prop: ${pB.value_prop.score}/25 | Emotional: ${pB.emotional.score}/25 | CTA: ${pB.cta_conversion.score}/25
|
|
946
|
+
|
|
947
|
+
Overall winner: Copy ${computedWinner}${computedWinner !== 'tie' ? ` by ${computedMargin} points` : ' (tied)'}
|
|
948
|
+
Pillar winners: Hook → ${computedPillarWinners.hook} | Value Prop → ${computedPillarWinners.value_prop} | Emotional → ${computedPillarWinners.emotional} | CTA → ${computedPillarWinners.cta_conversion}
|
|
949
|
+
|
|
950
|
+
Write the verdict, suggested hybrid, and strategic analysis.`;
|
|
951
|
+
const rawComp = await askClaude(comparisonPrompt, {
|
|
952
|
+
systemPrompt: compareSystemPrompt,
|
|
953
|
+
model: 'haiku',
|
|
954
|
+
});
|
|
955
|
+
const compParsed = parseScore(rawComp);
|
|
956
|
+
const comparison = {
|
|
957
|
+
winner: computedWinner,
|
|
958
|
+
margin: computedMargin,
|
|
959
|
+
verdict: compParsed.verdict ?? '',
|
|
960
|
+
pillar_winners: computedPillarWinners,
|
|
961
|
+
suggested_hybrid: compParsed.suggested_hybrid ?? '',
|
|
962
|
+
strategic_analysis: compParsed.strategic_analysis ?? '',
|
|
963
|
+
};
|
|
964
|
+
const ascNewCount = incrementUsage(ascIpHash, 'ad-scorer');
|
|
965
|
+
const ascRemaining = Math.max(0, ascRate.limit - ascNewCount);
|
|
966
|
+
return { adCopyA: scoreA, adCopyB: scoreB, comparison, usage: { remaining: ascRemaining, limit: ascRate.limit, isPro: ascRate.isPro, gated: false } };
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
console.error('ad_scorer_compare', err);
|
|
970
|
+
reply.status(500);
|
|
971
|
+
return { error: `Comparison failed: ${err.message}` };
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
// ── Thread Grader ──────────────────────────────────────
|
|
975
|
+
app.post('/api/demos/thread-grader', async (req, reply) => {
|
|
976
|
+
const body = req.body;
|
|
977
|
+
const threadText = (body?.threadText ?? '').trim();
|
|
978
|
+
if (!threadText || threadText.length < 20) {
|
|
979
|
+
reply.status(400);
|
|
980
|
+
return { error: 'Thread must be at least 20 characters.' };
|
|
981
|
+
}
|
|
982
|
+
if (threadText.length > 5000) {
|
|
983
|
+
reply.status(400);
|
|
984
|
+
return { error: 'Thread must be under 5000 characters.' };
|
|
985
|
+
}
|
|
986
|
+
const _tgIpHash = hashIp(req.ip);
|
|
987
|
+
const _tgEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
988
|
+
const _tgRate = await checkRateLimit(_tgIpHash, 'thread-grader', _tgEmail);
|
|
989
|
+
if (!_tgRate.allowed) {
|
|
990
|
+
return {
|
|
991
|
+
gated: true,
|
|
992
|
+
isPro: _tgRate.isPro,
|
|
993
|
+
remaining: 0,
|
|
994
|
+
limit: _tgRate.limit,
|
|
995
|
+
message: _tgRate.isPro ? proGateMsg() : freeGateMsg('Get 100 analyses/day with Pro'),
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const systemPrompt = `You are a viral content analyst specializing in X/Twitter threads. Score this thread on 4 pillars:
|
|
999
|
+
|
|
1000
|
+
SCORING SYSTEM (total 100 points):
|
|
1001
|
+
|
|
1002
|
+
**Pillar 1: Hook Strength (30 points)**
|
|
1003
|
+
- Does tweet 1 stop the scroll? Curiosity gap, specificity, controversy, bold claim?
|
|
1004
|
+
- Does the opening create an open loop that demands resolution?
|
|
1005
|
+
- Is it specific enough to signal expertise, not generic enough to be ignored?
|
|
1006
|
+
|
|
1007
|
+
**Pillar 2: Tension Chain (25 points)**
|
|
1008
|
+
- Does each tweet pull you to the next? Does the reader NEED to keep reading or can they drop off?
|
|
1009
|
+
- Is there a logical escalation — setup → complication → revelation?
|
|
1010
|
+
- Does each tweet end with enough unresolved tension to demand the next?
|
|
1011
|
+
|
|
1012
|
+
**Pillar 3: Payoff (25 points)**
|
|
1013
|
+
- Does the thread deliver real value? Surprise, actionable insight, or earned conclusion?
|
|
1014
|
+
- Is the payoff proportional to the promise made in tweet 1?
|
|
1015
|
+
- Will the reader feel their time was well spent?
|
|
1016
|
+
|
|
1017
|
+
**Pillar 4: Share Trigger (20 points)**
|
|
1018
|
+
- Is there a moment worth screenshotting or quoting?
|
|
1019
|
+
- Quotable line, surprising stat, hot take, or counter-intuitive truth?
|
|
1020
|
+
- Does it make the reader look smart or insightful if they share it?
|
|
1021
|
+
|
|
1022
|
+
GRADING SCALE:
|
|
1023
|
+
90–100: A+ | 85–89: A | 80–84: A- | 75–79: B+ | 70–74: B | 65–69: B- | 60–64: C+ | 55–59: C | 50–54: C- | 40–49: D | 0–39: F
|
|
1024
|
+
|
|
1025
|
+
Tweets are separated by "---" or double newlines. Parse each tweet individually.
|
|
1026
|
+
|
|
1027
|
+
Respond ONLY with valid JSON matching this exact schema — no markdown, no extra text:
|
|
1028
|
+
{
|
|
1029
|
+
"total_score": <number 0-100>,
|
|
1030
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">,
|
|
1031
|
+
"pillar_scores": {
|
|
1032
|
+
"hook": { "score": <0-30>, "max": 30, "feedback": "<1-2 sentences specific to tweet 1>" },
|
|
1033
|
+
"tension": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences on the chain>" },
|
|
1034
|
+
"payoff": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences on the conclusion>" },
|
|
1035
|
+
"share_trigger": { "score": <0-20>, "max": 20, "feedback": "<1-2 sentences on shareability>" }
|
|
1036
|
+
},
|
|
1037
|
+
"tweet_breakdown": [
|
|
1038
|
+
{ "tweet_index": <number starting at 1>, "text_preview": "<first 60 chars>", "score": <0-10>, "note": "<one specific observation>" }
|
|
1039
|
+
],
|
|
1040
|
+
"rewrites": [
|
|
1041
|
+
{ "label": "Max curiosity gap", "text": "<rewritten hook tweet that stops the scroll>", "why_better": "<one sentence>" },
|
|
1042
|
+
{ "label": "Bold claim", "text": "<rewritten hook with a specific bold claim>", "why_better": "<one sentence>" },
|
|
1043
|
+
{ "label": "Contrast hook", "text": "<rewritten hook using unexpected contrast>", "why_better": "<one sentence>" }
|
|
1044
|
+
],
|
|
1045
|
+
"verdict": "<One sharp sentence: the single biggest strength or weakness of this thread>",
|
|
1046
|
+
"upgrade_hook": "<One sentence teasing what a full thread strategy audit (structure, timing, CTA, audience) would reveal>"
|
|
1047
|
+
}`;
|
|
1048
|
+
try {
|
|
1049
|
+
const raw = await askClaude(`Score this X/Twitter thread:\n\n${threadText}`, {
|
|
1050
|
+
systemPrompt,
|
|
1051
|
+
model: 'haiku',
|
|
1052
|
+
});
|
|
1053
|
+
let parsed;
|
|
1054
|
+
try {
|
|
1055
|
+
parsed = JSON.parse(raw);
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
1059
|
+
if (!jsonMatch)
|
|
1060
|
+
throw new Error('Could not parse scoring response.');
|
|
1061
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
1062
|
+
}
|
|
1063
|
+
const _tgNewCount = incrementUsage(_tgIpHash, 'thread-grader');
|
|
1064
|
+
const _tgRemaining = Math.max(0, _tgRate.limit - _tgNewCount);
|
|
1065
|
+
return { ...parsed, usage: { remaining: _tgRemaining, limit: _tgRate.limit, isPro: _tgRate.isPro, gated: false } };
|
|
1066
|
+
}
|
|
1067
|
+
catch (err) {
|
|
1068
|
+
console.error('thread_grader_demo', err);
|
|
1069
|
+
reply.status(500);
|
|
1070
|
+
return { error: `Scoring failed: ${err.message}` };
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
app.post('/api/demos/thread-grader/compare', async (req, reply) => {
|
|
1074
|
+
const body = req.body;
|
|
1075
|
+
const threadA = (body?.threadA ?? '').trim();
|
|
1076
|
+
const threadB = (body?.threadB ?? '').trim();
|
|
1077
|
+
if (!threadA || threadA.length < 20 || !threadB || threadB.length < 20) {
|
|
1078
|
+
reply.status(400);
|
|
1079
|
+
return { error: 'Both threads must be at least 20 characters.' };
|
|
1080
|
+
}
|
|
1081
|
+
if (threadA.length > 5000 || threadB.length > 5000) {
|
|
1082
|
+
reply.status(400);
|
|
1083
|
+
return { error: 'Threads must be under 5000 characters each.' };
|
|
1084
|
+
}
|
|
1085
|
+
const tgcIpHash = hashIp(req.ip);
|
|
1086
|
+
const tgcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
1087
|
+
const tgcRate = await checkRateLimit(tgcIpHash, 'thread-grader', tgcEmail);
|
|
1088
|
+
if (!tgcRate.allowed) {
|
|
1089
|
+
return {
|
|
1090
|
+
gated: true,
|
|
1091
|
+
isPro: tgcRate.isPro,
|
|
1092
|
+
remaining: 0,
|
|
1093
|
+
limit: tgcRate.limit,
|
|
1094
|
+
message: tgcRate.isPro ? proGateMsg() : freeGateMsg('Get 100 grades/day with Pro'),
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
const scoringSystemPrompt = `You are a viral content analyst specializing in X/Twitter threads. Score this thread on 4 pillars:
|
|
1098
|
+
|
|
1099
|
+
SCORING SYSTEM (total 100 points):
|
|
1100
|
+
|
|
1101
|
+
**Pillar 1: Hook Strength (30 points)**
|
|
1102
|
+
Does tweet 1 stop the scroll? Curiosity gap, specificity, controversy, bold claim?
|
|
1103
|
+
|
|
1104
|
+
**Pillar 2: Tension Chain (25 points)**
|
|
1105
|
+
Does each tweet pull you to the next? Does the reader NEED to keep reading?
|
|
1106
|
+
|
|
1107
|
+
**Pillar 3: Payoff (25 points)**
|
|
1108
|
+
Does the thread deliver real value? Surprise, actionable insight, or earned conclusion?
|
|
1109
|
+
|
|
1110
|
+
**Pillar 4: Share Trigger (20 points)**
|
|
1111
|
+
Is there a moment worth screenshotting or quoting?
|
|
1112
|
+
|
|
1113
|
+
GRADING SCALE:
|
|
1114
|
+
90–100: A+ | 85–89: A | 80–84: A- | 75–79: B+ | 70–74: B | 65–69: B- | 60–64: C+ | 55–59: C | 50–54: C- | 40–49: D | 0–39: F
|
|
1115
|
+
|
|
1116
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
1117
|
+
{
|
|
1118
|
+
"total_score": <number 0-100>,
|
|
1119
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">,
|
|
1120
|
+
"pillar_scores": {
|
|
1121
|
+
"hook": { "score": <0-30>, "max": 30, "feedback": "<1-2 sentences>" },
|
|
1122
|
+
"tension": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" },
|
|
1123
|
+
"payoff": { "score": <0-25>, "max": 25, "feedback": "<1-2 sentences>" },
|
|
1124
|
+
"share_trigger": { "score": <0-20>, "max": 20, "feedback": "<1-2 sentences>" }
|
|
1125
|
+
},
|
|
1126
|
+
"verdict": "<One sharp sentence: the single biggest strength or weakness>"
|
|
1127
|
+
}`;
|
|
1128
|
+
const compareSystemPrompt = `You are a viral content analyst specializing in X/Twitter threads. You will be given two scored threads with their pillar breakdowns. Your job is to:
|
|
1129
|
+
1. Write a sharp 2-sentence verdict explaining WHY the winner is stronger — reference the specific pillar scores and gaps
|
|
1130
|
+
2. Write a suggested hybrid thread hook that steals the best element from each — be specific about what was borrowed
|
|
1131
|
+
3. Write a one-sentence strategic analysis: what this result means for the creator's next thread
|
|
1132
|
+
|
|
1133
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
1134
|
+
{
|
|
1135
|
+
"verdict": "<2 sentences citing specific pillar score gaps>",
|
|
1136
|
+
"suggested_hybrid": "<rewritten hook tweet combining best elements> | <one sentence: what was taken from A and what was taken from B>",
|
|
1137
|
+
"strategic_analysis": "<one sentence tactical recommendation for the next thread>"
|
|
1138
|
+
}`;
|
|
1139
|
+
function parseScore(raw) {
|
|
1140
|
+
try {
|
|
1141
|
+
return JSON.parse(raw);
|
|
1142
|
+
}
|
|
1143
|
+
catch { }
|
|
1144
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
1145
|
+
if (!m)
|
|
1146
|
+
throw new Error('Could not parse scoring response.');
|
|
1147
|
+
return JSON.parse(m[0]);
|
|
1148
|
+
}
|
|
1149
|
+
try {
|
|
1150
|
+
const [rawA, rawB] = await Promise.all([
|
|
1151
|
+
askClaude(`Score this X/Twitter thread:\n\n${threadA}`, {
|
|
1152
|
+
systemPrompt: scoringSystemPrompt,
|
|
1153
|
+
model: 'haiku',
|
|
1154
|
+
}),
|
|
1155
|
+
askClaude(`Score this X/Twitter thread:\n\n${threadB}`, {
|
|
1156
|
+
systemPrompt: scoringSystemPrompt,
|
|
1157
|
+
model: 'haiku',
|
|
1158
|
+
}),
|
|
1159
|
+
]);
|
|
1160
|
+
const scoreA = parseScore(rawA);
|
|
1161
|
+
const scoreB = parseScore(rawB);
|
|
1162
|
+
const pA = scoreA.pillar_scores;
|
|
1163
|
+
const pB = scoreB.pillar_scores;
|
|
1164
|
+
const computedMargin = Math.abs(scoreA.total_score - scoreB.total_score);
|
|
1165
|
+
const computedWinner = scoreA.total_score > scoreB.total_score ? 'A' :
|
|
1166
|
+
scoreB.total_score > scoreA.total_score ? 'B' : 'tie';
|
|
1167
|
+
const pillarWin = (a, b) => a > b ? 'A' : b > a ? 'B' : 'tie';
|
|
1168
|
+
const computedPillarWinners = {
|
|
1169
|
+
hook: pillarWin(pA.hook.score, pB.hook.score),
|
|
1170
|
+
tension: pillarWin(pA.tension.score, pB.tension.score),
|
|
1171
|
+
payoff: pillarWin(pA.payoff.score, pB.payoff.score),
|
|
1172
|
+
share_trigger: pillarWin(pA.share_trigger.score, pB.share_trigger.score),
|
|
1173
|
+
};
|
|
1174
|
+
const comparisonPrompt = `Thread A:
|
|
1175
|
+
${threadA.substring(0, 200)}...
|
|
1176
|
+
Total: ${scoreA.total_score}/100 | Hook: ${pA.hook.score}/30 | Tension: ${pA.tension.score}/25 | Payoff: ${pA.payoff.score}/25 | Share: ${pA.share_trigger.score}/20
|
|
1177
|
+
|
|
1178
|
+
Thread B:
|
|
1179
|
+
${threadB.substring(0, 200)}...
|
|
1180
|
+
Total: ${scoreB.total_score}/100 | Hook: ${pB.hook.score}/30 | Tension: ${pB.tension.score}/25 | Payoff: ${pB.payoff.score}/25 | Share: ${pB.share_trigger.score}/20
|
|
1181
|
+
|
|
1182
|
+
Overall winner: Thread ${computedWinner}${computedWinner !== 'tie' ? ` by ${computedMargin} points` : ' (tied)'}
|
|
1183
|
+
Pillar winners: Hook → ${computedPillarWinners.hook} | Tension → ${computedPillarWinners.tension} | Payoff → ${computedPillarWinners.payoff} | Share → ${computedPillarWinners.share_trigger}
|
|
1184
|
+
|
|
1185
|
+
Write the verdict, suggested hybrid hook, and strategic analysis.`;
|
|
1186
|
+
const rawComp = await askClaude(comparisonPrompt, {
|
|
1187
|
+
systemPrompt: compareSystemPrompt,
|
|
1188
|
+
model: 'haiku',
|
|
1189
|
+
});
|
|
1190
|
+
const compParsed = parseScore(rawComp);
|
|
1191
|
+
const comparison = {
|
|
1192
|
+
winner: computedWinner,
|
|
1193
|
+
margin: computedMargin,
|
|
1194
|
+
verdict: compParsed.verdict ?? '',
|
|
1195
|
+
pillar_winners: computedPillarWinners,
|
|
1196
|
+
suggested_hybrid: compParsed.suggested_hybrid ?? '',
|
|
1197
|
+
strategic_analysis: compParsed.strategic_analysis ?? '',
|
|
1198
|
+
};
|
|
1199
|
+
const tgcNewCount = incrementUsage(tgcIpHash, 'thread-grader');
|
|
1200
|
+
const tgcRemaining = Math.max(0, tgcRate.limit - tgcNewCount);
|
|
1201
|
+
return { threadA: scoreA, threadB: scoreB, comparison, usage: { remaining: tgcRemaining, limit: tgcRate.limit, isPro: tgcRate.isPro, gated: false } };
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
console.error('thread_grader_compare', err);
|
|
1205
|
+
reply.status(500);
|
|
1206
|
+
return { error: `Comparison failed: ${err.message}` };
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
// ── Email Forge ──────────────────────────────────────
|
|
1210
|
+
app.post('/api/demos/email-forge', async (req, reply) => {
|
|
1211
|
+
const body = req.body;
|
|
1212
|
+
const product = (body?.product ?? '').trim();
|
|
1213
|
+
const audience = (body?.audience ?? '').trim();
|
|
1214
|
+
const goal = (body?.goal ?? 'cold_outreach').trim();
|
|
1215
|
+
const tone = (body?.tone ?? 'professional').trim();
|
|
1216
|
+
if (!product || product.length < 10) {
|
|
1217
|
+
reply.status(400);
|
|
1218
|
+
return { error: 'Product description must be at least 10 characters.' };
|
|
1219
|
+
}
|
|
1220
|
+
if (!audience || audience.length < 5) {
|
|
1221
|
+
reply.status(400);
|
|
1222
|
+
return { error: 'Audience must be at least 5 characters.' };
|
|
1223
|
+
}
|
|
1224
|
+
const _efIpHash = hashIp(req.ip);
|
|
1225
|
+
const _efEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
1226
|
+
const _efRate = await checkRateLimit(_efIpHash, 'email-forge', _efEmail);
|
|
1227
|
+
if (!_efRate.allowed) {
|
|
1228
|
+
return {
|
|
1229
|
+
gated: true,
|
|
1230
|
+
isPro: _efRate.isPro,
|
|
1231
|
+
remaining: 0,
|
|
1232
|
+
limit: _efRate.limit,
|
|
1233
|
+
message: _efRate.isPro ? proGateMsg() : freeGateMsg('Get 100 sequences/day with Pro'),
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
const GOAL_LABELS = {
|
|
1237
|
+
cold_outreach: 'Cold Outreach',
|
|
1238
|
+
nurture: 'Nurture Sequence',
|
|
1239
|
+
launch: 'Product Launch',
|
|
1240
|
+
're-engagement': 'Re-engagement',
|
|
1241
|
+
};
|
|
1242
|
+
const TONE_LABELS = {
|
|
1243
|
+
professional: 'Professional',
|
|
1244
|
+
casual: 'Casual',
|
|
1245
|
+
urgent: 'Urgent',
|
|
1246
|
+
storytelling: 'Storytelling',
|
|
1247
|
+
};
|
|
1248
|
+
const systemPrompt = `You are an elite email copywriter who has studied AIDA, PAS, Hormozi, Cialdini, and narrative frameworks deeply. You write high-converting email sequences for real businesses.
|
|
1249
|
+
|
|
1250
|
+
Generate a 5-email sequence. Each email MUST use a DIFFERENT framework from this list:
|
|
1251
|
+
1. AIDA (Attention-Interest-Desire-Action)
|
|
1252
|
+
2. PAS (Problem-Agitate-Solve)
|
|
1253
|
+
3. Hormozi Value Equation (Dream Outcome × Perceived Likelihood / Time Delay × Effort & Sacrifice)
|
|
1254
|
+
4. Cialdini Reciprocity (give massive free value, then present the offer)
|
|
1255
|
+
5. Storytelling Arc (Setup → Conflict → Resolution → Call to Action)
|
|
1256
|
+
|
|
1257
|
+
The sequence should have logical progression:
|
|
1258
|
+
- Email 1: Hook — stops the scroll, creates a curiosity gap or bold promise
|
|
1259
|
+
- Email 2: Trust — proves credibility, shares social proof or insight
|
|
1260
|
+
- Email 3: Value — presents the core offer with maximum perceived value
|
|
1261
|
+
- Email 4: Urgency — creates legitimate urgency or scarcity
|
|
1262
|
+
- Email 5: Final CTA — last chance with scarcity, addresses objections
|
|
1263
|
+
|
|
1264
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
1265
|
+
{
|
|
1266
|
+
"emails": [
|
|
1267
|
+
{
|
|
1268
|
+
"position": 1,
|
|
1269
|
+
"subject_line": "<subject line that gets opened — max 60 chars>",
|
|
1270
|
+
"preview_text": "<preview/preheader text — max 90 chars>",
|
|
1271
|
+
"body": "<full email body — 100-200 words, formatted with line breaks>",
|
|
1272
|
+
"cta": "<call-to-action button text — max 30 chars>",
|
|
1273
|
+
"framework_used": "<framework name from the list above>",
|
|
1274
|
+
"framework_explanation": "<one sentence: how this framework is applied in this email>",
|
|
1275
|
+
"estimated_open_rate": "<percentage range, e.g. '28-35%'>",
|
|
1276
|
+
"estimated_click_rate": "<percentage range, e.g. '4-7%'>"
|
|
1277
|
+
}
|
|
1278
|
+
],
|
|
1279
|
+
"sequence_strategy": "<2-3 sentences: the overall strategy and why this sequence will convert>",
|
|
1280
|
+
"overall_score": <number 0-100>,
|
|
1281
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">
|
|
1282
|
+
}`;
|
|
1283
|
+
const userPrompt = `Generate a 5-email sequence for:
|
|
1284
|
+
|
|
1285
|
+
Product: ${product}
|
|
1286
|
+
Target Audience: ${audience}
|
|
1287
|
+
Goal: ${GOAL_LABELS[goal] ?? goal}
|
|
1288
|
+
Tone: ${TONE_LABELS[tone] ?? tone}
|
|
1289
|
+
|
|
1290
|
+
Make each email feel distinct — different frameworks, different emotional levers, different angles. The sequence should feel like a progression, not five versions of the same pitch.`;
|
|
1291
|
+
try {
|
|
1292
|
+
const raw = await askClaude(userPrompt, {
|
|
1293
|
+
systemPrompt,
|
|
1294
|
+
model: 'haiku',
|
|
1295
|
+
});
|
|
1296
|
+
let parsed;
|
|
1297
|
+
try {
|
|
1298
|
+
parsed = JSON.parse(raw);
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
1302
|
+
if (!jsonMatch)
|
|
1303
|
+
throw new Error('Could not parse email sequence response.');
|
|
1304
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
1305
|
+
}
|
|
1306
|
+
const _efNewCount = incrementUsage(_efIpHash, 'email-forge');
|
|
1307
|
+
const _efRemaining = Math.max(0, _efRate.limit - _efNewCount);
|
|
1308
|
+
return { ...parsed, usage: { remaining: _efRemaining, limit: _efRate.limit, isPro: _efRate.isPro, gated: false } };
|
|
1309
|
+
}
|
|
1310
|
+
catch (err) {
|
|
1311
|
+
console.error('email_forge_demo', err);
|
|
1312
|
+
reply.status(500);
|
|
1313
|
+
return { error: `Generation failed: ${err.message}` };
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
app.post('/api/demos/email-forge/compare', async (req, reply) => {
|
|
1317
|
+
const body = req.body;
|
|
1318
|
+
const productA = (body?.product_a ?? '').trim();
|
|
1319
|
+
const audienceA = (body?.audience_a ?? '').trim();
|
|
1320
|
+
const goalA = (body?.goal_a ?? 'cold_outreach').trim();
|
|
1321
|
+
const toneA = (body?.tone_a ?? 'professional').trim();
|
|
1322
|
+
const productB = (body?.product_b ?? '').trim();
|
|
1323
|
+
const audienceB = (body?.audience_b ?? '').trim();
|
|
1324
|
+
const goalB = (body?.goal_b ?? 'cold_outreach').trim();
|
|
1325
|
+
const toneB = (body?.tone_b ?? 'professional').trim();
|
|
1326
|
+
if (!productA || productA.length < 10 || !productB || productB.length < 10) {
|
|
1327
|
+
reply.status(400);
|
|
1328
|
+
return { error: 'Both product descriptions must be at least 10 characters.' };
|
|
1329
|
+
}
|
|
1330
|
+
if (!audienceA || audienceA.length < 5 || !audienceB || audienceB.length < 5) {
|
|
1331
|
+
reply.status(400);
|
|
1332
|
+
return { error: 'Both audience fields must be at least 5 characters.' };
|
|
1333
|
+
}
|
|
1334
|
+
const efcIpHash = hashIp(req.ip);
|
|
1335
|
+
const efcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
1336
|
+
const efcRate = await checkRateLimit(efcIpHash, 'email-forge', efcEmail);
|
|
1337
|
+
if (!efcRate.allowed) {
|
|
1338
|
+
return {
|
|
1339
|
+
gated: true,
|
|
1340
|
+
isPro: efcRate.isPro,
|
|
1341
|
+
remaining: 0,
|
|
1342
|
+
limit: efcRate.limit,
|
|
1343
|
+
message: efcRate.isPro ? proGateMsg() : freeGateMsg('Get unlimited sequences/day with Pro'),
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
const systemPrompt = `You are an elite email copywriter who has studied AIDA, PAS, Hormozi, Cialdini, and narrative frameworks deeply. You write high-converting email sequences for real businesses.
|
|
1347
|
+
|
|
1348
|
+
Generate a 5-email sequence. Each email MUST use a DIFFERENT framework from this list:
|
|
1349
|
+
1. AIDA (Attention-Interest-Desire-Action)
|
|
1350
|
+
2. PAS (Problem-Agitate-Solve)
|
|
1351
|
+
3. Hormozi Value Equation
|
|
1352
|
+
4. Cialdini Reciprocity
|
|
1353
|
+
5. Storytelling Arc
|
|
1354
|
+
|
|
1355
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
1356
|
+
{
|
|
1357
|
+
"emails": [
|
|
1358
|
+
{
|
|
1359
|
+
"position": 1,
|
|
1360
|
+
"subject_line": "<subject line — max 60 chars>",
|
|
1361
|
+
"preview_text": "<preview text — max 90 chars>",
|
|
1362
|
+
"body": "<full email body — 100-200 words>",
|
|
1363
|
+
"cta": "<CTA button text — max 30 chars>",
|
|
1364
|
+
"framework_used": "<framework name>",
|
|
1365
|
+
"framework_explanation": "<one sentence>",
|
|
1366
|
+
"estimated_open_rate": "<percentage range>",
|
|
1367
|
+
"estimated_click_rate": "<percentage range>"
|
|
1368
|
+
}
|
|
1369
|
+
],
|
|
1370
|
+
"sequence_strategy": "<2-3 sentences>",
|
|
1371
|
+
"overall_score": <number 0-100>,
|
|
1372
|
+
"grade": <"A+"|"A"|"A-"|"B+"|"B"|"B-"|"C+"|"C"|"C-"|"D"|"F">
|
|
1373
|
+
}`;
|
|
1374
|
+
function buildPrompt(product, audience, goal, tone) {
|
|
1375
|
+
const GOAL_LABELS = {
|
|
1376
|
+
cold_outreach: 'Cold Outreach', nurture: 'Nurture Sequence',
|
|
1377
|
+
launch: 'Product Launch', 're-engagement': 'Re-engagement',
|
|
1378
|
+
};
|
|
1379
|
+
const TONE_LABELS = {
|
|
1380
|
+
professional: 'Professional', casual: 'Casual',
|
|
1381
|
+
urgent: 'Urgent', storytelling: 'Storytelling',
|
|
1382
|
+
};
|
|
1383
|
+
return `Generate a 5-email sequence for:\n\nProduct: ${product}\nTarget Audience: ${audience}\nGoal: ${GOAL_LABELS[goal] ?? goal}\nTone: ${TONE_LABELS[tone] ?? tone}`;
|
|
1384
|
+
}
|
|
1385
|
+
function parseSeq(raw) {
|
|
1386
|
+
try {
|
|
1387
|
+
return JSON.parse(raw);
|
|
1388
|
+
}
|
|
1389
|
+
catch { }
|
|
1390
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
1391
|
+
if (!m)
|
|
1392
|
+
throw new Error('Could not parse sequence response.');
|
|
1393
|
+
return JSON.parse(m[0]);
|
|
1394
|
+
}
|
|
1395
|
+
try {
|
|
1396
|
+
const [rawA, rawB] = await Promise.all([
|
|
1397
|
+
askClaude(buildPrompt(productA, audienceA, goalA, toneA), {
|
|
1398
|
+
systemPrompt,
|
|
1399
|
+
model: 'haiku',
|
|
1400
|
+
}),
|
|
1401
|
+
askClaude(buildPrompt(productB, audienceB, goalB, toneB), {
|
|
1402
|
+
systemPrompt,
|
|
1403
|
+
model: 'haiku',
|
|
1404
|
+
}),
|
|
1405
|
+
]);
|
|
1406
|
+
const seqA = parseSeq(rawA);
|
|
1407
|
+
const seqB = parseSeq(rawB);
|
|
1408
|
+
const margin = Math.abs(seqA.overall_score - seqB.overall_score);
|
|
1409
|
+
const winner = seqA.overall_score >= seqB.overall_score ? 'A' : 'B';
|
|
1410
|
+
const compareSystemPrompt = `You are an email marketing strategist. Compare two email sequences and provide analysis.
|
|
1411
|
+
|
|
1412
|
+
Respond ONLY with valid JSON — no markdown:
|
|
1413
|
+
{
|
|
1414
|
+
"reasoning": "<2-3 sentences on why the winner performs better — reference specific frameworks and subject lines>",
|
|
1415
|
+
"per_email_comparison": [
|
|
1416
|
+
{ "position": 1, "winner": "A", "reason": "<one sentence specific to this email>" },
|
|
1417
|
+
{ "position": 2, "winner": "B", "reason": "<one sentence>" },
|
|
1418
|
+
{ "position": 3, "winner": "A", "reason": "<one sentence>" },
|
|
1419
|
+
{ "position": 4, "winner": "B", "reason": "<one sentence>" },
|
|
1420
|
+
{ "position": 5, "winner": "A", "reason": "<one sentence>" }
|
|
1421
|
+
]
|
|
1422
|
+
}`;
|
|
1423
|
+
const comparePrompt = `Sequence A (score: ${seqA.overall_score}, grade: ${seqA.grade}):
|
|
1424
|
+
Product: ${productA.substring(0, 150)}
|
|
1425
|
+
Strategy: ${seqA.sequence_strategy ?? ''}
|
|
1426
|
+
Subject lines: ${(seqA.emails ?? []).map((e) => `Email ${e.position}: "${e.subject_line}"`).join(' | ')}
|
|
1427
|
+
Frameworks: ${(seqA.emails ?? []).map((e) => `Email ${e.position}: ${e.framework_used}`).join(', ')}
|
|
1428
|
+
|
|
1429
|
+
Sequence B (score: ${seqB.overall_score}, grade: ${seqB.grade}):
|
|
1430
|
+
Product: ${productB.substring(0, 150)}
|
|
1431
|
+
Strategy: ${seqB.sequence_strategy ?? ''}
|
|
1432
|
+
Subject lines: ${(seqB.emails ?? []).map((e) => `Email ${e.position}: "${e.subject_line}"`).join(' | ')}
|
|
1433
|
+
Frameworks: ${(seqB.emails ?? []).map((e) => `Email ${e.position}: ${e.framework_used}`).join(', ')}
|
|
1434
|
+
|
|
1435
|
+
Overall winner: Sequence ${winner} by ${margin} points.`;
|
|
1436
|
+
const rawComp = await askClaude(comparePrompt, {
|
|
1437
|
+
systemPrompt: compareSystemPrompt,
|
|
1438
|
+
model: 'haiku',
|
|
1439
|
+
});
|
|
1440
|
+
const compParsed = parseSeq(rawComp);
|
|
1441
|
+
const efcNewCount = incrementUsage(efcIpHash, 'email-forge');
|
|
1442
|
+
const efcRemaining = Math.max(0, efcRate.limit - efcNewCount);
|
|
1443
|
+
return {
|
|
1444
|
+
sequence_a: seqA,
|
|
1445
|
+
sequence_b: seqB,
|
|
1446
|
+
comparison: {
|
|
1447
|
+
winner,
|
|
1448
|
+
margin,
|
|
1449
|
+
reasoning: compParsed.reasoning ?? '',
|
|
1450
|
+
per_email_comparison: compParsed.per_email_comparison ?? [],
|
|
1451
|
+
},
|
|
1452
|
+
usage: { remaining: efcRemaining, limit: efcRate.limit, isPro: efcRate.isPro, gated: false },
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
catch (err) {
|
|
1456
|
+
console.error('email_forge_compare', err);
|
|
1457
|
+
reply.status(500);
|
|
1458
|
+
return { error: `Comparison failed: ${err.message}` };
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
// ── Email Capture ──────────────────────────────────────
|
|
1462
|
+
app.post('/api/demos/email-capture', async (req, reply) => {
|
|
1463
|
+
const body = req.body;
|
|
1464
|
+
const email = (body?.email ?? '').trim().toLowerCase();
|
|
1465
|
+
const tool = (body?.tool ?? '').trim();
|
|
1466
|
+
const score = String(body?.score ?? '').trim();
|
|
1467
|
+
if (!email || !email.includes('@') || !tool) {
|
|
1468
|
+
reply.status(400);
|
|
1469
|
+
return { error: 'Valid email and tool are required.' };
|
|
1470
|
+
}
|
|
1471
|
+
try {
|
|
1472
|
+
const db = getDb();
|
|
1473
|
+
const ipHash = hashIp(req.ip);
|
|
1474
|
+
db.prepare('INSERT OR IGNORE INTO email_captures (email, tool, score, ip_hash, source) VALUES (?, ?, ?, ?, ?)').run(email, tool, score, ipHash, tool);
|
|
1475
|
+
return { ok: true };
|
|
1476
|
+
}
|
|
1477
|
+
catch (err) {
|
|
1478
|
+
console.error('email_capture', err);
|
|
1479
|
+
reply.status(500);
|
|
1480
|
+
return { error: 'Failed to save.' };
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
// ── Audience Decoder ──────────────────────────────────────
|
|
1484
|
+
app.post('/api/demos/audience-decoder', async (req, reply) => {
|
|
1485
|
+
const body = req.body;
|
|
1486
|
+
const content = (body?.content ?? '').trim();
|
|
1487
|
+
if (!content || content.length < 50) {
|
|
1488
|
+
reply.status(400);
|
|
1489
|
+
return { error: 'Content must be at least 50 characters. Paste 10-20 posts.' };
|
|
1490
|
+
}
|
|
1491
|
+
if (content.length > 15000) {
|
|
1492
|
+
reply.status(400);
|
|
1493
|
+
return { error: 'Content must be under 15000 characters.' };
|
|
1494
|
+
}
|
|
1495
|
+
// AudienceDecoder is a one-time purchase product — check hasPurchased instead of subscription
|
|
1496
|
+
const _adIpHash = hashIp(req.ip);
|
|
1497
|
+
const _adEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
1498
|
+
const _adRate = await checkRateLimit(_adIpHash, 'audience-decoder', _adEmail, 'audiencedecoder_report');
|
|
1499
|
+
if (!_adRate.allowed) {
|
|
1500
|
+
return {
|
|
1501
|
+
gated: true,
|
|
1502
|
+
isPro: _adRate.isPro,
|
|
1503
|
+
remaining: 0,
|
|
1504
|
+
limit: _adRate.limit,
|
|
1505
|
+
message: _adRate.isPro ? proGateMsg() : freeGateMsg('Purchase the full report for 100 analyses/day'),
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
const systemPrompt = `You are an audience intelligence analyst. Analyze the creator's content portfolio and return a JSON object.
|
|
1509
|
+
|
|
1510
|
+
Identify:
|
|
1511
|
+
1. Audience archetypes — who reads this content (2-4 distinct segments, with percentages totaling 100, real evidence from the text)
|
|
1512
|
+
2. Content patterns — what works vs what doesn't, the optimal format and length, voice analysis
|
|
1513
|
+
3. Engagement model — hook effectiveness, CTA effectiveness, controversy index, shareability
|
|
1514
|
+
4. Growth opportunities — 3-5 specific, ranked opportunities with impact/effort ratings
|
|
1515
|
+
5. Content calendar — weekly mix recommendation, theme rotation, gaps to fill
|
|
1516
|
+
6. Overall score (0-100) and letter grade (A-F scale)
|
|
1517
|
+
7. A memorable, shareable headline — like a personality type result (e.g. "The Aspiration Architect — strong voice, weak CTAs, massive thread potential")
|
|
1518
|
+
|
|
1519
|
+
Rules:
|
|
1520
|
+
- Be specific — cite actual phrases or content excerpts as evidence
|
|
1521
|
+
- Scores must be differentiated — not everything is 70-80, use the full range
|
|
1522
|
+
- Grade on A-F scale: 90-100 A, 80-89 B, 70-79 C, 60-69 D, below 60 F
|
|
1523
|
+
- Growth opportunities must be actionable — not generic advice
|
|
1524
|
+
- The headline must be memorable and shareable — it IS the viral hook
|
|
1525
|
+
|
|
1526
|
+
Respond ONLY with valid JSON matching this exact schema — no markdown, no extra text:
|
|
1527
|
+
{
|
|
1528
|
+
"audience_archetypes": [
|
|
1529
|
+
{ "name": "<archetype name>", "percentage": <number>, "description": "<2 sentences>", "evidence": ["<quote or excerpt from their content>"] }
|
|
1530
|
+
],
|
|
1531
|
+
"content_patterns": {
|
|
1532
|
+
"top_performing_themes": [{ "theme": "<theme>", "frequency": <number>, "avg_engagement_signal": "high|medium|low" }],
|
|
1533
|
+
"underperforming_themes": [{ "theme": "<theme>", "frequency": <number>, "avg_engagement_signal": "high|medium|low" }],
|
|
1534
|
+
"optimal_format": "thread|single_post|question|story|list",
|
|
1535
|
+
"optimal_length": "short|medium|long",
|
|
1536
|
+
"voice_analysis": { "tone": "<describe tone>", "unique_phrases": ["<phrase>"], "brand_words": ["<word>"] }
|
|
1537
|
+
},
|
|
1538
|
+
"engagement_model": {
|
|
1539
|
+
"hook_effectiveness": { "score": <0-100>, "best_hooks": ["<excerpt>"], "worst_hooks": ["<excerpt>"] },
|
|
1540
|
+
"cta_effectiveness": { "score": <0-100>, "recommendation": "<specific advice>" },
|
|
1541
|
+
"controversy_index": { "score": <0-100>, "note": "<context>" },
|
|
1542
|
+
"shareability_score": <0-100>
|
|
1543
|
+
},
|
|
1544
|
+
"growth_opportunities": [
|
|
1545
|
+
{ "opportunity": "<specific opportunity>", "impact": "high|medium|low", "effort": "high|medium|low", "explanation": "<why this works for their audience>" }
|
|
1546
|
+
],
|
|
1547
|
+
"content_calendar": {
|
|
1548
|
+
"weekly_mix": { "threads": <number>, "single_posts": <number>, "questions": <number> },
|
|
1549
|
+
"theme_rotation": ["<day: theme>"],
|
|
1550
|
+
"gaps_to_fill": ["<content gap>"]
|
|
1551
|
+
},
|
|
1552
|
+
"overall_score": <0-100>,
|
|
1553
|
+
"grade": "<A|B|C|D|F>",
|
|
1554
|
+
"headline": "<memorable shareable one-liner about this creator's profile>"
|
|
1555
|
+
}`;
|
|
1556
|
+
try {
|
|
1557
|
+
const raw = await askClaude(`Analyze this creator's content portfolio:\n\n${content}`, {
|
|
1558
|
+
systemPrompt,
|
|
1559
|
+
model: 'haiku',
|
|
1560
|
+
});
|
|
1561
|
+
let parsed;
|
|
1562
|
+
try {
|
|
1563
|
+
parsed = JSON.parse(raw);
|
|
1564
|
+
}
|
|
1565
|
+
catch {
|
|
1566
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
1567
|
+
if (!jsonMatch)
|
|
1568
|
+
throw new Error('Could not parse analysis response.');
|
|
1569
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
1570
|
+
}
|
|
1571
|
+
const _adNewCount = incrementUsage(_adIpHash, 'audience-decoder');
|
|
1572
|
+
const _adRemaining = Math.max(0, _adRate.limit - _adNewCount);
|
|
1573
|
+
return { ...parsed, usage: { remaining: _adRemaining, limit: _adRate.limit, isPro: _adRate.isPro, gated: false } };
|
|
1574
|
+
}
|
|
1575
|
+
catch (err) {
|
|
1576
|
+
console.error('audience_decoder_demo', err);
|
|
1577
|
+
reply.status(500);
|
|
1578
|
+
return { error: `Analysis failed: ${err.message}` };
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
app.post('/api/demos/audience-decoder/compare', async (req, reply) => {
|
|
1582
|
+
const body = req.body;
|
|
1583
|
+
const contentA = (body?.content_a ?? '').trim();
|
|
1584
|
+
const contentB = (body?.content_b ?? '').trim();
|
|
1585
|
+
if (!contentA || contentA.length < 50 || !contentB || contentB.length < 50) {
|
|
1586
|
+
reply.status(400);
|
|
1587
|
+
return { error: 'Both content portfolios must be at least 50 characters.' };
|
|
1588
|
+
}
|
|
1589
|
+
if (contentA.length > 15000 || contentB.length > 15000) {
|
|
1590
|
+
reply.status(400);
|
|
1591
|
+
return { error: 'Content must be under 15000 characters each.' };
|
|
1592
|
+
}
|
|
1593
|
+
const adcIpHash = hashIp(req.ip);
|
|
1594
|
+
const adcEmail = (body?.email ?? '').trim().toLowerCase() || undefined;
|
|
1595
|
+
const adcRate = await checkRateLimit(adcIpHash, 'audience-decoder', adcEmail, 'audiencedecoder_report');
|
|
1596
|
+
if (!adcRate.allowed) {
|
|
1597
|
+
return {
|
|
1598
|
+
gated: true,
|
|
1599
|
+
isPro: adcRate.isPro,
|
|
1600
|
+
remaining: 0,
|
|
1601
|
+
limit: adcRate.limit,
|
|
1602
|
+
message: adcRate.isPro ? proGateMsg() : freeGateMsg('Purchase the full report for unlimited analyses/day'),
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
const analyzeSystemPrompt = `You are an audience intelligence analyst. Analyze the creator's content portfolio and return a JSON object.
|
|
1606
|
+
|
|
1607
|
+
Identify audience archetypes, content patterns, engagement model, growth opportunities, content calendar, overall score and grade, and a memorable headline.
|
|
1608
|
+
|
|
1609
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
1610
|
+
{
|
|
1611
|
+
"audience_archetypes": [
|
|
1612
|
+
{ "name": "<archetype>", "percentage": <number>, "description": "<2 sentences>", "evidence": ["<excerpt>"] }
|
|
1613
|
+
],
|
|
1614
|
+
"content_patterns": {
|
|
1615
|
+
"top_performing_themes": [{ "theme": "<theme>", "frequency": <number>, "avg_engagement_signal": "high|medium|low" }],
|
|
1616
|
+
"underperforming_themes": [{ "theme": "<theme>", "frequency": <number>, "avg_engagement_signal": "high|medium|low" }],
|
|
1617
|
+
"optimal_format": "thread|single_post|question|story|list",
|
|
1618
|
+
"optimal_length": "short|medium|long",
|
|
1619
|
+
"voice_analysis": { "tone": "<tone>", "unique_phrases": ["<phrase>"], "brand_words": ["<word>"] }
|
|
1620
|
+
},
|
|
1621
|
+
"engagement_model": {
|
|
1622
|
+
"hook_effectiveness": { "score": <0-100>, "best_hooks": ["<excerpt>"], "worst_hooks": ["<excerpt>"] },
|
|
1623
|
+
"cta_effectiveness": { "score": <0-100>, "recommendation": "<advice>" },
|
|
1624
|
+
"controversy_index": { "score": <0-100>, "note": "<context>" },
|
|
1625
|
+
"shareability_score": <0-100>
|
|
1626
|
+
},
|
|
1627
|
+
"growth_opportunities": [
|
|
1628
|
+
{ "opportunity": "<opportunity>", "impact": "high|medium|low", "effort": "high|medium|low", "explanation": "<why>" }
|
|
1629
|
+
],
|
|
1630
|
+
"content_calendar": {
|
|
1631
|
+
"weekly_mix": { "threads": <number>, "single_posts": <number>, "questions": <number> },
|
|
1632
|
+
"theme_rotation": ["<day: theme>"],
|
|
1633
|
+
"gaps_to_fill": ["<gap>"]
|
|
1634
|
+
},
|
|
1635
|
+
"overall_score": <0-100>,
|
|
1636
|
+
"grade": "<A|B|C|D|F>",
|
|
1637
|
+
"headline": "<memorable shareable one-liner>"
|
|
1638
|
+
}`;
|
|
1639
|
+
const compareSystemPrompt = `You are an audience intelligence analyst. Two creator profiles have been analyzed. Write a comparative analysis.
|
|
1640
|
+
|
|
1641
|
+
Respond ONLY with valid JSON — no markdown, no extra text:
|
|
1642
|
+
{
|
|
1643
|
+
"audience_overlap": <0-100 percentage>,
|
|
1644
|
+
"differentiation_score": <0-100>,
|
|
1645
|
+
"collaboration_potential": "high|medium|low",
|
|
1646
|
+
"winner_by_category": {
|
|
1647
|
+
"hooks": "A|B|tie",
|
|
1648
|
+
"depth": "A|B|tie",
|
|
1649
|
+
"shareability": "A|B|tie",
|
|
1650
|
+
"consistency": "A|B|tie",
|
|
1651
|
+
"audience_clarity": "A|B|tie"
|
|
1652
|
+
},
|
|
1653
|
+
"strategic_advice": "<2 sentences: what each creator should adopt from the other>"
|
|
1654
|
+
}`;
|
|
1655
|
+
try {
|
|
1656
|
+
const [rawA, rawB] = await Promise.all([
|
|
1657
|
+
askClaude(`Analyze this creator's content portfolio:\n\n${contentA}`, {
|
|
1658
|
+
systemPrompt: analyzeSystemPrompt,
|
|
1659
|
+
model: 'haiku',
|
|
1660
|
+
}),
|
|
1661
|
+
askClaude(`Analyze this creator's content portfolio:\n\n${contentB}`, {
|
|
1662
|
+
systemPrompt: analyzeSystemPrompt,
|
|
1663
|
+
model: 'haiku',
|
|
1664
|
+
}),
|
|
1665
|
+
]);
|
|
1666
|
+
const analysisA = parseResult(rawA);
|
|
1667
|
+
const analysisB = parseResult(rawB);
|
|
1668
|
+
const comparisonPrompt = `Creator A: "${analysisA.headline}"
|
|
1669
|
+
Score: ${analysisA.overall_score}/100 | Grade: ${analysisA.grade}
|
|
1670
|
+
Top archetype: ${analysisA.audience_archetypes?.[0]?.name ?? 'unknown'}
|
|
1671
|
+
Hook effectiveness: ${analysisA.engagement_model?.hook_effectiveness?.score ?? '?'}/100
|
|
1672
|
+
Shareability: ${analysisA.engagement_model?.shareability_score ?? '?'}/100
|
|
1673
|
+
|
|
1674
|
+
Creator B: "${analysisB.headline}"
|
|
1675
|
+
Score: ${analysisB.overall_score}/100 | Grade: ${analysisB.grade}
|
|
1676
|
+
Top archetype: ${analysisB.audience_archetypes?.[0]?.name ?? 'unknown'}
|
|
1677
|
+
Hook effectiveness: ${analysisB.engagement_model?.hook_effectiveness?.score ?? '?'}/100
|
|
1678
|
+
Shareability: ${analysisB.engagement_model?.shareability_score ?? '?'}/100
|
|
1679
|
+
|
|
1680
|
+
Compare these two creators and identify collaboration potential, audience overlap, and what each should learn from the other.`;
|
|
1681
|
+
const rawComp = await askClaude(comparisonPrompt, {
|
|
1682
|
+
systemPrompt: compareSystemPrompt,
|
|
1683
|
+
model: 'haiku',
|
|
1684
|
+
});
|
|
1685
|
+
const comparison = parseResult(rawComp);
|
|
1686
|
+
const adcNewCount = incrementUsage(adcIpHash, 'audience-decoder');
|
|
1687
|
+
const adcRemaining = Math.max(0, adcRate.limit - adcNewCount);
|
|
1688
|
+
return {
|
|
1689
|
+
analysis_a: analysisA,
|
|
1690
|
+
analysis_b: analysisB,
|
|
1691
|
+
comparison,
|
|
1692
|
+
usage: { remaining: adcRemaining, limit: adcRate.limit, isPro: adcRate.isPro, gated: false },
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
catch (err) {
|
|
1696
|
+
console.error('audience_decoder_compare', err);
|
|
1697
|
+
reply.status(500);
|
|
1698
|
+
return { error: `Comparison failed: ${err.message}` };
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
}
|