geo-ai-search-optimization 2.5.0 → 2.6.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/backlink-profile.js +378 -0
- package/src/bulk-optimize.js +357 -0
- package/src/cli-site-ops-commands.js +179 -2
- package/src/dashboard-html.js +339 -0
- package/src/fetch-utils.js +1 -1
- package/src/geo-score-api.js +201 -0
- package/src/index.d.ts +239 -0
- package/src/index.js +8 -0
- package/src/pdf-report.js +1 -1
- 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.6.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
|
+
}
|