1import { Actor, log } from 'apify';
2import { parseEntity, analyzeAnswer, summarize, sanitizeText } from './analyze.js';
3import { askChatGPT, classify, DEFAULT_MODEL, EngineError } from './engine.js';
4import { freeTrialCap } from './plan.js';
5const FREE_TRIAL_CHECKS = 10;
6
7const EVENT_BUILTIN = 'brand-check';
8const EVENT_BYOK = 'brand-check-own-key';
9const PRICE_BUILTIN = 0.04;
10const BUILTIN_MODELS = ['gpt-6-luna', 'gpt-5-nano', 'gpt-4o-mini', 'gpt-5.6-luna'];
11const BUDGET_STORE = 'brand-tracker-owner-spend';
12const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13const clampInt = (v, d, lo, hi) => Math.max(lo, Math.min(hi, Math.floor(Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : d)));
14class InputError extends Error {}
15const list = (v) => (Array.isArray(v) ? v : v ? String(v).split('\n') : []);
16
17await Actor.init();
18let exitMsg;
19try {
20 const input = (await Actor.getInput()) ?? {};
21 const { brand, brandDomain, brandAliases = [], competitors = [], prompts = [], samplesPerPrompt = 1, country = 'US',
22 analyzeSentiment = true, includeAnswerText = true, maxChecks = 50, openaiApiKey, model, maxConcurrency = 3 } = input;
23
24
25 const main = parseEntity(typeof brand === 'object' && brand ? brand : { name: brand, domain: brandDomain, aliases: list(brandAliases) }, 'brand');
26 if (!main) throw new InputError('Set "Your brand", e.g. Notion.');
27 const comps = list(competitors).map((c) => parseEntity(c, 'competitor')).filter(Boolean);
28 if (comps.length > 20) throw new InputError('Track at most 20 competitors per run.');
29 const entities = [main, ...comps.filter((c) => c.name.toLowerCase() !== main.name.toLowerCase())];
30 const ps = [...new Set(list(prompts).map((p) => String(p ?? '').trim()).filter(Boolean))];
31 if (!ps.length) throw new InputError('Add at least one prompt, e.g. "What is the best note-taking app for teams?"');
32 if (ps.length > 100) throw new InputError('Use at most 100 prompts per run.');
33 const tooLong = ps.find((p) => p.length > 1000); if (tooLong) throw new InputError(`Prompts must be under 1,000 characters (got one with ${tooLong.length}).`);
34 const samples = clampInt(samplesPerPrompt, 1, 1, 5);
35 const cc = String(country || '').trim().toUpperCase();
36 if (cc && !/^[A-Z]{2}$/.test(cc)) throw new InputError('"Country" must be a 2-letter code like US, GB or DE.');
37 const cap = clampInt(maxChecks, 50, 1, 1000);
38 const conc = clampInt(maxConcurrency, 3, 1, 10);
39
40
41 const byok = Boolean(openaiApiKey && String(openaiApiKey).trim());
42 let apiKey; let mdl;
43 if (byok) { apiKey = String(openaiApiKey).trim(); mdl = String(model || DEFAULT_MODEL).trim(); }
44 else {
45 apiKey = process.env.LLM_API_KEY;
46 if (!apiKey) throw new InputError('The built-in AI is not available on this deployment right now. Add your own OpenAI API key to run.');
47 mdl = process.env.LLM_MODEL || DEFAULT_MODEL;
48 if (model && model !== mdl) { if (BUILTIN_MODELS.includes(model)) mdl = model; else log.warning(`Model ${model} needs your own OpenAI key; using ${mdl}.`); }
49 }
50 const EVENT = byok ? EVENT_BYOK : EVENT_BUILTIN;
51 const trial = freeTrialCap({ requested: cap, byok, limit: FREE_TRIAL_CHECKS });
52 if (trial.limited) log.warning(`Apify free plan: the built-in AI runs at most ${FREE_TRIAL_CHECKS} checks per run. Add your own OpenAI API key, or use any paid Apify plan, to run up to ${cap}.`);
53
54
55 const env = Actor.getEnv();
56 const ownerBudget = !byok && process.env.OWNER_USER_ID && env.userId === process.env.OWNER_USER_ID && Number(process.env.OWNER_BUDGET_USD) > 0 ? Number(process.env.OWNER_BUDGET_USD) : null;
57 let budgetKv = null; let ownerSpent = 0;
58 if (ownerBudget) {
59
60 try { budgetKv = await Actor.openKeyValueStore(BUDGET_STORE); } catch (e) { throw new EngineError(`Owner test budget store unavailable (${e.message}); not spending.`); }
61 const rec = await budgetKv.getValue('SPEND');
62 ownerSpent = Number(rec?.usd ?? process.env.OWNER_SEED_USD ?? 0);
63 log.info(`Owner test budget: $${ownerSpent.toFixed(4)} of $${ownerBudget} used.`);
64 }
65
66 const jobs = []; for (const p of ps) for (let s = 1; s <= samples; s++) jobs.push({ prompt: p, sample: s });
67 if (jobs.length > trial.cap) log.warning(`${jobs.length} checks requested; the limit is ${trial.cap}, so only the first ${trial.cap} run.`);
68 const todo = jobs.slice(0, trial.cap);
69 log.info(`${main.name} vs ${entities.length - 1} competitors; ${ps.length} prompts x ${samples} sample(s) = ${todo.length} checks on ChatGPT (${mdl}, ${byok ? 'your key' : 'built-in key'}${cc ? `, ${cc}` : ''}).`);
70
71 const charging = Actor.getChargingManager();
72 const rows = []; const counts = {}; let charged = 0; let aiCost = 0; let stop = null; let fatal = null;
73 const names = entities.map((e) => e.name);
74
75 const one = async (job) => {
76 const row = { engine: 'chatgpt', model: mdl, prompt: job.prompt, sample: job.sample, country: cc || null, brand: main.name, status: 'ok',
77 brandMentioned: false, brandRank: null, brandListPosition: null, brandCited: false, brandSentiment: null, brandRecommended: null,
78 competitorsMentioned: [], mentionedOrder: [], entities: [], citations: [], citedDomains: [], otherBrandsMentioned: [], searchQueries: [],
79 answer: null, answerLength: 0, aiCostUsd: 0, aiKey: byok ? 'own-key' : 'built-in', checkedAt: null, error: null };
80 if (stop || fatal) return null;
81 if (ownerBudget && ownerSpent + aiCost + 0.03 > ownerBudget) { stop ??= `Owner test budget of $${ownerBudget} reached`; return null; }
82 if (!byok && aiCost > 0.05 + 0.6 * PRICE_BUILTIN * Math.max(1, charged)) { stop ??= 'Built-in AI cost guard tripped; stopping'; return null; }
83 const left = charging.calculateMaxEventChargeCountWithinLimit(EVENT);
84 if (left !== Infinity && left - (inflight - 1) <= 0) { stop ??= 'Reached your maximum cost per run'; return null; }
85 let r; let err;
86 for (let a = 0; a <= 2; a++) {
87 try { r = await askChatGPT({ apiKey, model: mdl, prompt: job.prompt, country: cc || undefined }); break; } catch (e) {
88 err = e; if (e.fatal) { fatal = e.message; break; }
89 if (!e.retryable || a === 2) break; await sleep(2000 * (a + 1) + Math.random() * 1000);
90 }
91 }
92 if (!r) { row.status = 'error'; row.error = sanitizeText(err?.message || 'unknown error'); row.checkedAt = new Date().toISOString(); return row; }
93 aiCost += r.costUsd; row.aiCostUsd += r.costUsd;
94 const ents = analyzeAnswer(r.answer, r.citations, entities);
95 if (analyzeSentiment) {
96 const mentioned = ents.filter((e) => e.mentioned).map((e) => e.name);
97 try {
98 const c = await classify({ apiKey, model: mdl, prompt: job.prompt, answer: r.answer, names: mentioned, tracked: names });
99 aiCost += c.costUsd; row.aiCostUsd += c.costUsd;
100 for (const e of ents) { const j = e.mentioned ? c.entities.find((x) => x.name.toLowerCase() === e.name.toLowerCase()) : null; e.sentiment = j?.sentiment ?? null; e.recommended = j?.recommended ?? null; }
101 const lower = new Set(entities.flatMap((e) => e.aliases.map((a) => a.toLowerCase())));
102 row.otherBrandsMentioned = [...new Set(c.otherBrands.map((x) => sanitizeText(String(x)).trim()).filter((x) => x && x.length < 80 && !lower.has(x.toLowerCase())))].slice(0, 15);
103 } catch (e) { log.warning(`Sentiment pass failed for one check (${e.message}); mentions and citations are still returned.`); }
104 }
105 const b = ents.find((e) => e.role === 'brand');
106 Object.assign(row, { entities: ents, brandMentioned: b.mentioned, brandRank: b.rank, brandListPosition: b.listPosition, brandCited: b.cited,
107 brandSentiment: b.sentiment ?? null, brandRecommended: b.recommended ?? null,
108 competitorsMentioned: ents.filter((e) => e.role === 'competitor' && e.mentioned).map((e) => e.name),
109 mentionedOrder: ents.filter((e) => e.mentioned).sort((x, y) => x.rank - y.rank).map((e) => e.name),
110 citations: r.citations, citedDomains: [...new Set(r.citations.filter((c) => c.kind === 'cited').map((c) => c.domain).filter(Boolean))],
111 searchQueries: r.searchQueries, answer: includeAnswerText ? r.answer : null, answerLength: r.answer.length, model: r.model,
112 aiCostUsd: +row.aiCostUsd.toFixed(5), checkedAt: new Date().toISOString() });
113 return row;
114 };
115
116 let next = 0; let inflight = 0; let recorded = 0;
117 const worker = async () => {
118 while (next < todo.length && !stop && !fatal) {
119 const job = todo[next++]; inflight++;
120 const row = await one(job); inflight--;
121 if (!row) continue;
122 counts[row.status] = (counts[row.status] ?? 0) + 1; rows.push(row);
123 if (row.status === 'ok') {
124 const res = await Actor.pushData(row, EVENT); charged++;
125 if (res?.eventChargeLimitReached) stop ??= 'Reached your maximum cost per run';
126 } else await Actor.pushData(row);
127 if (budgetKv) {
128 const delta = aiCost - recorded; recorded = aiCost;
129 const cur = await budgetKv.getValue('SPEND');
130 const base = Number(cur?.usd ?? process.env.OWNER_SEED_USD ?? 0);
131 ownerSpent = base + delta - aiCost;
132 await budgetKv.setValue('SPEND', { usd: +(base + delta).toFixed(5), updatedAt: new Date().toISOString() });
133 }
134 }
135 };
136 await Promise.all(Array.from({ length: Math.min(conc, todo.length) }, worker));
137
138
139 const skippedByLimit = jobs.length - todo.length; const skippedByStop = todo.length - rows.length;
140 const freePlanNote = trial.limited && skippedByLimit > 0 ? `Apify free plan: ran ${todo.length} of ${jobs.length} checks with the built-in AI. Add your own OpenAI API key, or use any paid Apify plan, to run all of them.` : null;
141 const notice = [fatal || stop ? `Stopped: ${fatal || stop}; ${skippedByStop} checks not run (not charged).` : null, freePlanNote,
142 !trial.limited && skippedByLimit > 0 ? `${skippedByLimit} checks over the per-run limit of ${trial.cap} were not run.` : null].filter(Boolean).join(' ');
143 if (notice) await Actor.pushData({ engine: 'chatgpt', model: mdl, prompt: null, sample: null, country: cc || null, brand: main.name, status: fatal || stop ? 'stopped' : 'limited',
144 checksRequested: jobs.length, checksRun: rows.length, error: notice, checkedAt: new Date().toISOString() });
145 const summary = { ...summarize(rows, entities), engine: 'chatgpt', model: mdl, country: cc || null, aiKey: byok ? 'own-key' : 'built-in',
146 checksRequested: jobs.length, checksRun: rows.length, charged, byStatus: counts, aiCostUsd: +aiCost.toFixed(5),
147 truncated: skippedByLimit + skippedByStop > 0, checksSkipped: skippedByLimit + skippedByStop, freePlanLimited: !!freePlanNote, notice: notice || null,
148 stoppedReason: fatal || stop || null, finishedAt: new Date().toISOString() };
149 await Actor.setValue('OUTPUT', summary);
150 if (fatal && !charged) throw new EngineError(fatal);
151 const pct = Math.round(100 * (summary.brandMentionRate || 0));
152 exitMsg = `${main.name} was mentioned in ${summary.leaderboard.find((l) => l.role === 'brand')?.mentions ?? 0} of ${counts.ok ?? 0} ChatGPT answers (${pct}%)` +
153 `${counts.error ? `; ${counts.error} checks failed (not charged)` : ''}${fatal || stop ? `. Stopped: ${fatal || stop}` : ''}.${freePlanNote ? ` ${freePlanNote}` : ''}`;
154 if (!counts.ok) exitMsg = `0 results: ${counts.error ? `all ${counts.error} checks failed (not charged); see the error field.` : notice || 'no checks ran.'}`;
155} catch (e) {
156 if (e instanceof InputError) { log.error(e.message); await Actor.setValue('OUTPUT', { error: e.message }); await Actor.fail(`Input error: ${e.message}`); }
157 else if (e instanceof EngineError) { log.error(e.message); await Actor.fail(e.message); }
158 else throw e;
159}
160await Actor.exit(exitMsg);