@rankcli/agent-runtime 0.0.1
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/README.md +242 -0
- package/dist/analyzer-2CSWIQGD.mjs +6 -0
- package/dist/chunk-YNZYHEYM.mjs +774 -0
- package/dist/index.d.mts +4012 -0
- package/dist/index.d.ts +4012 -0
- package/dist/index.js +29672 -0
- package/dist/index.mjs +28602 -0
- package/package.json +53 -0
- package/scripts/build-deno.ts +134 -0
- package/src/audit/ai/analyzer.ts +347 -0
- package/src/audit/ai/index.ts +29 -0
- package/src/audit/ai/prompts/content-analysis.ts +271 -0
- package/src/audit/ai/types.ts +179 -0
- package/src/audit/checks/additional-checks.ts +439 -0
- package/src/audit/checks/ai-citation-worthiness.ts +399 -0
- package/src/audit/checks/ai-content-structure.ts +325 -0
- package/src/audit/checks/ai-readiness.ts +339 -0
- package/src/audit/checks/anchor-text.ts +179 -0
- package/src/audit/checks/answer-conciseness.ts +322 -0
- package/src/audit/checks/asset-minification.ts +270 -0
- package/src/audit/checks/bing-optimization.ts +206 -0
- package/src/audit/checks/brand-mention-optimization.ts +349 -0
- package/src/audit/checks/caching-headers.ts +305 -0
- package/src/audit/checks/canonical-advanced.ts +150 -0
- package/src/audit/checks/canonical-domain.ts +196 -0
- package/src/audit/checks/citation-quality.ts +358 -0
- package/src/audit/checks/client-rendering.ts +542 -0
- package/src/audit/checks/color-contrast.ts +342 -0
- package/src/audit/checks/content-freshness.ts +170 -0
- package/src/audit/checks/content-science.ts +589 -0
- package/src/audit/checks/conversion-elements.ts +526 -0
- package/src/audit/checks/crawlability.ts +220 -0
- package/src/audit/checks/directory-listing.ts +172 -0
- package/src/audit/checks/dom-analysis.ts +191 -0
- package/src/audit/checks/dom-size.ts +246 -0
- package/src/audit/checks/duplicate-content.ts +194 -0
- package/src/audit/checks/eeat-signals.ts +990 -0
- package/src/audit/checks/entity-seo.ts +396 -0
- package/src/audit/checks/featured-snippet.ts +473 -0
- package/src/audit/checks/freshness-signals.ts +443 -0
- package/src/audit/checks/funnel-intent.ts +463 -0
- package/src/audit/checks/hreflang.ts +174 -0
- package/src/audit/checks/html-compliance.ts +302 -0
- package/src/audit/checks/image-dimensions.ts +167 -0
- package/src/audit/checks/images.ts +160 -0
- package/src/audit/checks/indexnow.ts +275 -0
- package/src/audit/checks/interactive-tools.ts +475 -0
- package/src/audit/checks/internal-link-graph.ts +436 -0
- package/src/audit/checks/keyword-analysis.ts +239 -0
- package/src/audit/checks/keyword-cannibalization.ts +385 -0
- package/src/audit/checks/keyword-placement.ts +471 -0
- package/src/audit/checks/links.ts +203 -0
- package/src/audit/checks/llms-txt.ts +224 -0
- package/src/audit/checks/local-seo.ts +296 -0
- package/src/audit/checks/mobile.ts +167 -0
- package/src/audit/checks/modern-images.ts +226 -0
- package/src/audit/checks/navboost-signals.ts +395 -0
- package/src/audit/checks/on-page.ts +209 -0
- package/src/audit/checks/page-resources.ts +285 -0
- package/src/audit/checks/pagination.ts +180 -0
- package/src/audit/checks/performance.ts +153 -0
- package/src/audit/checks/platform-presence.ts +580 -0
- package/src/audit/checks/redirect-analysis.ts +153 -0
- package/src/audit/checks/redirect-chain.ts +389 -0
- package/src/audit/checks/resource-hints.ts +420 -0
- package/src/audit/checks/responsive-css.ts +247 -0
- package/src/audit/checks/responsive-images.ts +396 -0
- package/src/audit/checks/review-ecosystem.ts +415 -0
- package/src/audit/checks/robots-validation.ts +373 -0
- package/src/audit/checks/security-headers.ts +172 -0
- package/src/audit/checks/security.ts +144 -0
- package/src/audit/checks/serp-preview.ts +251 -0
- package/src/audit/checks/site-maturity.ts +444 -0
- package/src/audit/checks/social-meta.test.ts +275 -0
- package/src/audit/checks/social-meta.ts +134 -0
- package/src/audit/checks/soft-404.ts +151 -0
- package/src/audit/checks/structured-data.ts +238 -0
- package/src/audit/checks/tech-detection.ts +496 -0
- package/src/audit/checks/topical-clusters.ts +435 -0
- package/src/audit/checks/tracker-bloat.ts +462 -0
- package/src/audit/checks/tracking-verification.test.ts +371 -0
- package/src/audit/checks/tracking-verification.ts +636 -0
- package/src/audit/checks/url-safety.ts +682 -0
- package/src/audit/deno-entry.ts +66 -0
- package/src/audit/discovery/index.ts +15 -0
- package/src/audit/discovery/link-crawler.ts +232 -0
- package/src/audit/discovery/repo-routes.ts +347 -0
- package/src/audit/engine.ts +620 -0
- package/src/audit/fixes/index.ts +209 -0
- package/src/audit/fixes/social-meta-fixes.test.ts +329 -0
- package/src/audit/fixes/social-meta-fixes.ts +463 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/runner.test.ts +299 -0
- package/src/audit/runner.ts +130 -0
- package/src/audit/types.ts +1953 -0
- package/src/content/featured-snippet.ts +367 -0
- package/src/content/generator.test.ts +534 -0
- package/src/content/generator.ts +501 -0
- package/src/content/headline.ts +317 -0
- package/src/content/index.ts +62 -0
- package/src/content/intent.ts +258 -0
- package/src/content/keyword-density.ts +349 -0
- package/src/content/readability.ts +262 -0
- package/src/executor.ts +336 -0
- package/src/fixer.ts +416 -0
- package/src/frameworks/detector.test.ts +248 -0
- package/src/frameworks/detector.ts +371 -0
- package/src/frameworks/index.ts +68 -0
- package/src/frameworks/recipes/angular.yaml +171 -0
- package/src/frameworks/recipes/astro.yaml +206 -0
- package/src/frameworks/recipes/django.yaml +180 -0
- package/src/frameworks/recipes/laravel.yaml +137 -0
- package/src/frameworks/recipes/nextjs.yaml +268 -0
- package/src/frameworks/recipes/nuxt.yaml +175 -0
- package/src/frameworks/recipes/rails.yaml +188 -0
- package/src/frameworks/recipes/react.yaml +202 -0
- package/src/frameworks/recipes/sveltekit.yaml +154 -0
- package/src/frameworks/recipes/vue.yaml +137 -0
- package/src/frameworks/recipes/wordpress.yaml +209 -0
- package/src/frameworks/suggestion-engine.ts +320 -0
- package/src/geo/geo-content.test.ts +305 -0
- package/src/geo/geo-content.ts +266 -0
- package/src/geo/geo-history.test.ts +473 -0
- package/src/geo/geo-history.ts +433 -0
- package/src/geo/geo-tracker.test.ts +359 -0
- package/src/geo/geo-tracker.ts +411 -0
- package/src/geo/index.ts +10 -0
- package/src/git/commit-helper.test.ts +261 -0
- package/src/git/commit-helper.ts +329 -0
- package/src/git/index.ts +12 -0
- package/src/git/pr-helper.test.ts +284 -0
- package/src/git/pr-helper.ts +307 -0
- package/src/index.ts +66 -0
- package/src/keywords/ai-keyword-engine.ts +1062 -0
- package/src/keywords/ai-summarizer.ts +387 -0
- package/src/keywords/ci-mode.ts +555 -0
- package/src/keywords/engine.ts +359 -0
- package/src/keywords/index.ts +151 -0
- package/src/keywords/llm-judge.ts +357 -0
- package/src/keywords/nlp-analysis.ts +706 -0
- package/src/keywords/prioritizer.ts +295 -0
- package/src/keywords/site-crawler.ts +342 -0
- package/src/keywords/sources/autocomplete.ts +139 -0
- package/src/keywords/sources/competitive-search.ts +450 -0
- package/src/keywords/sources/competitor-analysis.ts +374 -0
- package/src/keywords/sources/dataforseo.ts +206 -0
- package/src/keywords/sources/free-sources.ts +294 -0
- package/src/keywords/sources/gsc.ts +123 -0
- package/src/keywords/topic-grouping.ts +327 -0
- package/src/keywords/types.ts +144 -0
- package/src/keywords/wizard.ts +457 -0
- package/src/loader.ts +40 -0
- package/src/reports/index.ts +7 -0
- package/src/reports/report-generator.test.ts +293 -0
- package/src/reports/report-generator.ts +713 -0
- package/src/scheduler/alerts.test.ts +458 -0
- package/src/scheduler/alerts.ts +328 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/scheduled-audit.test.ts +377 -0
- package/src/scheduler/scheduled-audit.ts +149 -0
- package/src/test/integration-test.ts +325 -0
- package/src/tools/analyzer.ts +373 -0
- package/src/tools/crawl.ts +293 -0
- package/src/tools/files.ts +301 -0
- package/src/tools/h1-fixer.ts +249 -0
- package/src/tools/index.ts +67 -0
- package/src/tracking/github-action.ts +326 -0
- package/src/tracking/google-analytics.ts +265 -0
- package/src/tracking/index.ts +45 -0
- package/src/tracking/report-generator.ts +386 -0
- package/src/tracking/search-console.ts +335 -0
- package/src/types.ts +134 -0
- package/src/utils/http.ts +302 -0
- package/src/wasm-adapter.ts +297 -0
- package/src/wasm-entry.ts +14 -0
- package/tsconfig.json +17 -0
- package/tsup.wasm.config.ts +26 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GEO History Module
|
|
3
|
+
*
|
|
4
|
+
* Tracks LLM visibility over time, detects changes, generates reports,
|
|
5
|
+
* and compares against competitors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { GEOResult, LLMProvider, Sentiment } from './geo-tracker.js';
|
|
9
|
+
|
|
10
|
+
export type GEOAlertType =
|
|
11
|
+
| 'new_mention'
|
|
12
|
+
| 'lost_mention'
|
|
13
|
+
| 'position_improved'
|
|
14
|
+
| 'position_dropped'
|
|
15
|
+
| 'sentiment_changed'
|
|
16
|
+
| 'score_improved'
|
|
17
|
+
| 'score_dropped';
|
|
18
|
+
|
|
19
|
+
export type TrendDirection = 'improving' | 'declining' | 'stable' | 'unknown';
|
|
20
|
+
|
|
21
|
+
export interface GEOHistory {
|
|
22
|
+
brandName?: string;
|
|
23
|
+
results: GEOResult[];
|
|
24
|
+
maxResults?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GEOHistoryOptions {
|
|
28
|
+
brandName?: string;
|
|
29
|
+
maxResults?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GEOTrend {
|
|
33
|
+
direction: TrendDirection;
|
|
34
|
+
averageScore: number;
|
|
35
|
+
mentionRate: number;
|
|
36
|
+
dataPoints: number;
|
|
37
|
+
firstScore?: number;
|
|
38
|
+
lastScore?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GEOAlert {
|
|
42
|
+
type: GEOAlertType;
|
|
43
|
+
message: string;
|
|
44
|
+
provider: LLMProvider;
|
|
45
|
+
keyword: string;
|
|
46
|
+
previousValue?: any;
|
|
47
|
+
currentValue?: any;
|
|
48
|
+
timestamp: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GEOReport {
|
|
52
|
+
brandName?: string;
|
|
53
|
+
totalQueries: number;
|
|
54
|
+
mentionRate: number;
|
|
55
|
+
averageScore: number;
|
|
56
|
+
byProvider: Record<string, ProviderStats>;
|
|
57
|
+
byKeyword: Record<string, KeywordStats>;
|
|
58
|
+
generatedAt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ProviderStats {
|
|
62
|
+
queries: number;
|
|
63
|
+
mentions: number;
|
|
64
|
+
mentionRate: number;
|
|
65
|
+
averageScore: number;
|
|
66
|
+
averagePosition: number | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface KeywordStats {
|
|
70
|
+
queries: number;
|
|
71
|
+
mentions: number;
|
|
72
|
+
mentionRate: number;
|
|
73
|
+
averageScore: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CompetitorComparison {
|
|
77
|
+
brand: string;
|
|
78
|
+
competitor: string;
|
|
79
|
+
brandMentionRate: number;
|
|
80
|
+
competitorMentionRate: number;
|
|
81
|
+
brandAverageScore: number;
|
|
82
|
+
competitorAverageScore: number;
|
|
83
|
+
brandAveragePosition: number | null;
|
|
84
|
+
competitorAveragePosition: number | null;
|
|
85
|
+
winner: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a new GEO history instance
|
|
90
|
+
*/
|
|
91
|
+
export function createGEOHistory(options: GEOHistoryOptions = {}): GEOHistory {
|
|
92
|
+
return {
|
|
93
|
+
brandName: options.brandName,
|
|
94
|
+
results: [],
|
|
95
|
+
maxResults: options.maxResults,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Add a tracking result to history
|
|
101
|
+
*/
|
|
102
|
+
export function addTrackingResult(
|
|
103
|
+
history: GEOHistory,
|
|
104
|
+
result: GEOResult
|
|
105
|
+
): GEOHistory {
|
|
106
|
+
const newResults = [...history.results, result];
|
|
107
|
+
|
|
108
|
+
// Sort by timestamp
|
|
109
|
+
newResults.sort((a, b) =>
|
|
110
|
+
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Limit size if maxResults is set
|
|
114
|
+
let finalResults = newResults;
|
|
115
|
+
if (history.maxResults && newResults.length > history.maxResults) {
|
|
116
|
+
// Keep most recent results
|
|
117
|
+
finalResults = newResults.slice(-history.maxResults);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...history,
|
|
122
|
+
results: finalResults,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get visibility trend for a specific provider and keyword
|
|
128
|
+
*/
|
|
129
|
+
export function getVisibilityTrend(
|
|
130
|
+
history: GEOHistory,
|
|
131
|
+
provider: LLMProvider,
|
|
132
|
+
keyword: string
|
|
133
|
+
): GEOTrend {
|
|
134
|
+
const filtered = history.results.filter(
|
|
135
|
+
r => r.provider === provider && r.keyword === keyword
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (filtered.length === 0) {
|
|
139
|
+
return {
|
|
140
|
+
direction: 'unknown',
|
|
141
|
+
averageScore: 0,
|
|
142
|
+
mentionRate: 0,
|
|
143
|
+
dataPoints: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const scores = filtered.map(r => r.score);
|
|
148
|
+
const mentions = filtered.filter(r => r.mentioned).length;
|
|
149
|
+
const averageScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
150
|
+
const mentionRate = (mentions / filtered.length) * 100;
|
|
151
|
+
|
|
152
|
+
// Calculate trend direction based on score progression
|
|
153
|
+
const direction = calculateTrendDirection(scores);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
direction,
|
|
157
|
+
averageScore,
|
|
158
|
+
mentionRate,
|
|
159
|
+
dataPoints: filtered.length,
|
|
160
|
+
firstScore: scores[0],
|
|
161
|
+
lastScore: scores[scores.length - 1],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Calculate trend direction from scores
|
|
167
|
+
*/
|
|
168
|
+
function calculateTrendDirection(scores: number[]): TrendDirection {
|
|
169
|
+
if (scores.length < 2) {
|
|
170
|
+
return 'unknown';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Compare first half average to second half average
|
|
174
|
+
const mid = Math.floor(scores.length / 2);
|
|
175
|
+
const firstHalf = scores.slice(0, mid);
|
|
176
|
+
const secondHalf = scores.slice(mid);
|
|
177
|
+
|
|
178
|
+
const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length;
|
|
179
|
+
const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length;
|
|
180
|
+
|
|
181
|
+
const diff = secondAvg - firstAvg;
|
|
182
|
+
const threshold = 10; // 10 point change threshold
|
|
183
|
+
|
|
184
|
+
if (diff > threshold) {
|
|
185
|
+
return 'improving';
|
|
186
|
+
} else if (diff < -threshold) {
|
|
187
|
+
return 'declining';
|
|
188
|
+
}
|
|
189
|
+
return 'stable';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Detect visibility changes between history and new result
|
|
194
|
+
*/
|
|
195
|
+
export function detectVisibilityChanges(
|
|
196
|
+
history: GEOHistory,
|
|
197
|
+
newResult: GEOResult
|
|
198
|
+
): GEOAlert[] {
|
|
199
|
+
const alerts: GEOAlert[] = [];
|
|
200
|
+
|
|
201
|
+
// Get recent results for same provider/keyword
|
|
202
|
+
const recentResults = history.results.filter(
|
|
203
|
+
r => r.provider === newResult.provider && r.keyword === newResult.keyword
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (recentResults.length === 0) {
|
|
207
|
+
return alerts;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const lastResult = recentResults[recentResults.length - 1];
|
|
211
|
+
|
|
212
|
+
// Check for new mention
|
|
213
|
+
if (!lastResult.mentioned && newResult.mentioned) {
|
|
214
|
+
alerts.push({
|
|
215
|
+
type: 'new_mention',
|
|
216
|
+
message: `${newResult.provider} now mentions your brand for "${newResult.keyword}"`,
|
|
217
|
+
provider: newResult.provider,
|
|
218
|
+
keyword: newResult.keyword,
|
|
219
|
+
previousValue: false,
|
|
220
|
+
currentValue: true,
|
|
221
|
+
timestamp: newResult.timestamp,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check for lost mention
|
|
226
|
+
if (lastResult.mentioned && !newResult.mentioned) {
|
|
227
|
+
alerts.push({
|
|
228
|
+
type: 'lost_mention',
|
|
229
|
+
message: `${newResult.provider} no longer mentions your brand for "${newResult.keyword}"`,
|
|
230
|
+
provider: newResult.provider,
|
|
231
|
+
keyword: newResult.keyword,
|
|
232
|
+
previousValue: true,
|
|
233
|
+
currentValue: false,
|
|
234
|
+
timestamp: newResult.timestamp,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check for position changes (only if both mentioned)
|
|
239
|
+
if (lastResult.mentioned && newResult.mentioned &&
|
|
240
|
+
lastResult.position !== null && newResult.position !== null) {
|
|
241
|
+
const positionDiff = lastResult.position - newResult.position;
|
|
242
|
+
|
|
243
|
+
if (positionDiff >= 2) {
|
|
244
|
+
alerts.push({
|
|
245
|
+
type: 'position_improved',
|
|
246
|
+
message: `Position improved from ${lastResult.position} to ${newResult.position} on ${newResult.provider}`,
|
|
247
|
+
provider: newResult.provider,
|
|
248
|
+
keyword: newResult.keyword,
|
|
249
|
+
previousValue: lastResult.position,
|
|
250
|
+
currentValue: newResult.position,
|
|
251
|
+
timestamp: newResult.timestamp,
|
|
252
|
+
});
|
|
253
|
+
} else if (positionDiff <= -2) {
|
|
254
|
+
alerts.push({
|
|
255
|
+
type: 'position_dropped',
|
|
256
|
+
message: `Position dropped from ${lastResult.position} to ${newResult.position} on ${newResult.provider}`,
|
|
257
|
+
provider: newResult.provider,
|
|
258
|
+
keyword: newResult.keyword,
|
|
259
|
+
previousValue: lastResult.position,
|
|
260
|
+
currentValue: newResult.position,
|
|
261
|
+
timestamp: newResult.timestamp,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check for sentiment changes
|
|
267
|
+
if (lastResult.sentiment && newResult.sentiment &&
|
|
268
|
+
lastResult.sentiment !== newResult.sentiment) {
|
|
269
|
+
alerts.push({
|
|
270
|
+
type: 'sentiment_changed',
|
|
271
|
+
message: `Sentiment changed from ${lastResult.sentiment} to ${newResult.sentiment} on ${newResult.provider}`,
|
|
272
|
+
provider: newResult.provider,
|
|
273
|
+
keyword: newResult.keyword,
|
|
274
|
+
previousValue: lastResult.sentiment,
|
|
275
|
+
currentValue: newResult.sentiment,
|
|
276
|
+
timestamp: newResult.timestamp,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return alerts;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Generate a summary report from history
|
|
285
|
+
*/
|
|
286
|
+
export function generateGEOReport(history: GEOHistory): GEOReport {
|
|
287
|
+
const results = history.results;
|
|
288
|
+
|
|
289
|
+
if (results.length === 0) {
|
|
290
|
+
return {
|
|
291
|
+
brandName: history.brandName,
|
|
292
|
+
totalQueries: 0,
|
|
293
|
+
mentionRate: 0,
|
|
294
|
+
averageScore: 0,
|
|
295
|
+
byProvider: {},
|
|
296
|
+
byKeyword: {},
|
|
297
|
+
generatedAt: new Date().toISOString(),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Calculate overall stats
|
|
302
|
+
const mentions = results.filter(r => r.mentioned).length;
|
|
303
|
+
const mentionRate = (mentions / results.length) * 100;
|
|
304
|
+
const averageScore = results.reduce((a, b) => a + b.score, 0) / results.length;
|
|
305
|
+
|
|
306
|
+
// Group by provider
|
|
307
|
+
const byProvider: Record<string, ProviderStats> = {};
|
|
308
|
+
const providerGroups = groupBy(results, r => r.provider);
|
|
309
|
+
|
|
310
|
+
for (const [provider, providerResults] of Object.entries(providerGroups)) {
|
|
311
|
+
const providerMentions = providerResults.filter(r => r.mentioned).length;
|
|
312
|
+
const positions = providerResults
|
|
313
|
+
.filter(r => r.position !== null)
|
|
314
|
+
.map(r => r.position as number);
|
|
315
|
+
|
|
316
|
+
byProvider[provider] = {
|
|
317
|
+
queries: providerResults.length,
|
|
318
|
+
mentions: providerMentions,
|
|
319
|
+
mentionRate: (providerMentions / providerResults.length) * 100,
|
|
320
|
+
averageScore: providerResults.reduce((a, b) => a + b.score, 0) / providerResults.length,
|
|
321
|
+
averagePosition: positions.length > 0
|
|
322
|
+
? positions.reduce((a, b) => a + b, 0) / positions.length
|
|
323
|
+
: null,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Group by keyword
|
|
328
|
+
const byKeyword: Record<string, KeywordStats> = {};
|
|
329
|
+
const keywordGroups = groupBy(results, r => r.keyword);
|
|
330
|
+
|
|
331
|
+
for (const [keyword, keywordResults] of Object.entries(keywordGroups)) {
|
|
332
|
+
const keywordMentions = keywordResults.filter(r => r.mentioned).length;
|
|
333
|
+
|
|
334
|
+
byKeyword[keyword] = {
|
|
335
|
+
queries: keywordResults.length,
|
|
336
|
+
mentions: keywordMentions,
|
|
337
|
+
mentionRate: (keywordMentions / keywordResults.length) * 100,
|
|
338
|
+
averageScore: keywordResults.reduce((a, b) => a + b.score, 0) / keywordResults.length,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
brandName: history.brandName,
|
|
344
|
+
totalQueries: results.length,
|
|
345
|
+
mentionRate,
|
|
346
|
+
averageScore,
|
|
347
|
+
byProvider,
|
|
348
|
+
byKeyword,
|
|
349
|
+
generatedAt: new Date().toISOString(),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Compare brand visibility against a competitor
|
|
355
|
+
*/
|
|
356
|
+
export function compareCompetitorVisibility(
|
|
357
|
+
brandHistory: GEOHistory,
|
|
358
|
+
competitorName: string,
|
|
359
|
+
competitorResults: GEOResult[]
|
|
360
|
+
): CompetitorComparison {
|
|
361
|
+
const brandResults = brandHistory.results;
|
|
362
|
+
|
|
363
|
+
// Brand stats
|
|
364
|
+
const brandMentions = brandResults.filter(r => r.mentioned).length;
|
|
365
|
+
const brandMentionRate = brandResults.length > 0
|
|
366
|
+
? (brandMentions / brandResults.length) * 100
|
|
367
|
+
: 0;
|
|
368
|
+
const brandAvgScore = brandResults.length > 0
|
|
369
|
+
? brandResults.reduce((a, b) => a + b.score, 0) / brandResults.length
|
|
370
|
+
: 0;
|
|
371
|
+
const brandPositions = brandResults
|
|
372
|
+
.filter(r => r.position !== null)
|
|
373
|
+
.map(r => r.position as number);
|
|
374
|
+
const brandAvgPosition = brandPositions.length > 0
|
|
375
|
+
? brandPositions.reduce((a, b) => a + b, 0) / brandPositions.length
|
|
376
|
+
: null;
|
|
377
|
+
|
|
378
|
+
// Competitor stats
|
|
379
|
+
const compMentions = competitorResults.filter(r => r.mentioned).length;
|
|
380
|
+
const compMentionRate = competitorResults.length > 0
|
|
381
|
+
? (compMentions / competitorResults.length) * 100
|
|
382
|
+
: 0;
|
|
383
|
+
const compAvgScore = competitorResults.length > 0
|
|
384
|
+
? competitorResults.reduce((a, b) => a + b.score, 0) / competitorResults.length
|
|
385
|
+
: 0;
|
|
386
|
+
const compPositions = competitorResults
|
|
387
|
+
.filter(r => r.position !== null)
|
|
388
|
+
.map(r => r.position as number);
|
|
389
|
+
const compAvgPosition = compPositions.length > 0
|
|
390
|
+
? compPositions.reduce((a, b) => a + b, 0) / compPositions.length
|
|
391
|
+
: null;
|
|
392
|
+
|
|
393
|
+
// Determine winner
|
|
394
|
+
let winner = brandHistory.brandName || 'Brand';
|
|
395
|
+
if (compMentionRate > brandMentionRate) {
|
|
396
|
+
winner = competitorName;
|
|
397
|
+
} else if (compMentionRate === brandMentionRate) {
|
|
398
|
+
// Tie-breaker: average position (lower is better)
|
|
399
|
+
if (compAvgPosition !== null && brandAvgPosition !== null) {
|
|
400
|
+
if (compAvgPosition < brandAvgPosition) {
|
|
401
|
+
winner = competitorName;
|
|
402
|
+
}
|
|
403
|
+
} else if (compAvgScore > brandAvgScore) {
|
|
404
|
+
winner = competitorName;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
brand: brandHistory.brandName || 'Brand',
|
|
410
|
+
competitor: competitorName,
|
|
411
|
+
brandMentionRate,
|
|
412
|
+
competitorMentionRate: compMentionRate,
|
|
413
|
+
brandAverageScore: brandAvgScore,
|
|
414
|
+
competitorAverageScore: compAvgScore,
|
|
415
|
+
brandAveragePosition: brandAvgPosition,
|
|
416
|
+
competitorAveragePosition: compAvgPosition,
|
|
417
|
+
winner,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Helper: Group array by key
|
|
423
|
+
*/
|
|
424
|
+
function groupBy<T>(array: T[], keyFn: (item: T) => string): Record<string, T[]> {
|
|
425
|
+
return array.reduce((acc, item) => {
|
|
426
|
+
const key = keyFn(item);
|
|
427
|
+
if (!acc[key]) {
|
|
428
|
+
acc[key] = [];
|
|
429
|
+
}
|
|
430
|
+
acc[key].push(item);
|
|
431
|
+
return acc;
|
|
432
|
+
}, {} as Record<string, T[]>);
|
|
433
|
+
}
|