1import { Actor, log } from 'apify';
2
3
4
5interface ActorInput {
6 brand: string;
7 website?: string;
8 keywords: string[];
9 competitors?: string[];
10 models?: string[];
11}
12
13interface BrandMention {
14 model: string;
15 query: string;
16 brandMentioned: boolean;
17 brandContext: string;
18 brandPosition: number;
19 websiteMentioned: boolean;
20 competitorMentions: { name: string; mentioned: boolean; position: number }[];
21 responseLength: number;
22 responseExcerpt: string;
23 sentiment: 'positive' | 'neutral' | 'negative' | 'none';
24 categories: string[];
25 recommendations: string[];
26 timestamp: string;
27}
28
29interface ActorOutput {
30 brand: string;
31 website: string;
32 scannedAt: string;
33 totalQueries: number;
34 totalMentions: number;
35 mentionRate: number;
36 avgPosition: number;
37 websiteMentionRate: number;
38 positiveSentimentRate: number;
39 competitorResults: { name: string; mentionRate: number; avgPosition: number }[];
40 modelResults: { model: string; queries: number; mentions: number; mentionRate: number }[];
41 results: BrandMention[];
42}
43
44
45
46const LLM_API_KEY = process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || '';
47const LLM_API_BASE = (process.env.LLM_API_BASE || 'https://openrouter.ai/api/v1').replace(/\/$/, '');
48const LLM_ENDPOINT = `${LLM_API_BASE}/chat/completions`;
49const DEFAULT_MODELS = ['nvidia/nemotron-3-super-120b-a12b:free'];
50
51interface LlmResponse {
52 content: string;
53 model: string;
54 success: boolean;
55 error?: string;
56}
57
58async function queryLlm(
59 model: string,
60 prompt: string,
61 apiKey: string,
62 endpoint: string
63): Promise<LlmResponse> {
64 if (!apiKey) {
65 return { content: '', model, success: false, error: 'No API key (set LLM_API_KEY in Actor Environment tab)' };
66 }
67
68 try {
69 const resp = await fetch(endpoint, {
70 method: 'POST',
71 headers: {
72 'Authorization': `Bearer ${apiKey}`,
73 'Content-Type': 'application/json',
74 'HTTP-Referer': 'https://apify.com/upstanding_biobot/ai-citation-monitor',
75 'X-Title': 'AI Citation Monitor',
76 },
77 body: JSON.stringify({
78 model,
79 messages: [{ role: 'user', content: prompt }],
80 stream: false,
81 max_tokens: 4096,
82 temperature: 0.7,
83 }),
84 });
85
86 if (!resp.ok) {
87 const errBody = await resp.text();
88 return { content: '', model, success: false, error: `API ${resp.status}: ${errBody.substring(0, 200)}` };
89 }
90
91 const data = await resp.json() as any;
92 const content = data?.choices?.[0]?.message?.content?.trim() || '';
93 return { content, model, success: true };
94 } catch (error) {
95 return { content: '', model, success: false, error: (error as Error).message };
96 }
97}
98
99
100
101interface BrandAnalysis {
102 brandMentioned: boolean;
103 brandContext: string;
104 brandPosition: number;
105 websiteMentioned: boolean;
106 competitorMentions: { name: string; mentioned: boolean; position: number }[];
107 responseLength: number;
108 responseExcerpt: string;
109 sentiment: 'positive' | 'neutral' | 'negative' | 'none';
110 categories: string[];
111 recommendations: string[];
112}
113
114function analyzeResponse(
115 responseText: string,
116 brand: string,
117 website: string,
118 competitors: string[]
119): BrandAnalysis {
120 const responseLower = responseText.toLowerCase();
121 const brandLower = brand.toLowerCase();
122
123
124 const brandMentioned = responseLower.includes(brandLower);
125 const brandIdx = brandMentioned ? responseLower.indexOf(brandLower) : 0;
126
127
128 const brandPosition = brandMentioned
129 ? Math.min(10, Math.ceil((brandIdx / (responseText.length || 1)) * 10) + 1)
130 : 0;
131
132
133 let brandContext = '';
134 if (brandMentioned) {
135 const start = Math.max(0, brandIdx - 300);
136 const end = Math.min(responseText.length, brandIdx + brand.length + 300);
137 brandContext = '...' + responseText.substring(start, end) + '...';
138 }
139
140
141 let websiteMentioned = false;
142 if (website) {
143 try {
144 const domain = new URL(website).hostname.replace('www.', '');
145 websiteMentioned = responseLower.includes(domain) || responseLower.includes(website.toLowerCase());
146 } catch {
147 websiteMentioned = responseLower.includes(website.toLowerCase());
148 }
149 }
150
151
152 const competitorMentions = competitors.map(name => {
153 const mentioned = responseLower.includes(name.toLowerCase());
154 const idx = mentioned ? responseLower.indexOf(name.toLowerCase()) : 0;
155 const pos = mentioned
156 ? Math.min(10, Math.ceil((idx / (responseText.length || 1)) * 10) + 1)
157 : 0;
158 return { name, mentioned, position: pos };
159 });
160
161
162 let sentiment: 'positive' | 'neutral' | 'negative' | 'none' = 'none';
163 if (brandMentioned) {
164 const contextWindow = responseLower.substring(
165 Math.max(0, brandIdx - 100),
166 Math.min(responseLower.length, brandIdx + brand.length + 100)
167 );
168 const positiveWords = ['best', 'top', 'leading', 'recommended', 'excellent', 'great', 'reliable', 'trusted', 'popular', 'standout', 'superior'];
169 const negativeWords = ['avoid', 'poor', 'bad', 'overpriced', 'unreliable', 'scam', 'complaint', 'lawsuit', 'problem', 'issue', 'controversy'];
170 const hasPositive = positiveWords.some(w => contextWindow.includes(w));
171 const hasNegative = negativeWords.some(w => contextWindow.includes(w));
172 if (hasPositive && !hasNegative) sentiment = 'positive';
173 else if (hasNegative && !hasPositive) sentiment = 'negative';
174 else sentiment = 'neutral';
175 }
176
177
178 const categories: string[] = [];
179 if (responseLower.includes('alternative')) categories.push('alternatives');
180 if (responseLower.includes('best') || responseLower.includes('top')) categories.push('recommendation');
181 if (responseLower.includes('compare') || responseLower.includes('vs')) categories.push('comparison');
182 if (responseLower.includes('how to') || responseLower.includes('guide')) categories.push('how-to');
183 if (responseLower.includes('review')) categories.push('review');
184 if (responseLower.includes('pricing') || responseLower.includes('cost')) categories.push('pricing');
185 if (responseLower.includes('feature') || responseLower.includes('benefit')) categories.push('features');
186
187
188 const recommendations: string[] = [];
189 if (!brandMentioned) {
190 recommendations.push(`Brand "${brand}" not mentioned for this query. Create content targeting this keyword to improve AI visibility.`);
191 }
192 if (brandMentioned && brandPosition > 5) {
193 recommendations.push(`Brand mentioned but late in response (position ${brandPosition}). Improve content authority to rank higher in AI responses.`);
194 }
195 if (website && !websiteMentioned && brandMentioned) {
196 recommendations.push('Brand mentioned but website not cited. Ensure website is well-indexed and linked from authoritative sources.');
197 }
198 if (sentiment === 'negative') {
199 recommendations.push('Negative sentiment detected around brand mention. Monitor online reputation and address reviewed issues.');
200 }
201 const competitorMentionCount = competitorMentions.filter(c => c.mentioned).length;
202 if (competitorMentionCount > 0 && !brandMentioned) {
203 recommendations.push(`${competitorMentionCount} competitors mentioned but your brand is not. Significant AI visibility gap.`);
204 }
205
206 return {
207 brandMentioned,
208 brandContext,
209 brandPosition,
210 websiteMentioned,
211 competitorMentions,
212 responseLength: responseText.length,
213 responseExcerpt: responseText.substring(0, 500),
214 sentiment,
215 categories,
216 recommendations,
217 };
218}
219
220
221
222async function main() {
223 await Actor.init();
224
225 const input = (await Actor.getInput()) as ActorInput;
226
227 if (!input?.brand) {
228 log.error('No brand provided');
229 await Actor.exit('No brand provided', { exitCode: 1 });
230 return;
231 }
232
233 if (!input?.keywords || input.keywords.length === 0) {
234 log.error('No keywords provided');
235 await Actor.exit('No keywords provided', { exitCode: 1 });
236 return;
237 }
238
239 const brand = input.brand;
240 const website = input.website || '';
241 const keywords = input.keywords;
242 const competitors = input.competitors || [];
243 const models = input.models && input.models.length > 0 ? input.models : DEFAULT_MODELS;
244
245
246 if (!LLM_API_KEY) {
247 log.warning('LLM_API_KEY environment variable not set — pushing error results');
248 for (const keyword of keywords) {
249 await Actor.pushData({
250 model: models[0],
251 query: keyword,
252 brandMentioned: false,
253 brandPosition: 0,
254 websiteMentioned: false,
255 sentiment: 'none',
256 categories: [],
257 competitorMentions: competitors.map(c => ({ name: c, mentioned: false, position: 0 })),
258 recommendations: ['LLM_API_KEY not set — add it in Actor Environment tab to run citation checks'],
259 responseLength: 0,
260 timestamp: new Date().toISOString(),
261 });
262 }
263 await Actor.exit('No LLM API key — error results pushed to dataset', { exitCode: 1 });
264 return;
265 }
266
267 log.info(`Monitoring brand "${brand}" via ${LLM_API_BASE}`);
268 log.info(`Models: ${models.join(', ')}`);
269 log.info(`Queries: ${keywords.length}`);
270 log.info(`Competitors: ${competitors.join(', ') || 'none'}`);
271
272 const results: BrandMention[] = [];
273 let failedQueries = 0;
274
275 for (const keyword of keywords) {
276 for (const model of models) {
277 log.info(`\n=== Query: "${keyword}" | Model: ${model} ===`);
278
279
280 const prompt = `You are a helpful assistant. A user is searching for information. Answer the following query naturally and comprehensively, as if you were an AI search engine:
281
282${keyword}
283
284Provide a detailed, helpful response. Mention specific brands, tools, or companies when relevant.`;
285
286 const llmResp = await queryLlm(model, prompt, LLM_API_KEY, LLM_ENDPOINT);
287
288 if (!llmResp.success) {
289 log.warning(` LLM query failed: ${llmResp.error}`);
290 failedQueries++;
291
292
293 await Actor.pushData({
294 model,
295 query: keyword,
296 brandMentioned: false,
297 brandPosition: 0,
298 websiteMentioned: false,
299 sentiment: 'none',
300 categories: [],
301 competitorMentions: competitors.map(c => ({ name: c, mentioned: false, position: 0 })),
302 responseLength: 0,
303 responseExcerpt: `Error: ${llmResp.error}`,
304 recommendations: [`LLM query failed: ${llmResp.error}`],
305 timestamp: new Date().toISOString(),
306 });
307 results.push({
308 model,
309 query: keyword,
310 brandMentioned: false,
311 brandContext: '',
312 brandPosition: 0,
313 websiteMentioned: false,
314 competitorMentions: competitors.map(c => ({ name: c, mentioned: false, position: 0 })),
315 responseLength: 0,
316 responseExcerpt: `Error: ${llmResp.error}`,
317 sentiment: 'none',
318 categories: [],
319 recommendations: ['LLM query failed — see responseExcerpt for the API error'],
320 timestamp: new Date().toISOString(),
321 });
322 continue;
323 }
324
325
326 const analysis = analyzeResponse(llmResp.content, brand, website, competitors);
327
328 log.info(` Brand mentioned: ${analysis.brandMentioned ? 'YES' : 'NO'}`);
329 if (analysis.brandMentioned) {
330 log.info(` Position: ${analysis.brandPosition} | Sentiment: ${analysis.sentiment}`);
331 }
332 log.info(` Website mentioned: ${analysis.websiteMentioned ? 'YES' : 'NO'}`);
333 log.info(` Competitors mentioned: ${analysis.competitorMentions.filter(c => c.mentioned).map(c => c.name).join(', ') || 'none'}`);
334 log.info(` Response length: ${analysis.responseLength} chars`);
335
336 const result: BrandMention = {
337 model,
338 query: keyword,
339 ...analysis,
340 timestamp: new Date().toISOString(),
341 };
342
343 results.push(result);
344
345 await Actor.pushData({
346 model,
347 query: keyword,
348 brandMentioned: result.brandMentioned,
349 brandPosition: result.brandPosition,
350 websiteMentioned: result.websiteMentioned,
351 sentiment: result.sentiment,
352 categories: result.categories,
353 competitorMentions: result.competitorMentions,
354 recommendations: result.recommendations,
355 responseLength: result.responseLength,
356 timestamp: result.timestamp,
357 });
358
359
360 await Actor.charge({ eventName: 'Citation check', count: 1 });
361 }
362 }
363
364
365
366 const totalPlanned = keywords.length * models.length;
367 if (results.length > 0 && failedQueries === totalPlanned) {
368 log.error(`All ${failedQueries} LLM queries failed — failing run instead of exiting cleanly`);
369 await Actor.exit(`All ${failedQueries} LLM queries failed — check LLM_API_KEY and model availability`, { exitCode: 1 });
370 return;
371 }
372
373
374 const totalQueries = results.length;
375 const totalMentions = results.filter(r => r.brandMentioned).length;
376 const mentionRate = totalQueries > 0 ? (totalMentions / totalQueries) * 100 : 0;
377
378 const positions = results.filter(r => r.brandMentioned && r.brandPosition > 0).map(r => r.brandPosition);
379 const avgPosition = positions.length > 0
380 ? Math.round((positions.reduce((a, b) => a + b, 0) / positions.length) * 10) / 10
381 : 0;
382
383 const websiteMentions = results.filter(r => r.websiteMentioned).length;
384 const websiteMentionRate = totalQueries > 0 ? (websiteMentions / totalQueries) * 100 : 0;
385
386 const positiveMentions = results.filter(r => r.brandMentioned && r.sentiment === 'positive').length;
387 const positiveSentimentRate = totalMentions > 0 ? (positiveMentions / totalMentions) * 100 : 0;
388
389
390 const modelResults = models.map(model => {
391 const modelResults = results.filter(r => r.model === model);
392 const mentions = modelResults.filter(r => r.brandMentioned).length;
393 return {
394 model,
395 queries: modelResults.length,
396 mentions,
397 mentionRate: modelResults.length > 0 ? Math.round((mentions / modelResults.length) * 1000) / 10 : 0,
398 };
399 });
400
401
402 const competitorResults = competitors.map(name => {
403 const compResults = results.filter(r => r.competitorMentions.some(c => c.name === name));
404 const mentions = results.filter(r => r.competitorMentions.some(c => c.name === name && c.mentioned)).length;
405 const mentionRate = results.length > 0 ? (mentions / results.length) * 100 : 0;
406 const compPositions = results
407 .flatMap(r => r.competitorMentions.filter(c => c.name === name && c.mentioned && c.position > 0))
408 .map(c => c.position);
409 const avgPos = compPositions.length > 0
410 ? Math.round((compPositions.reduce((a, b) => a + b, 0) / compPositions.length) * 10) / 10
411 : 0;
412 return { name, mentionRate: Math.round(mentionRate * 10) / 10, avgPosition: avgPos };
413 });
414
415 const output: ActorOutput = {
416 brand,
417 website,
418 scannedAt: new Date().toISOString(),
419 totalQueries,
420 totalMentions,
421 mentionRate: Math.round(mentionRate * 10) / 10,
422 avgPosition,
423 websiteMentionRate: Math.round(websiteMentionRate * 10) / 10,
424 positiveSentimentRate: Math.round(positiveSentimentRate * 10) / 10,
425 competitorResults,
426 modelResults,
427 results,
428 };
429
430 const kvStore = await Actor.openKeyValueStore();
431 await kvStore.setValue('OUTPUT', output);
432
433 log.info(`\n=== AI Brand Visibility Report ===`);
434 log.info(`Brand: ${brand}`);
435 log.info(`Total queries: ${totalQueries}`);
436 log.info(`Brand mentioned: ${totalMentions} (${mentionRate.toFixed(1)}%)`);
437 log.info(`Website mentioned: ${websiteMentions} (${websiteMentionRate.toFixed(1)}%)`);
438 log.info(`Avg position: ${avgPosition}`);
439 log.info(`Positive sentiment: ${positiveMentions}/${totalMentions} (${positiveSentimentRate.toFixed(1)}%)`);
440 for (const mr of modelResults) {
441 log.info(` ${mr.model}: ${mr.mentions}/${mr.queries} (${mr.mentionRate}%)`);
442 }
443
444 await Actor.exit();
445}
446
447main().catch(async (error) => {
448 console.error('Fatal error:', error);
449 await Actor.exit('Fatal error', { exitCode: 1 });
450});