geo-ai-search-optimization 2.5.0 → 2.7.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/README.md +1 -1
- package/action.yml +1 -1
- package/package.json +1 -1
- package/src/ai-snippet-simulator.js +359 -0
- package/src/alert-rules.js +248 -0
- package/src/backlink-profile.js +378 -0
- package/src/bulk-optimize.js +357 -0
- package/src/canonical-resolver.js +328 -0
- package/src/cli-site-ops-commands.js +351 -2
- package/src/content-rewriter.js +580 -0
- package/src/dashboard-html.js +339 -0
- package/src/fetch-utils.js +1 -1
- package/src/geo-score-api.js +201 -0
- package/src/image-audit.js +342 -0
- package/src/index.d.ts +519 -0
- package/src/index.js +15 -0
- package/src/keyword-gap.js +393 -0
- package/src/multi-lang-audit.js +321 -0
- package/src/pdf-report.js +1 -1
- package/src/score-history.js +234 -0
- package/src/sitemap-generator.js +294 -0
- package/src/slack-report.js +254 -0
- package/src/structured-data-generator.js +532 -0
- package/src/watch-competitors.js +207 -0
package/README.md
CHANGED
|
@@ -193,7 +193,7 @@ Full TypeScript declarations included (`index.d.ts`) — 230+ exports with IDE a
|
|
|
193
193
|
## GitHub Action
|
|
194
194
|
|
|
195
195
|
```yaml
|
|
196
|
-
- uses: redredchen01/geo-ai-search-optimization@v2.
|
|
196
|
+
- uses: redredchen01/geo-ai-search-optimization@v2.7.0
|
|
197
197
|
with:
|
|
198
198
|
project-path: ./your-project
|
|
199
199
|
min-score: 60
|
package/action.yml
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fetchText } from "./fetch-utils.js";
|
|
4
|
+
import { writeScanOutput } from "./scan.js";
|
|
5
|
+
|
|
6
|
+
function stripHtml(text) {
|
|
7
|
+
return text
|
|
8
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
9
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
10
|
+
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
|
|
11
|
+
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
|
|
12
|
+
.replace(/<[^>]+>/g, " ")
|
|
13
|
+
.replace(/&[a-z0-9#]+;/gi, " ")
|
|
14
|
+
.replace(/\s+/g, " ")
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractPlainText(content) {
|
|
19
|
+
if (/<html|<head|<body/i.test(content)) {
|
|
20
|
+
return stripHtml(content);
|
|
21
|
+
}
|
|
22
|
+
return content.replace(/^---[\s\S]*?---/, "").trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract headings and their positions so we can detect "follows a heading".
|
|
27
|
+
*/
|
|
28
|
+
function extractHeadingPositions(content) {
|
|
29
|
+
const positions = [];
|
|
30
|
+
const regex = /<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi;
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = regex.exec(content)) !== null) {
|
|
33
|
+
positions.push(match.index);
|
|
34
|
+
}
|
|
35
|
+
// Also match markdown headings
|
|
36
|
+
const mdRegex = /^#{1,6}\s+.+$/gm;
|
|
37
|
+
while ((match = mdRegex.exec(content)) !== null) {
|
|
38
|
+
positions.push(match.index);
|
|
39
|
+
}
|
|
40
|
+
return positions;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function splitSentences(text) {
|
|
44
|
+
return text
|
|
45
|
+
.split(/(?<=[.!?])\s+/)
|
|
46
|
+
.map((s) => s.trim())
|
|
47
|
+
.filter((s) => s.length > 10);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NUMBER_STAT_PATTERN = /\b\d+(\.\d+)?\s*(%|percent|million|billion|thousand|x|times|fold)\b/i;
|
|
51
|
+
const NUMBER_PATTERN = /\b\d{2,}(\.\d+)?\b/;
|
|
52
|
+
const DATE_PATTERN = /\b(20[12]\d|19\d\d)\b/;
|
|
53
|
+
const DEFINITIVE_PATTERN = /\b(is|are|was|were|means?|refers? to|defined as|according to|known as)\b/i;
|
|
54
|
+
const SELF_CONTAINED_PATTERN = /^[A-Z][A-Za-z]+\s+(is|are|was|were|has|have|had|can|could|will|would|should|must|means?)\b/;
|
|
55
|
+
const PROPER_NOUN_PATTERN = /\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b/;
|
|
56
|
+
const NAV_JUNK_PATTERN = /\b(click here|read more|learn more|sign up|subscribe|menu|navigation|skip to|cookie|privacy policy)\b/i;
|
|
57
|
+
|
|
58
|
+
function scoreSentence(sentence, index, totalSentences, followsHeading) {
|
|
59
|
+
let score = 0;
|
|
60
|
+
const reasons = [];
|
|
61
|
+
const wordCount = sentence.split(/\s+/).length;
|
|
62
|
+
|
|
63
|
+
// +25 if contains number/stat/percentage
|
|
64
|
+
if (NUMBER_STAT_PATTERN.test(sentence) || (NUMBER_PATTERN.test(sentence) && DATE_PATTERN.test(sentence))) {
|
|
65
|
+
score += 25;
|
|
66
|
+
reasons.push("contains numbers/stats");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// +20 if self-contained (starts with subject, has verb)
|
|
70
|
+
if (SELF_CONTAINED_PATTERN.test(sentence)) {
|
|
71
|
+
score += 20;
|
|
72
|
+
reasons.push("self-contained");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// +15 if uses definitive language
|
|
76
|
+
if (DEFINITIVE_PATTERN.test(sentence)) {
|
|
77
|
+
score += 15;
|
|
78
|
+
reasons.push("definitive language");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// +15 if length 20-60 words (optimal snippet length)
|
|
82
|
+
if (wordCount >= 20 && wordCount <= 60) {
|
|
83
|
+
score += 15;
|
|
84
|
+
reasons.push("optimal length");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// +10 if in first 30% of content
|
|
88
|
+
if (totalSentences > 0 && index / totalSentences < 0.3) {
|
|
89
|
+
score += 10;
|
|
90
|
+
reasons.push("early position");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// +10 if follows a heading
|
|
94
|
+
if (followsHeading) {
|
|
95
|
+
score += 10;
|
|
96
|
+
reasons.push("follows heading");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// +5 if contains a proper noun
|
|
100
|
+
if (PROPER_NOUN_PATTERN.test(sentence)) {
|
|
101
|
+
score += 5;
|
|
102
|
+
reasons.push("proper noun");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// -20 if contains navigation text
|
|
106
|
+
if (NAV_JUNK_PATTERN.test(sentence)) {
|
|
107
|
+
score -= 20;
|
|
108
|
+
reasons.push("navigation/junk text");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { score: Math.max(0, Math.min(100, score)), reasons };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildFollowsHeadingMap(plainText, sentences, rawContent) {
|
|
115
|
+
const headingPositions = extractHeadingPositions(rawContent);
|
|
116
|
+
const map = new Map();
|
|
117
|
+
|
|
118
|
+
for (let i = 0; i < sentences.length; i++) {
|
|
119
|
+
const sentencePos = plainText.indexOf(sentences[i]);
|
|
120
|
+
let follows = false;
|
|
121
|
+
// Check if any heading appears shortly before this sentence in raw content
|
|
122
|
+
// Simplified: first sentence and sentences right after a heading-like pattern
|
|
123
|
+
if (i === 0) {
|
|
124
|
+
follows = true;
|
|
125
|
+
} else {
|
|
126
|
+
const prevSentence = sentences[i - 1];
|
|
127
|
+
// If previous text ends near a heading boundary, mark as follows heading
|
|
128
|
+
if (headingPositions.length > 0) {
|
|
129
|
+
follows = i <= headingPositions.length;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
map.set(i, follows);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return map;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function generateSimulatedSnippets(topQuotables, source) {
|
|
139
|
+
const bestSentence = topQuotables[0]?.sentence || "No quotable content found.";
|
|
140
|
+
const secondBest = topQuotables[1]?.sentence || bestSentence;
|
|
141
|
+
const domain = extractDomain(source);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
chatgpt: {
|
|
145
|
+
snippet: `According to ${domain}, ${lowerFirst(bestSentence)} ${secondBest.length < 200 ? secondBest : ""}`.trim(),
|
|
146
|
+
citation: `Source: ${source}`
|
|
147
|
+
},
|
|
148
|
+
perplexity: {
|
|
149
|
+
snippet: `"${bestSentence}" [1]`,
|
|
150
|
+
citation: `[1] ${source}`
|
|
151
|
+
},
|
|
152
|
+
googleAio: {
|
|
153
|
+
snippet: `${bestSentence} ${topQuotables[2]?.sentence || ""}`.trim(),
|
|
154
|
+
citation: source
|
|
155
|
+
},
|
|
156
|
+
gemini: {
|
|
157
|
+
snippet: bestSentence,
|
|
158
|
+
citation: `Based on information from ${domain}`
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function extractDomain(source) {
|
|
164
|
+
try {
|
|
165
|
+
return new URL(source).hostname;
|
|
166
|
+
} catch {
|
|
167
|
+
return path.basename(source);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function lowerFirst(str) {
|
|
172
|
+
if (!str) return str;
|
|
173
|
+
// Don't lowercase if starts with proper noun or acronym
|
|
174
|
+
if (/^[A-Z]{2,}/.test(str) || /^[A-Z][a-z]+\s+[A-Z]/.test(str)) return str;
|
|
175
|
+
return str[0].toLowerCase() + str.slice(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getScoreLabel(score) {
|
|
179
|
+
if (score >= 80) return "Highly Quotable";
|
|
180
|
+
if (score >= 60) return "Quotable";
|
|
181
|
+
if (score >= 40) return "Partially Quotable";
|
|
182
|
+
return "Low Quotability";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildRecommendations(topQuotables, sentences, score) {
|
|
186
|
+
const recs = [];
|
|
187
|
+
const avgScore = topQuotables.length > 0
|
|
188
|
+
? Math.round(topQuotables.reduce((s, q) => s + q.score, 0) / topQuotables.length)
|
|
189
|
+
: 0;
|
|
190
|
+
|
|
191
|
+
if (topQuotables.length < 3) {
|
|
192
|
+
recs.push("Add more self-contained, factual sentences that AI engines can directly quote.");
|
|
193
|
+
}
|
|
194
|
+
if (avgScore < 40) {
|
|
195
|
+
recs.push("Strengthen sentences with specific numbers, statistics, or data points.");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const hasDefinitive = topQuotables.some((q) => q.reason.includes("definitive language"));
|
|
199
|
+
if (!hasDefinitive) {
|
|
200
|
+
recs.push("Use definitive language patterns like 'X is...', 'X means...', 'According to...' for clearer AI extraction.");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const hasOptimalLength = topQuotables.some((q) => q.reason.includes("optimal length"));
|
|
204
|
+
if (!hasOptimalLength) {
|
|
205
|
+
recs.push("Write key sentences in the 20-60 word range for optimal AI snippet length.");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const hasNumbers = topQuotables.some((q) => q.reason.includes("numbers/stats"));
|
|
209
|
+
if (!hasNumbers) {
|
|
210
|
+
recs.push("Include specific numbers, percentages, or statistics to increase citation likelihood.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (sentences.length < 10) {
|
|
214
|
+
recs.push("Expand content with more informative sentences to give AI engines more material to cite.");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const earlyQuotables = topQuotables.filter((q) => q.reason.includes("early position"));
|
|
218
|
+
if (earlyQuotables.length === 0 && topQuotables.length > 0) {
|
|
219
|
+
recs.push("Move your strongest factual claims earlier in the content for position bias advantage.");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return recs;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function simulateAiSnippet(input, options = {}) {
|
|
226
|
+
let rawContent;
|
|
227
|
+
let source;
|
|
228
|
+
|
|
229
|
+
if (/^https?:\/\//i.test(input)) {
|
|
230
|
+
rawContent = await fetchText(input);
|
|
231
|
+
source = input;
|
|
232
|
+
} else {
|
|
233
|
+
const filePath = path.resolve(input);
|
|
234
|
+
rawContent = await fs.readFile(filePath, "utf8");
|
|
235
|
+
source = filePath;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const plainText = extractPlainText(rawContent);
|
|
239
|
+
const sentences = splitSentences(plainText);
|
|
240
|
+
const followsHeadingMap = buildFollowsHeadingMap(plainText, sentences, rawContent);
|
|
241
|
+
|
|
242
|
+
// Score all sentences
|
|
243
|
+
const scored = sentences.map((sentence, index) => {
|
|
244
|
+
const { score, reasons } = scoreSentence(
|
|
245
|
+
sentence,
|
|
246
|
+
index,
|
|
247
|
+
sentences.length,
|
|
248
|
+
followsHeadingMap.get(index) || false
|
|
249
|
+
);
|
|
250
|
+
return {
|
|
251
|
+
sentence: sentence.slice(0, 300),
|
|
252
|
+
score,
|
|
253
|
+
reason: reasons.join(", ") || "baseline",
|
|
254
|
+
position: index
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Sort by score descending, take top quotables
|
|
259
|
+
const topQuotables = scored
|
|
260
|
+
.filter((s) => s.score > 0)
|
|
261
|
+
.sort((a, b) => b.score - a.score)
|
|
262
|
+
.slice(0, 10);
|
|
263
|
+
|
|
264
|
+
// Generate simulated snippets
|
|
265
|
+
const simulatedSnippets = generateSimulatedSnippets(topQuotables, source);
|
|
266
|
+
|
|
267
|
+
// Snippet readiness
|
|
268
|
+
const top5 = topQuotables.slice(0, 5);
|
|
269
|
+
const avgScore = top5.length > 0
|
|
270
|
+
? Math.round(top5.reduce((s, q) => s + q.score, 0) / top5.length)
|
|
271
|
+
: 0;
|
|
272
|
+
const topScore = top5.length > 0 ? top5[0].score : 0;
|
|
273
|
+
|
|
274
|
+
const overallScore = Math.round(Math.min(avgScore, 100));
|
|
275
|
+
const scoreLabel = getScoreLabel(overallScore);
|
|
276
|
+
const recommendations = buildRecommendations(topQuotables, sentences, overallScore);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
kind: "geo-ai-snippet-simulator",
|
|
280
|
+
source,
|
|
281
|
+
topQuotables,
|
|
282
|
+
simulatedSnippets,
|
|
283
|
+
snippetReadiness: {
|
|
284
|
+
quotableSentences: topQuotables.length,
|
|
285
|
+
avgScore,
|
|
286
|
+
topScore
|
|
287
|
+
},
|
|
288
|
+
recommendations,
|
|
289
|
+
score: overallScore,
|
|
290
|
+
scoreLabel,
|
|
291
|
+
summary: `Snippet readiness: ${overallScore}/100 (${scoreLabel}). ${topQuotables.length} quotable sentences found. ${recommendations[0] || "Content is well-optimized for AI snippet extraction."}`
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function renderAiSnippetMarkdown(report) {
|
|
296
|
+
const lines = [
|
|
297
|
+
"# AI Snippet Simulator",
|
|
298
|
+
"",
|
|
299
|
+
`- Source: \`${report.source}\``,
|
|
300
|
+
`- Snippet Readiness Score: \`${report.score}/100\` (${report.scoreLabel})`,
|
|
301
|
+
`- Quotable Sentences: \`${report.snippetReadiness.quotableSentences}\``,
|
|
302
|
+
`- Average Score (top 5): \`${report.snippetReadiness.avgScore}\``,
|
|
303
|
+
`- Top Score: \`${report.snippetReadiness.topScore}\``,
|
|
304
|
+
"",
|
|
305
|
+
"## Simulated AI Snippets",
|
|
306
|
+
"",
|
|
307
|
+
"### ChatGPT Style",
|
|
308
|
+
"",
|
|
309
|
+
`> ${report.simulatedSnippets.chatgpt.snippet}`,
|
|
310
|
+
"",
|
|
311
|
+
`*${report.simulatedSnippets.chatgpt.citation}*`,
|
|
312
|
+
"",
|
|
313
|
+
"### Perplexity Style",
|
|
314
|
+
"",
|
|
315
|
+
`> ${report.simulatedSnippets.perplexity.snippet}`,
|
|
316
|
+
"",
|
|
317
|
+
`*${report.simulatedSnippets.perplexity.citation}*`,
|
|
318
|
+
"",
|
|
319
|
+
"### Google AI Overviews Style",
|
|
320
|
+
"",
|
|
321
|
+
`> ${report.simulatedSnippets.googleAio.snippet}`,
|
|
322
|
+
"",
|
|
323
|
+
`*${report.simulatedSnippets.googleAio.citation}*`,
|
|
324
|
+
"",
|
|
325
|
+
"### Gemini Style",
|
|
326
|
+
"",
|
|
327
|
+
`> ${report.simulatedSnippets.gemini.snippet}`,
|
|
328
|
+
"",
|
|
329
|
+
`*${report.simulatedSnippets.gemini.citation}*`,
|
|
330
|
+
"",
|
|
331
|
+
"## Top Quotable Sentences",
|
|
332
|
+
""
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
if (report.topQuotables.length === 0) {
|
|
336
|
+
lines.push("- No highly quotable sentences detected.");
|
|
337
|
+
} else {
|
|
338
|
+
for (const q of report.topQuotables) {
|
|
339
|
+
lines.push(`- (score ${q.score}, pos ${q.position}) ${q.sentence}`);
|
|
340
|
+
lines.push(` *Reason: ${q.reason}*`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
lines.push("", "## Recommendations", "");
|
|
345
|
+
if (report.recommendations.length === 0) {
|
|
346
|
+
lines.push("- Content is well-optimized for AI snippet extraction.");
|
|
347
|
+
} else {
|
|
348
|
+
for (const rec of report.recommendations) {
|
|
349
|
+
lines.push(`- ${rec}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
lines.push("");
|
|
354
|
+
return lines.join("\n");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function writeAiSnippetOutput(outputPath, content) {
|
|
358
|
+
return writeScanOutput(outputPath, content);
|
|
359
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeScanOutput } from "./scan.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_RULES = [
|
|
6
|
+
{ name: "Low Score", condition: "score-below", threshold: 40, action: "console" },
|
|
7
|
+
{ name: "Score Drop", condition: "score-drop", threshold: 10, action: "console" },
|
|
8
|
+
{ name: "Weak Citability", condition: "dimension-below", threshold: 30, dimension: "citability", action: "console" }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create an alert rule evaluator from a rules array.
|
|
13
|
+
*/
|
|
14
|
+
export function createAlertRules(rules) {
|
|
15
|
+
const effectiveRules = rules && rules.length > 0 ? rules : DEFAULT_RULES;
|
|
16
|
+
return {
|
|
17
|
+
evaluate(auditResult, previousResult) {
|
|
18
|
+
return evaluateAlertRules(effectiveRules, auditResult, { previousResult });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Evaluate alert rules against an audit result.
|
|
25
|
+
*/
|
|
26
|
+
export async function evaluateAlertRules(rules, auditResult, options = {}) {
|
|
27
|
+
const effectiveRules = rules && rules.length > 0 ? rules : DEFAULT_RULES;
|
|
28
|
+
const { previousResult, competitorScores } = options;
|
|
29
|
+
const score = auditResult.compositeScore ?? auditResult.score ?? 0;
|
|
30
|
+
const dimensions = auditResult.dimensions ?? {};
|
|
31
|
+
|
|
32
|
+
const triggered = [];
|
|
33
|
+
const passed = [];
|
|
34
|
+
|
|
35
|
+
for (const rule of effectiveRules) {
|
|
36
|
+
const result = evaluateSingleRule(rule, score, dimensions, previousResult, competitorScores);
|
|
37
|
+
if (result.triggered) {
|
|
38
|
+
triggered.push(result.detail);
|
|
39
|
+
} else {
|
|
40
|
+
passed.push({ rule: rule.name });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Execute actions for triggered alerts
|
|
45
|
+
for (const alert of triggered) {
|
|
46
|
+
try {
|
|
47
|
+
await executeAction(alert, rules.find((r) => r.name === alert.rule));
|
|
48
|
+
alert.actionExecuted = true;
|
|
49
|
+
} catch {
|
|
50
|
+
alert.actionExecuted = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const summary = triggered.length === 0
|
|
55
|
+
? `All ${effectiveRules.length} rules passed. Score: ${score}/100.`
|
|
56
|
+
: `${triggered.length}/${effectiveRules.length} rules triggered. Score: ${score}/100. Alerts: ${triggered.map((t) => t.rule).join(", ")}.`;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
kind: "geo-alert-rules",
|
|
60
|
+
rulesEvaluated: effectiveRules.length,
|
|
61
|
+
triggered,
|
|
62
|
+
passed,
|
|
63
|
+
score,
|
|
64
|
+
summary
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function evaluateSingleRule(rule, score, dimensions, previousResult, competitorScores) {
|
|
69
|
+
switch (rule.condition) {
|
|
70
|
+
case "score-below": {
|
|
71
|
+
if (score < rule.threshold) {
|
|
72
|
+
return {
|
|
73
|
+
triggered: true,
|
|
74
|
+
detail: {
|
|
75
|
+
rule: rule.name,
|
|
76
|
+
condition: rule.condition,
|
|
77
|
+
message: `Composite score ${score} is below threshold ${rule.threshold}.`,
|
|
78
|
+
value: score,
|
|
79
|
+
threshold: rule.threshold,
|
|
80
|
+
actionExecuted: false
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { triggered: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case "score-drop": {
|
|
88
|
+
if (!previousResult) return { triggered: false };
|
|
89
|
+
const prevScore = previousResult.compositeScore ?? previousResult.score ?? 0;
|
|
90
|
+
const drop = prevScore - score;
|
|
91
|
+
if (drop > rule.threshold) {
|
|
92
|
+
return {
|
|
93
|
+
triggered: true,
|
|
94
|
+
detail: {
|
|
95
|
+
rule: rule.name,
|
|
96
|
+
condition: rule.condition,
|
|
97
|
+
message: `Score dropped by ${drop} points (${prevScore} → ${score}), exceeding threshold of ${rule.threshold}.`,
|
|
98
|
+
value: drop,
|
|
99
|
+
threshold: rule.threshold,
|
|
100
|
+
actionExecuted: false
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { triggered: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case "dimension-below": {
|
|
108
|
+
const dim = rule.dimension;
|
|
109
|
+
if (!dim || !dimensions[dim]) return { triggered: false };
|
|
110
|
+
const dimScore = typeof dimensions[dim] === "object" ? dimensions[dim].score : dimensions[dim];
|
|
111
|
+
if (dimScore < rule.threshold) {
|
|
112
|
+
return {
|
|
113
|
+
triggered: true,
|
|
114
|
+
detail: {
|
|
115
|
+
rule: rule.name,
|
|
116
|
+
condition: rule.condition,
|
|
117
|
+
message: `Dimension "${dim}" score ${dimScore} is below threshold ${rule.threshold}.`,
|
|
118
|
+
value: dimScore,
|
|
119
|
+
threshold: rule.threshold,
|
|
120
|
+
actionExecuted: false
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { triggered: false };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "competitor-overtake": {
|
|
128
|
+
if (!competitorScores || competitorScores.length === 0) return { triggered: false };
|
|
129
|
+
const overtaking = competitorScores.filter((c) => c.score > score);
|
|
130
|
+
if (overtaking.length > 0) {
|
|
131
|
+
const top = overtaking.reduce((a, b) => (a.score > b.score ? a : b));
|
|
132
|
+
return {
|
|
133
|
+
triggered: true,
|
|
134
|
+
detail: {
|
|
135
|
+
rule: rule.name,
|
|
136
|
+
condition: rule.condition,
|
|
137
|
+
message: `Competitor "${top.url || top.name || "unknown"}" (${top.score}) overtook own score (${score}).`,
|
|
138
|
+
value: top.score,
|
|
139
|
+
threshold: score,
|
|
140
|
+
actionExecuted: false
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { triggered: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
default:
|
|
148
|
+
return { triggered: false };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function executeAction(alert, rule) {
|
|
153
|
+
if (!rule) return;
|
|
154
|
+
|
|
155
|
+
switch (rule.action) {
|
|
156
|
+
case "console": {
|
|
157
|
+
const line = `[ALERT] ${alert.rule}: ${alert.message}`;
|
|
158
|
+
process.stdout.write(line + "\n");
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case "webhook": {
|
|
163
|
+
const url = rule.actionConfig?.url;
|
|
164
|
+
if (!url) throw new Error("Webhook URL not configured");
|
|
165
|
+
const body = JSON.stringify({
|
|
166
|
+
alert: {
|
|
167
|
+
rule: alert.rule,
|
|
168
|
+
condition: alert.condition,
|
|
169
|
+
message: alert.message,
|
|
170
|
+
value: alert.value,
|
|
171
|
+
threshold: alert.threshold
|
|
172
|
+
},
|
|
173
|
+
timestamp: new Date().toISOString()
|
|
174
|
+
});
|
|
175
|
+
const resp = await fetch(url, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "Content-Type": "application/json" },
|
|
178
|
+
body
|
|
179
|
+
});
|
|
180
|
+
if (!resp.ok) throw new Error(`Webhook returned ${resp.status}`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case "file": {
|
|
185
|
+
const filePath = rule.actionConfig?.filePath;
|
|
186
|
+
if (!filePath) throw new Error("File path not configured");
|
|
187
|
+
const dir = path.dirname(filePath);
|
|
188
|
+
await fs.mkdir(dir, { recursive: true });
|
|
189
|
+
const line = `[${new Date().toISOString()}] ${alert.rule}: ${alert.message}\n`;
|
|
190
|
+
await fs.appendFile(filePath, line, "utf8");
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Load alert rules from a JSON file.
|
|
198
|
+
*/
|
|
199
|
+
export async function loadAlertRulesFromFile(filePath) {
|
|
200
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
201
|
+
const rules = JSON.parse(content);
|
|
202
|
+
if (!Array.isArray(rules)) {
|
|
203
|
+
throw new Error("Alert rules file must contain a JSON array");
|
|
204
|
+
}
|
|
205
|
+
return rules;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render alert rules report as markdown.
|
|
210
|
+
*/
|
|
211
|
+
export function renderAlertRulesMarkdown(report) {
|
|
212
|
+
const lines = [
|
|
213
|
+
"# GEO Alert Rules Report",
|
|
214
|
+
"",
|
|
215
|
+
`**Score:** ${report.score}/100`,
|
|
216
|
+
`**Rules evaluated:** ${report.rulesEvaluated}`,
|
|
217
|
+
`**Triggered:** ${report.triggered.length}`,
|
|
218
|
+
`**Passed:** ${report.passed.length}`,
|
|
219
|
+
""
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
if (report.triggered.length > 0) {
|
|
223
|
+
lines.push("## Triggered Alerts", "");
|
|
224
|
+
for (const alert of report.triggered) {
|
|
225
|
+
lines.push(`- **${alert.rule}** (${alert.condition}): ${alert.message} [action: ${alert.actionExecuted ? "executed" : "failed"}]`);
|
|
226
|
+
}
|
|
227
|
+
lines.push("");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (report.passed.length > 0) {
|
|
231
|
+
lines.push("## Passed Rules", "");
|
|
232
|
+
for (const p of report.passed) {
|
|
233
|
+
lines.push(`- ${p.rule}`);
|
|
234
|
+
}
|
|
235
|
+
lines.push("");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
lines.push("## Summary", "", report.summary, "");
|
|
239
|
+
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Write alert rules output to file.
|
|
245
|
+
*/
|
|
246
|
+
export async function writeAlertRulesOutput(outputPath, content) {
|
|
247
|
+
return writeScanOutput(outputPath, content);
|
|
248
|
+
}
|