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