geo-ai-search-optimization 2.6.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 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.6.0
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
@@ -51,7 +51,7 @@ runs:
51
51
 
52
52
  - name: Install geo-ai-search-optimization
53
53
  shell: bash
54
- run: npm install -g geo-ai-search-optimization@2.6.0
54
+ run: npm install -g geo-ai-search-optimization@2.7.0
55
55
 
56
56
  - name: Run GEO Audit
57
57
  id: audit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geo-ai-search-optimization",
3
- "version": "2.6.0",
3
+ "version": "2.7.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,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
+ }
@@ -0,0 +1,328 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fetchText, fetchResponse } from "./fetch-utils.js";
4
+ import { writeScanOutput } from "./scan.js";
5
+
6
+ /**
7
+ * Extract <link rel="canonical" href="..."> from HTML.
8
+ */
9
+ function extractLinkCanonical(html) {
10
+ // Match both attribute orders: rel before href, and href before rel
11
+ const pattern1 = /<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["'][^>]*>/i;
12
+ const pattern2 = /<link[^>]+href=["']([^"']+)["'][^>]+rel=["']canonical["'][^>]*>/i;
13
+ const match = html.match(pattern1) || html.match(pattern2);
14
+ return match ? match[1].trim() : null;
15
+ }
16
+
17
+ /**
18
+ * Extract <meta property="og:url" content="..."> from HTML.
19
+ */
20
+ function extractOgUrl(html) {
21
+ const pattern1 = /<meta[^>]+property=["']og:url["'][^>]+content=["']([^"']*)["'][^>]*>/i;
22
+ const pattern2 = /<meta[^>]+content=["']([^"']*)["'][^>]+property=["']og:url["'][^>]*>/i;
23
+ const match = html.match(pattern1) || html.match(pattern2);
24
+ return match ? match[1].trim() : null;
25
+ }
26
+
27
+ /**
28
+ * Extract canonical from HTTP Link header.
29
+ * Format: <https://example.com/page>; rel="canonical"
30
+ */
31
+ function extractHttpHeaderCanonical(headers) {
32
+ const linkHeader = headers?.link || headers?.Link;
33
+ if (!linkHeader) return null;
34
+ const match = linkHeader.match(/<([^>]+)>\s*;\s*rel=["']canonical["']/i);
35
+ return match ? match[1].trim() : null;
36
+ }
37
+
38
+ /**
39
+ * Normalize URL for comparison (lowercase host, remove trailing slash on path, etc.)
40
+ */
41
+ function normalizeForComparison(url) {
42
+ try {
43
+ const parsed = new URL(url);
44
+ return {
45
+ protocol: parsed.protocol,
46
+ hostname: parsed.hostname.toLowerCase(),
47
+ pathname: parsed.pathname,
48
+ full: parsed.href
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function checkTrailingSlashConsistency(url1, url2) {
56
+ if (!url1 || !url2) return true;
57
+ try {
58
+ const p1 = new URL(url1).pathname;
59
+ const p2 = new URL(url2).pathname;
60
+ // Both root "/" are fine
61
+ if (p1 === "/" && p2 === "/") return true;
62
+ // Check if one has trailing slash and the other doesn't (ignoring root)
63
+ const stripped1 = p1.replace(/\/$/, "") || "/";
64
+ const stripped2 = p2.replace(/\/$/, "") || "/";
65
+ if (stripped1 === stripped2 && p1 !== p2) return false;
66
+ return true;
67
+ } catch {
68
+ return true;
69
+ }
70
+ }
71
+
72
+ function checkWwwConsistency(url1, url2) {
73
+ if (!url1 || !url2) return true;
74
+ try {
75
+ const h1 = new URL(url1).hostname.toLowerCase();
76
+ const h2 = new URL(url2).hostname.toLowerCase();
77
+ const hasWww1 = h1.startsWith("www.");
78
+ const hasWww2 = h2.startsWith("www.");
79
+ if (hasWww1 !== hasWww2) {
80
+ // Check they refer to the same base domain
81
+ const base1 = h1.replace(/^www\./, "");
82
+ const base2 = h2.replace(/^www\./, "");
83
+ if (base1 === base2) return false;
84
+ }
85
+ return true;
86
+ } catch {
87
+ return true;
88
+ }
89
+ }
90
+
91
+ function computeScore(issues) {
92
+ let score = 100;
93
+ for (const issue of issues) {
94
+ if (issue.type === "no-canonical") score -= 30;
95
+ else if (issue.type === "signals-conflict") score -= 20;
96
+ else if (issue.type === "not-https") score -= 15;
97
+ else if (issue.type === "trailing-slash-inconsistency") score -= 10;
98
+ else if (issue.type === "www-inconsistency") score -= 10;
99
+ else if (issue.type === "not-self-referencing") score -= 5;
100
+ }
101
+ return Math.max(0, Math.min(100, score));
102
+ }
103
+
104
+ function getScoreLabel(score) {
105
+ if (score >= 80) return "Good";
106
+ if (score >= 60) return "Fair";
107
+ if (score >= 40) return "Issues Found";
108
+ return "Critical Issues";
109
+ }
110
+
111
+ export async function resolveCanonical(input, options = {}) {
112
+ let html;
113
+ let source;
114
+ let pageUrl = null;
115
+ let responseHeaders = {};
116
+
117
+ const isUrl = /^https?:\/\//i.test(input);
118
+
119
+ if (isUrl) {
120
+ const resp = await fetchResponse(input);
121
+ html = resp.text;
122
+ source = input;
123
+ pageUrl = resp.url || input;
124
+ responseHeaders = resp.headers || {};
125
+ } else {
126
+ const filePath = path.resolve(input);
127
+ html = await fs.readFile(filePath, "utf8");
128
+ source = filePath;
129
+ }
130
+
131
+ // Extract canonical signals
132
+ const linkTag = extractLinkCanonical(html);
133
+ const ogUrl = extractOgUrl(html);
134
+ const httpHeader = extractHttpHeaderCanonical(responseHeaders);
135
+
136
+ // Determine resolved canonical (priority: link tag > http header > og:url)
137
+ const resolvedCanonical = linkTag || httpHeader || ogUrl || null;
138
+
139
+ const issues = [];
140
+ const recommendations = [];
141
+
142
+ // Check: no canonical at all
143
+ const hasCanonical = Boolean(linkTag || httpHeader);
144
+ if (!hasCanonical) {
145
+ issues.push({
146
+ type: "no-canonical",
147
+ severity: "error",
148
+ message: "No canonical URL found (neither <link rel=\"canonical\"> nor HTTP Link header)."
149
+ });
150
+ recommendations.push("Add a <link rel=\"canonical\"> tag to the <head> of the page.");
151
+ }
152
+
153
+ // Check: signals consistency
154
+ const signals = [linkTag, ogUrl, httpHeader].filter(Boolean);
155
+ let signalsConsistent = true;
156
+ if (signals.length > 1) {
157
+ const normalized = signals.map((s) => {
158
+ try { return new URL(s).href; } catch { return s; }
159
+ });
160
+ const unique = new Set(normalized);
161
+ if (unique.size > 1) {
162
+ signalsConsistent = false;
163
+ issues.push({
164
+ type: "signals-conflict",
165
+ severity: "error",
166
+ message: `Canonical signals conflict: link tag="${linkTag || "none"}", og:url="${ogUrl || "none"}", HTTP header="${httpHeader || "none"}".`
167
+ });
168
+ recommendations.push("Ensure <link rel=\"canonical\">, og:url, and HTTP Link header all point to the same URL.");
169
+ }
170
+ }
171
+
172
+ // Check: HTTPS
173
+ let isHttps = true;
174
+ if (resolvedCanonical) {
175
+ isHttps = /^https:\/\//i.test(resolvedCanonical);
176
+ if (!isHttps) {
177
+ issues.push({
178
+ type: "not-https",
179
+ severity: "warning",
180
+ message: `Canonical URL uses HTTP instead of HTTPS: ${resolvedCanonical}`
181
+ });
182
+ recommendations.push("Use HTTPS for the canonical URL.");
183
+ }
184
+ }
185
+
186
+ // Check: self-referencing
187
+ let isSelfReferencing = false;
188
+ if (pageUrl && resolvedCanonical) {
189
+ try {
190
+ const pageNorm = new URL(pageUrl).href;
191
+ const canonNorm = new URL(resolvedCanonical).href;
192
+ isSelfReferencing = pageNorm === canonNorm;
193
+ } catch {
194
+ isSelfReferencing = false;
195
+ }
196
+ if (!isSelfReferencing) {
197
+ issues.push({
198
+ type: "not-self-referencing",
199
+ severity: "warning",
200
+ message: `Canonical points to a different URL than the page. Page: "${pageUrl}", Canonical: "${resolvedCanonical}". This may be intentional.`
201
+ });
202
+ recommendations.push("Verify the canonical URL is intentionally different from the page URL.");
203
+ }
204
+ } else if (!isUrl && resolvedCanonical) {
205
+ // For file inputs, we can't determine self-referencing
206
+ isSelfReferencing = false;
207
+ }
208
+
209
+ // Check: trailing slash consistency
210
+ let trailingSlashConsistent = true;
211
+ if (pageUrl && resolvedCanonical) {
212
+ trailingSlashConsistent = checkTrailingSlashConsistency(pageUrl, resolvedCanonical);
213
+ if (!trailingSlashConsistent) {
214
+ issues.push({
215
+ type: "trailing-slash-inconsistency",
216
+ severity: "warning",
217
+ message: `Trailing slash inconsistency between page URL and canonical: "${pageUrl}" vs "${resolvedCanonical}".`
218
+ });
219
+ recommendations.push("Ensure consistent trailing slash usage between page URL and canonical URL.");
220
+ }
221
+ }
222
+
223
+ // Check: www consistency
224
+ let wwwConsistent = true;
225
+ if (pageUrl && resolvedCanonical) {
226
+ wwwConsistent = checkWwwConsistency(pageUrl, resolvedCanonical);
227
+ if (!wwwConsistent) {
228
+ issues.push({
229
+ type: "www-inconsistency",
230
+ severity: "warning",
231
+ message: `www inconsistency between page URL and canonical: "${pageUrl}" vs "${resolvedCanonical}".`
232
+ });
233
+ recommendations.push("Use consistent www/non-www across page URL and canonical URL.");
234
+ }
235
+ }
236
+
237
+ const score = computeScore(issues);
238
+ const scoreLabel = getScoreLabel(score);
239
+
240
+ const checks = {
241
+ hasCanonical,
242
+ isSelfReferencing,
243
+ isHttps,
244
+ signalsConsistent,
245
+ trailingSlashConsistent,
246
+ wwwConsistent
247
+ };
248
+
249
+ const summaryParts = [`Canonical: ${score}/100 (${scoreLabel}).`];
250
+ if (resolvedCanonical) {
251
+ summaryParts.push(`Resolved: ${resolvedCanonical}`);
252
+ } else {
253
+ summaryParts.push("No canonical found.");
254
+ }
255
+ if (issues.length > 0) {
256
+ summaryParts.push(`${issues.length} issue(s) detected.`);
257
+ }
258
+
259
+ return {
260
+ kind: "geo-canonical-resolver",
261
+ source,
262
+ pageUrl,
263
+ canonical: { linkTag, ogUrl, httpHeader },
264
+ resolvedCanonical,
265
+ issues,
266
+ checks,
267
+ score,
268
+ scoreLabel,
269
+ recommendations,
270
+ summary: summaryParts.join(" ")
271
+ };
272
+ }
273
+
274
+ export function renderCanonicalResolverMarkdown(report) {
275
+ const lines = [
276
+ "# Canonical URL Analysis",
277
+ "",
278
+ `- Source: \`${report.source}\``,
279
+ `- Score: \`${report.score}/100\` (${report.scoreLabel})`,
280
+ `- Summary: ${report.summary}`,
281
+ "",
282
+ "## Canonical Signals",
283
+ "",
284
+ `| Signal | Value |`,
285
+ `|--------|-------|`,
286
+ `| \`<link rel="canonical">\` | ${report.canonical.linkTag || "Not found"} |`,
287
+ `| \`og:url\` | ${report.canonical.ogUrl || "Not found"} |`,
288
+ `| HTTP Link header | ${report.canonical.httpHeader || "Not found"} |`,
289
+ `| Resolved canonical | ${report.resolvedCanonical || "None"} |`,
290
+ ""
291
+ ];
292
+
293
+ if (report.pageUrl) {
294
+ lines.push(`- Page URL: \`${report.pageUrl}\``);
295
+ }
296
+
297
+ lines.push("", "## Checks", "", "| Check | Status |", "|-------|--------|");
298
+ lines.push(`| Has canonical | ${report.checks.hasCanonical ? "Pass" : "Fail"} |`);
299
+ lines.push(`| Self-referencing | ${report.checks.isSelfReferencing ? "Yes" : "No"} |`);
300
+ lines.push(`| HTTPS | ${report.checks.isHttps ? "Pass" : "Fail"} |`);
301
+ lines.push(`| Signals consistent | ${report.checks.signalsConsistent ? "Pass" : "Fail"} |`);
302
+ lines.push(`| Trailing slash consistent | ${report.checks.trailingSlashConsistent ? "Pass" : "Fail"} |`);
303
+ lines.push(`| www consistent | ${report.checks.wwwConsistent ? "Pass" : "Fail"} |`);
304
+
305
+ if (report.issues.length > 0) {
306
+ lines.push("", "## Issues", "");
307
+ for (const issue of report.issues) {
308
+ const icon = issue.severity === "error" ? "ERROR" : "WARNING";
309
+ lines.push(`- [${icon}] ${issue.message}`);
310
+ }
311
+ }
312
+
313
+ lines.push("", "## Recommendations", "");
314
+ if (report.recommendations.length === 0) {
315
+ lines.push("- Canonical URL configuration looks good.");
316
+ } else {
317
+ for (const rec of report.recommendations) {
318
+ lines.push(`- ${rec}`);
319
+ }
320
+ }
321
+ lines.push("");
322
+
323
+ return lines.join("\n");
324
+ }
325
+
326
+ export async function writeCanonicalResolverOutput(outputPath, content) {
327
+ return writeScanOutput(outputPath, content);
328
+ }