geo-ai-search-optimization 2.4.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.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/action.yml +13 -8
  3. package/package.json +1 -1
  4. package/src/ai-snippet-simulator.js +359 -0
  5. package/src/auto-fix.js +7 -5
  6. package/src/backlink-profile.js +378 -0
  7. package/src/batch-full-page-audit.js +3 -2
  8. package/src/bulk-optimize.js +357 -0
  9. package/src/citability.js +2 -11
  10. package/src/citation-check.js +5 -16
  11. package/src/cli-site-ops-commands.js +272 -4
  12. package/src/compare.js +6 -6
  13. package/src/competitor-tracking.js +275 -0
  14. package/src/config.js +6 -6
  15. package/src/content-freshness.js +411 -0
  16. package/src/crawlers.js +16 -13
  17. package/src/dashboard-html.js +339 -0
  18. package/src/deep-benchmark.js +3 -2
  19. package/src/eeat.js +2 -11
  20. package/src/explain.js +1 -1
  21. package/src/fetch-utils.js +12 -2
  22. package/src/freshness.js +4 -13
  23. package/src/full-audit.js +1 -0
  24. package/src/full-page-audit.js +10 -10
  25. package/src/geo-score-api.js +201 -0
  26. package/src/heading-structure.js +2 -11
  27. package/src/index.d.ts +1105 -1
  28. package/src/index.js +20 -1
  29. package/src/internal-links.js +2 -11
  30. package/src/link-quality.js +384 -0
  31. package/src/monitor.js +3 -3
  32. package/src/optimize-llms.js +583 -0
  33. package/src/page-audit.js +2 -14
  34. package/src/page-snapshot.js +1 -1
  35. package/src/pdf-report.js +9 -3
  36. package/src/platform-ready.js +2 -11
  37. package/src/plugins.js +1 -1
  38. package/src/readability.js +2 -11
  39. package/src/security.js +3 -18
  40. package/src/sitemap.js +4 -7
  41. package/src/slack-report.js +254 -0
  42. package/src/social-meta.js +2 -14
  43. package/src/structured-data-generator.js +532 -0
  44. package/src/summary.js +1 -1
  45. package/src/topics.js +2 -11
  46. package/src/url-onboarding.js +2 -14
  47. package/src/validate-llms.js +25 -10
  48. package/src/validate-schema.js +14 -12
  49. 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.2.0
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
@@ -51,23 +51,24 @@ runs:
51
51
 
52
52
  - name: Install geo-ai-search-optimization
53
53
  shell: bash
54
- run: npm install -g geo-ai-search-optimization
54
+ run: npm install -g geo-ai-search-optimization@2.6.0
55
55
 
56
56
  - name: Run GEO Audit
57
57
  id: audit
58
58
  shell: bash
59
+ env:
60
+ PROJ_PATH: ${{ inputs.project-path }}
61
+ BASELINE: ${{ inputs.baseline }}
62
+ MIN_SCORE: ${{ inputs.min-score }}
63
+ FAIL_ON_REGRESSION: ${{ inputs.fail-on-regression }}
59
64
  run: |
60
- PROJ_PATH="${{ inputs.project-path }}"
61
- BASELINE="${{ inputs.baseline }}"
62
- MIN_SCORE="${{ inputs.min-score }}"
63
-
64
65
  ARGS=("$PROJ_PATH" "--json")
65
66
 
66
67
  if [ -n "$BASELINE" ]; then
67
68
  ARGS+=("--baseline" "$BASELINE")
68
69
  fi
69
70
 
70
- if [ "${{ inputs.fail-on-regression }}" = "true" ]; then
71
+ if [ "$FAIL_ON_REGRESSION" = "true" ]; then
71
72
  ARGS+=("--fail-on-regression")
72
73
  fi
73
74
 
@@ -99,14 +100,18 @@ runs:
99
100
  - name: Save Snapshot
100
101
  if: inputs.save-snapshot == 'true'
101
102
  shell: bash
103
+ env:
104
+ PROJ_PATH: ${{ inputs.project-path }}
102
105
  run: |
103
- geo-ai-search-optimization audit "${{ inputs.project-path }}" --save --json > /dev/null 2>&1 || true
106
+ geo-ai-search-optimization audit "$PROJ_PATH" --save --json > /dev/null 2>&1 || true
104
107
 
105
108
  - name: Generate Markdown Report
106
109
  if: inputs.output-format == 'markdown'
107
110
  shell: bash
111
+ env:
112
+ PROJ_PATH: ${{ inputs.project-path }}
108
113
  run: |
109
- geo-ai-search-optimization audit "${{ inputs.project-path }}" --out geo-audit-report.md || true
114
+ geo-ai-search-optimization audit "$PROJ_PATH" --out geo-audit-report.md || true
110
115
 
111
116
  - name: Post Summary
112
117
  if: always()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geo-ai-search-optimization",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Install and run a Generative Engine Optimization (GEO)-first, SEO-supported Codex skill for website optimization.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
package/src/auto-fix.js CHANGED
@@ -1,5 +1,3 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
1
  import { writeScanOutput } from "./scan.js";
4
2
  import { auditPage } from "./page-audit.js";
5
3
 
@@ -50,10 +48,14 @@ function generateMetaTags(metadata, signals) {
50
48
  return tags;
51
49
  }
52
50
 
51
+ function escapeHtmlAttr(str) {
52
+ return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
53
+ }
54
+
53
55
  function generateOgTags(metadata) {
54
- const title = metadata.title || "Your Page Title";
55
- const description = metadata.metaDescription || "A concise description of your page.";
56
- const url = metadata.canonical || "https://yoursite.com/this-page";
56
+ const title = escapeHtmlAttr(metadata.title || "Your Page Title");
57
+ const description = escapeHtmlAttr(metadata.metaDescription || "A concise description of your page.");
58
+ const url = escapeHtmlAttr(metadata.canonical || "https://yoursite.com/this-page");
57
59
 
58
60
  return [
59
61
  { tag: "og:title", html: `<meta property="og:title" content="${title}">` },