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 +1 -1
- package/action.yml +1 -1
- package/package.json +1 -1
- package/src/alert-rules.js +248 -0
- package/src/canonical-resolver.js +328 -0
- package/src/cli-site-ops-commands.js +174 -2
- package/src/content-rewriter.js +580 -0
- package/src/dashboard-html.js +1 -1
- package/src/fetch-utils.js +1 -1
- package/src/image-audit.js +342 -0
- package/src/index.d.ts +280 -0
- package/src/index.js +7 -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/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,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
|
+
}
|