@aexol/spectral 0.9.125 → 0.9.127
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/dist/agent/index.d.ts.map +1 -1
- package/dist/agent/index.js +5 -0
- package/dist/cli.js +6 -0
- package/dist/commands/update.d.ts +17 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +292 -0
- package/dist/extensions/seo/analyzers/content.d.ts +29 -6
- package/dist/extensions/seo/analyzers/content.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/content.js +243 -8
- package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/crawler.js +133 -7
- package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/drift.js +86 -29
- package/dist/extensions/seo/analyzers/images.d.ts +10 -4
- package/dist/extensions/seo/analyzers/images.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/images.js +153 -5
- package/dist/extensions/seo/analyzers/performance.d.ts +3 -3
- package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/performance.js +77 -6
- package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/readability.js +17 -9
- package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/schema.js +99 -10
- package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/sitemap.js +57 -16
- package/dist/extensions/seo/analyzers/technical.d.ts +0 -7
- package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -1
- package/dist/extensions/seo/analyzers/technical.js +408 -37
- package/dist/extensions/seo/config.d.ts +2 -0
- package/dist/extensions/seo/config.d.ts.map +1 -1
- package/dist/extensions/seo/config.js +2 -0
- package/dist/extensions/seo/tools/batch.js +1 -1
- package/dist/extensions/seo/tools/content.d.ts.map +1 -1
- package/dist/extensions/seo/tools/content.js +5 -3
- package/dist/extensions/seo/tools/full-audit.d.ts +1 -1
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
- package/dist/extensions/seo/tools/full-audit.js +391 -66
- package/dist/extensions/seo/tools/helpers.d.ts +15 -4
- package/dist/extensions/seo/tools/helpers.d.ts.map +1 -1
- package/dist/extensions/seo/tools/helpers.js +57 -6
- package/dist/extensions/seo/tools/performance.d.ts.map +1 -1
- package/dist/extensions/seo/tools/performance.js +17 -11
- package/dist/extensions/seo/tools/report.d.ts.map +1 -1
- package/dist/extensions/seo/tools/report.js +513 -47
- package/dist/extensions/seo/types.d.ts +295 -3
- package/dist/extensions/seo/types.d.ts.map +1 -1
- package/dist/extensions/seo/utils/fetcher.d.ts +4 -0
- package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -1
- package/dist/extensions/seo/utils/fetcher.js +77 -11
- package/dist/extensions/seo/utils/parser.d.ts +3 -1
- package/dist/extensions/seo/utils/parser.d.ts.map +1 -1
- package/dist/extensions/seo/utils/parser.js +105 -25
- package/dist/extensions/seo/utils/renderer.d.ts +6 -1
- package/dist/extensions/seo/utils/renderer.d.ts.map +1 -1
- package/dist/extensions/seo/utils/renderer.js +207 -1
- package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -1
- package/dist/extensions/seo/utils/url-safety.js +19 -3
- package/dist/extensions/web/tools/fetch.js +2 -2
- package/dist/memory/tools/compact-context.d.ts.map +1 -1
- package/dist/memory/tools/compact-context.js +10 -3
- package/dist/sdk/agent-core/agent-loop.d.ts.map +1 -1
- package/dist/sdk/agent-core/agent-loop.js +87 -4
- package/dist/sdk/agent-core/types.d.ts +1 -0
- package/dist/sdk/agent-core/types.d.ts.map +1 -1
- package/dist/sdk/ai/utils/validation.d.ts +0 -7
- package/dist/sdk/ai/utils/validation.d.ts.map +1 -1
- package/dist/sdk/ai/utils/validation.js +21 -1
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +41 -0
- package/dist/server/storage.d.ts +9 -0
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +35 -9
- package/dist/server/wire.d.ts +2 -0
- package/dist/server/wire.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -12,7 +12,9 @@ const AI_CRAWLER_TOKENS = [
|
|
|
12
12
|
function parseRobotsTxt(content) {
|
|
13
13
|
const lines = content.split("\n").map((l) => l.trim());
|
|
14
14
|
let sitemapDeclared = false;
|
|
15
|
+
const sitemapUrls = [];
|
|
15
16
|
const aiCrawlerRules = [];
|
|
17
|
+
const disallowRules = [];
|
|
16
18
|
let currentUserAgents = [];
|
|
17
19
|
for (const line of lines) {
|
|
18
20
|
if (!line || line.startsWith("#"))
|
|
@@ -30,17 +32,22 @@ function parseRobotsTxt(content) {
|
|
|
30
32
|
if (AI_CRAWLER_TOKENS.includes(ua)) {
|
|
31
33
|
aiCrawlerRules.push(`${ua}: ${key} ${value}`);
|
|
32
34
|
}
|
|
35
|
+
if (ua === "*" && key === "disallow") {
|
|
36
|
+
disallowRules.push(value);
|
|
37
|
+
}
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
else if (key === "sitemap") {
|
|
36
41
|
sitemapDeclared = true;
|
|
42
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
43
|
+
sitemapUrls.push(value);
|
|
44
|
+
}
|
|
37
45
|
}
|
|
38
|
-
// Reset user-agents when we hit a non-user-agent line after a group.
|
|
39
46
|
if (key !== "user-agent") {
|
|
40
47
|
currentUserAgents = [];
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
|
-
return { sitemapDeclared, aiCrawlerRules };
|
|
50
|
+
return { sitemapDeclared, sitemapUrls, aiCrawlerRules, disallowRules };
|
|
44
51
|
}
|
|
45
52
|
const SECURITY_HEADERS = [
|
|
46
53
|
"content-security-policy",
|
|
@@ -53,24 +60,26 @@ const SECURITY_HEADERS = [
|
|
|
53
60
|
export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
54
61
|
const issues = [];
|
|
55
62
|
const categoryScores = {};
|
|
56
|
-
// Fetch the page if not provided.
|
|
57
63
|
if (!fetchResult) {
|
|
58
64
|
fetchResult = await fetchPage(url, config);
|
|
59
65
|
}
|
|
60
66
|
if (!pageData) {
|
|
61
67
|
pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
|
|
62
68
|
}
|
|
63
|
-
// Fetch robots.txt.
|
|
64
69
|
let robotsResult = null;
|
|
65
70
|
try {
|
|
66
71
|
robotsResult = await fetchRobotsTxt(url, config);
|
|
67
72
|
}
|
|
68
73
|
catch {
|
|
69
|
-
//
|
|
74
|
+
// robots.txt fetch failed gracefully
|
|
70
75
|
}
|
|
71
76
|
const robotsContent = robotsResult?.statusCode === 200 ? robotsResult.content : "";
|
|
72
|
-
const robotsParsed = robotsContent
|
|
73
|
-
|
|
77
|
+
const robotsParsed = robotsContent
|
|
78
|
+
? parseRobotsTxt(robotsContent)
|
|
79
|
+
: { sitemapDeclared: false, aiCrawlerRules: [], disallowRules: [], sitemapUrls: [] };
|
|
80
|
+
// ============================================================
|
|
81
|
+
// CRAWLABILITY
|
|
82
|
+
// ============================================================
|
|
74
83
|
let crawlScore = 100;
|
|
75
84
|
if (robotsResult?.statusCode !== 200) {
|
|
76
85
|
crawlScore -= 30;
|
|
@@ -92,9 +101,26 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
92
101
|
recommendation: "Add 'Sitemap: https://example.com/sitemap.xml' to robots.txt.",
|
|
93
102
|
});
|
|
94
103
|
}
|
|
104
|
+
// J. Blocked resources — significant disallow rules for User-agent: *
|
|
105
|
+
if (robotsParsed.disallowRules.length > 0) {
|
|
106
|
+
const broadBlocks = robotsParsed.disallowRules.filter((r) => r === "/" || r === "/*" || r.startsWith("/?"));
|
|
107
|
+
if (broadBlocks.length > 0) {
|
|
108
|
+
crawlScore -= 20;
|
|
109
|
+
issues.push({
|
|
110
|
+
category: "Crawlability",
|
|
111
|
+
severity: "medium",
|
|
112
|
+
title: "Broad robots.txt disallow rules detected",
|
|
113
|
+
description: `User-agent: * has broad disallow rules: ${broadBlocks.join(", ")}.`,
|
|
114
|
+
recommendation: "Review robots.txt disallow rules — broad blocks may prevent legitimate crawling of important pages.",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
95
118
|
categoryScores["Crawlability"] = Math.max(0, crawlScore);
|
|
96
|
-
//
|
|
119
|
+
// ============================================================
|
|
120
|
+
// INDEXABILITY
|
|
121
|
+
// ============================================================
|
|
97
122
|
let indexScore = 100;
|
|
123
|
+
// Meta robots noindex
|
|
98
124
|
if (pageData.metaRobots?.toLowerCase().includes("noindex")) {
|
|
99
125
|
indexScore -= 40;
|
|
100
126
|
issues.push({
|
|
@@ -105,6 +131,32 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
105
131
|
recommendation: "Remove the noindex directive if this page should be indexed.",
|
|
106
132
|
});
|
|
107
133
|
}
|
|
134
|
+
// A. X-Robots-Tag header check
|
|
135
|
+
const xRobotsTag = fetchResult.headers["x-robots-tag"];
|
|
136
|
+
if (xRobotsTag) {
|
|
137
|
+
const xRobotsLower = xRobotsTag.toLowerCase();
|
|
138
|
+
if (xRobotsLower.includes("noindex")) {
|
|
139
|
+
indexScore -= 40;
|
|
140
|
+
issues.push({
|
|
141
|
+
category: "Indexability",
|
|
142
|
+
severity: "critical",
|
|
143
|
+
title: "Page blocked from indexing via X-Robots-Tag header",
|
|
144
|
+
description: `X-Robots-Tag header contains 'noindex': ${xRobotsTag}`,
|
|
145
|
+
recommendation: "Remove the noindex directive from the X-Robots-Tag header if this page should be indexed.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (xRobotsLower.includes("nofollow")) {
|
|
149
|
+
indexScore -= 10;
|
|
150
|
+
issues.push({
|
|
151
|
+
category: "Indexability",
|
|
152
|
+
severity: "medium",
|
|
153
|
+
title: "Links marked nofollow via X-Robots-Tag header",
|
|
154
|
+
description: `X-Robots-Tag header contains 'nofollow': ${xRobotsTag}`,
|
|
155
|
+
recommendation: "Review the nofollow directive in X-Robots-Tag — it prevents link equity flow from this page.",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// H1 checks (existing)
|
|
108
160
|
if (pageData.h1Count === 0) {
|
|
109
161
|
indexScore -= 15;
|
|
110
162
|
issues.push({
|
|
@@ -119,26 +171,14 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
119
171
|
indexScore -= 10;
|
|
120
172
|
issues.push({
|
|
121
173
|
category: "Indexability",
|
|
122
|
-
severity: "
|
|
174
|
+
severity: "medium",
|
|
123
175
|
title: "Multiple H1 headings",
|
|
124
|
-
description: `
|
|
125
|
-
recommendation: "Use a single H1 per page
|
|
176
|
+
description: `The page has ${pageData.h1Count} H1 elements.`,
|
|
177
|
+
recommendation: "Use a single H1 per page for clear content hierarchy.",
|
|
126
178
|
});
|
|
127
179
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (canonicalUrl !== fetchResult.finalUrl) {
|
|
131
|
-
indexScore -= 10;
|
|
132
|
-
issues.push({
|
|
133
|
-
category: "Indexability",
|
|
134
|
-
severity: "low",
|
|
135
|
-
title: "Canonical URL differs from page URL",
|
|
136
|
-
description: `canonical=${canonicalUrl}, url=${fetchResult.finalUrl}`,
|
|
137
|
-
recommendation: "Verify the canonical tag is correct — it may point to a different preferred URL intentionally.",
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
180
|
+
// Canonical check (existing)
|
|
181
|
+
if (!pageData.canonical) {
|
|
142
182
|
indexScore -= 10;
|
|
143
183
|
issues.push({
|
|
144
184
|
category: "Indexability",
|
|
@@ -148,8 +188,79 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
148
188
|
recommendation: "Add a self-referencing canonical tag to prevent duplicate content issues.",
|
|
149
189
|
});
|
|
150
190
|
}
|
|
191
|
+
// C. Cross-domain canonical check
|
|
192
|
+
if (pageData.canonical) {
|
|
193
|
+
try {
|
|
194
|
+
const canonicalHost = new URL(pageData.canonical).hostname;
|
|
195
|
+
const pageHost = new URL(pageData.url).hostname;
|
|
196
|
+
if (canonicalHost !== pageHost) {
|
|
197
|
+
indexScore -= 15;
|
|
198
|
+
issues.push({
|
|
199
|
+
category: "Indexability",
|
|
200
|
+
severity: "medium",
|
|
201
|
+
title: "Cross-domain canonical URL",
|
|
202
|
+
description: `Canonical points to ${canonicalHost} but page is on ${pageHost}.`,
|
|
203
|
+
recommendation: "Ensure the cross-domain canonical is intentional (e.g., syndicated content). Otherwise, use a self-referencing canonical.",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// URL parsing failed — skip cross-domain check
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// H. Soft 404 detection
|
|
212
|
+
if (fetchResult.statusCode === 200) {
|
|
213
|
+
if (pageData.wordCount < 50) {
|
|
214
|
+
indexScore -= 40;
|
|
215
|
+
issues.push({
|
|
216
|
+
category: "Indexability",
|
|
217
|
+
severity: "critical",
|
|
218
|
+
title: "Likely soft 404 — very thin content",
|
|
219
|
+
description: `Status 200 but only ${pageData.wordCount} words on page.`,
|
|
220
|
+
recommendation: "Add meaningful content to this page or return a proper 404/410 status for non-existent pages.",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else if (pageData.wordCount < 100 && pageData.h1Count === 0) {
|
|
224
|
+
indexScore -= 30;
|
|
225
|
+
issues.push({
|
|
226
|
+
category: "Indexability",
|
|
227
|
+
severity: "high",
|
|
228
|
+
title: "Possible soft 404 — thin content with no H1",
|
|
229
|
+
description: `Status 200 but only ${pageData.wordCount} words and no H1 heading.`,
|
|
230
|
+
recommendation: "Add a descriptive H1 and substantive content, or return a proper 404/410 status.",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (fetchResult.contentLengthBytes < 1000) {
|
|
234
|
+
indexScore -= 20;
|
|
235
|
+
issues.push({
|
|
236
|
+
category: "Indexability",
|
|
237
|
+
severity: "medium",
|
|
238
|
+
title: "Very small response body — possible soft 404",
|
|
239
|
+
description: `Response body is only ${fetchResult.contentLengthBytes} bytes.`,
|
|
240
|
+
recommendation: "Ensure the page has meaningful content — very small responses may indicate empty or error pages.",
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// I. Pagination detection
|
|
245
|
+
const hasNext = pageData.links.some((l) => l.rel.toLowerCase().includes("next"));
|
|
246
|
+
const hasPrev = pageData.links.some((l) => l.rel.toLowerCase().includes("prev"));
|
|
247
|
+
const paginationDetected = hasNext || hasPrev;
|
|
248
|
+
if (paginationDetected) {
|
|
249
|
+
if (!pageData.canonical) {
|
|
250
|
+
indexScore -= 10;
|
|
251
|
+
issues.push({
|
|
252
|
+
category: "Indexability",
|
|
253
|
+
severity: "low",
|
|
254
|
+
title: "Pagination detected but no canonical tag",
|
|
255
|
+
description: "Page has rel='next' or rel='prev' but no canonical URL.",
|
|
256
|
+
recommendation: "Add a self-referencing canonical tag to paginated pages to prevent indexing of parameterized variants.",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
151
260
|
categoryScores["Indexability"] = Math.max(0, indexScore);
|
|
152
|
-
//
|
|
261
|
+
// ============================================================
|
|
262
|
+
// SECURITY (existing)
|
|
263
|
+
// ============================================================
|
|
153
264
|
let securityScore = 100;
|
|
154
265
|
const isHttps = fetchResult.finalUrl.startsWith("https://");
|
|
155
266
|
if (!isHttps) {
|
|
@@ -177,7 +288,9 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
177
288
|
}
|
|
178
289
|
}
|
|
179
290
|
categoryScores["Security"] = Math.max(0, securityScore);
|
|
180
|
-
//
|
|
291
|
+
// ============================================================
|
|
292
|
+
// URL STRUCTURE (existing + G. redirect-chain quality)
|
|
293
|
+
// ============================================================
|
|
181
294
|
let urlScore = 100;
|
|
182
295
|
if (fetchResult.finalUrl.length > 100) {
|
|
183
296
|
urlScore -= 10;
|
|
@@ -189,6 +302,7 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
189
302
|
recommendation: "Shorten the URL where possible for better readability and SEO.",
|
|
190
303
|
});
|
|
191
304
|
}
|
|
305
|
+
// Original redirect chain check (keep)
|
|
192
306
|
if (fetchResult.redirectChain.length > 1) {
|
|
193
307
|
urlScore -= 15;
|
|
194
308
|
issues.push({
|
|
@@ -199,7 +313,50 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
199
313
|
recommendation: "Reduce redirect chains to a single hop (max 1 redirect).",
|
|
200
314
|
});
|
|
201
315
|
}
|
|
202
|
-
//
|
|
316
|
+
// G. Redirect-chain quality (extended)
|
|
317
|
+
const redirectChain = fetchResult.redirectChain;
|
|
318
|
+
const permanentStatuses = new Set([301, 308]);
|
|
319
|
+
const temporaryStatuses = new Set([302, 307]);
|
|
320
|
+
const permanentCount = redirectChain.filter((h) => permanentStatuses.has(h.status)).length;
|
|
321
|
+
const temporaryCount = redirectChain.filter((h) => temporaryStatuses.has(h.status)).length;
|
|
322
|
+
const redirectQualityIssues = [];
|
|
323
|
+
// Check final redirect uses temporary
|
|
324
|
+
if (redirectChain.length > 0) {
|
|
325
|
+
const lastHop = redirectChain[redirectChain.length - 1];
|
|
326
|
+
if (temporaryStatuses.has(lastHop.status)) {
|
|
327
|
+
redirectQualityIssues.push(`Final redirect hop uses status ${lastHop.status} (temporary) instead of 301 (permanent).`);
|
|
328
|
+
issues.push({
|
|
329
|
+
category: "URL Structure",
|
|
330
|
+
severity: "low",
|
|
331
|
+
title: "Temporary redirect used where permanent may be appropriate",
|
|
332
|
+
description: `Final redirect status is ${lastHop.status} (temporary).`,
|
|
333
|
+
recommendation: "Use 301 (permanent) redirects for permanently moved content to pass link equity.",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Mixed permanent + temporary in chain
|
|
338
|
+
if (permanentCount > 0 && temporaryCount > 0) {
|
|
339
|
+
redirectQualityIssues.push(`Mixed redirect types: ${permanentCount} permanent + ${temporaryCount} temporary in chain.`);
|
|
340
|
+
issues.push({
|
|
341
|
+
category: "URL Structure",
|
|
342
|
+
severity: "low",
|
|
343
|
+
title: "Mixed permanent and temporary redirects in chain",
|
|
344
|
+
description: `Chain has ${permanentCount} permanent and ${temporaryCount} temporary redirects.`,
|
|
345
|
+
recommendation: "Use consistent redirect types — mixed chains can cause unpredictable caching and SEO behavior.",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// Chains longer than 3 hops
|
|
349
|
+
if (redirectChain.length > 3) {
|
|
350
|
+
urlScore -= 10;
|
|
351
|
+
issues.push({
|
|
352
|
+
category: "URL Structure",
|
|
353
|
+
severity: "medium",
|
|
354
|
+
title: "Long redirect chain (> 3 hops)",
|
|
355
|
+
description: `${redirectChain.length} redirect hops — each hop adds latency and dilutes link equity.`,
|
|
356
|
+
recommendation: "Reduce the redirect chain to at most 1–2 hops.",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Uppercase URL path (existing)
|
|
203
360
|
try {
|
|
204
361
|
const path = new URL(fetchResult.finalUrl).pathname;
|
|
205
362
|
if (path !== path.toLowerCase() && path.match(/[A-Z]/)) {
|
|
@@ -214,10 +371,12 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
214
371
|
}
|
|
215
372
|
}
|
|
216
373
|
catch {
|
|
217
|
-
//
|
|
374
|
+
// URL parse failed — skip
|
|
218
375
|
}
|
|
219
376
|
categoryScores["URL Structure"] = Math.max(0, urlScore);
|
|
220
|
-
//
|
|
377
|
+
// ============================================================
|
|
378
|
+
// MOBILE (existing)
|
|
379
|
+
// ============================================================
|
|
221
380
|
let mobileScore = 100;
|
|
222
381
|
const mobileOptimized = !!pageData.viewport;
|
|
223
382
|
if (!mobileOptimized) {
|
|
@@ -231,11 +390,12 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
231
390
|
});
|
|
232
391
|
}
|
|
233
392
|
categoryScores["Mobile"] = Math.max(0, mobileScore);
|
|
234
|
-
//
|
|
393
|
+
// ============================================================
|
|
394
|
+
// AI CRAWLERS (existing)
|
|
395
|
+
// ============================================================
|
|
235
396
|
let aiScore = 100;
|
|
236
397
|
if (robotsParsed.aiCrawlerRules.length === 0) {
|
|
237
|
-
//
|
|
238
|
-
aiScore -= 0;
|
|
398
|
+
aiScore -= 0; // informational only — no score penalty
|
|
239
399
|
issues.push({
|
|
240
400
|
category: "AI Crawlers",
|
|
241
401
|
severity: "low",
|
|
@@ -245,11 +405,197 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
245
405
|
});
|
|
246
406
|
}
|
|
247
407
|
categoryScores["AI Crawlers"] = aiScore;
|
|
248
|
-
//
|
|
408
|
+
// ============================================================
|
|
409
|
+
// HREFLANG (new)
|
|
410
|
+
// ============================================================
|
|
411
|
+
let hreflangScore = 100;
|
|
412
|
+
const hreflangIssues = [];
|
|
413
|
+
const hreflangEntries = pageData.hreflang;
|
|
414
|
+
// B. Missing/empty href
|
|
415
|
+
for (const entry of hreflangEntries) {
|
|
416
|
+
if (!entry.href || entry.href.trim() === "") {
|
|
417
|
+
hreflangScore -= 15;
|
|
418
|
+
const msg = `Hreflang entry for lang "${entry.hreflang}" has missing or empty href.`;
|
|
419
|
+
hreflangIssues.push(msg);
|
|
420
|
+
issues.push({
|
|
421
|
+
category: "Hreflang",
|
|
422
|
+
severity: "medium",
|
|
423
|
+
title: "Hreflang entry with missing href",
|
|
424
|
+
description: msg,
|
|
425
|
+
recommendation: "Ensure every hreflang tag has a valid, absolute URL in its href attribute.",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// B. Self-referencing hreflang missing
|
|
430
|
+
if (hreflangEntries.length > 0) {
|
|
431
|
+
const pageUrl = fetchResult.finalUrl;
|
|
432
|
+
const hasSelfRef = hreflangEntries.some((e) => {
|
|
433
|
+
try {
|
|
434
|
+
return new URL(e.href).href === new URL(pageUrl).href;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
if (!hasSelfRef) {
|
|
441
|
+
hreflangScore -= 10;
|
|
442
|
+
const msg = "No self-referencing hreflang tag found for the current page URL.";
|
|
443
|
+
hreflangIssues.push(msg);
|
|
444
|
+
issues.push({
|
|
445
|
+
category: "Hreflang",
|
|
446
|
+
severity: "low",
|
|
447
|
+
title: "Missing self-referencing hreflang",
|
|
448
|
+
description: msg,
|
|
449
|
+
recommendation: "Each page should include a self-referencing hreflang tag alongside alternate language tags.",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// B. Conflicting hreflang (same lang, different href)
|
|
454
|
+
const langMap = new Map();
|
|
455
|
+
for (const entry of hreflangEntries) {
|
|
456
|
+
if (!entry.hreflang || !entry.href)
|
|
457
|
+
continue;
|
|
458
|
+
const existing = langMap.get(entry.hreflang) ?? [];
|
|
459
|
+
existing.push(entry.href);
|
|
460
|
+
langMap.set(entry.hreflang, existing);
|
|
461
|
+
}
|
|
462
|
+
for (const [lang, hrefs] of langMap) {
|
|
463
|
+
const uniqueHrefs = [...new Set(hrefs)];
|
|
464
|
+
if (uniqueHrefs.length > 1) {
|
|
465
|
+
hreflangScore -= 15;
|
|
466
|
+
const msg = `Conflicting hreflang entries for lang "${lang}": ${uniqueHrefs.join(", ")}.`;
|
|
467
|
+
hreflangIssues.push(msg);
|
|
468
|
+
issues.push({
|
|
469
|
+
category: "Hreflang",
|
|
470
|
+
severity: "medium",
|
|
471
|
+
title: "Conflicting hreflang entries",
|
|
472
|
+
description: msg,
|
|
473
|
+
recommendation: `Fix duplicate or conflicting hreflang tags for "${lang}" — each language should have exactly one URL.`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
categoryScores["Hreflang"] = Math.max(0, hreflangScore);
|
|
478
|
+
// ============================================================
|
|
479
|
+
// CONTENT TYPE (new)
|
|
480
|
+
// ============================================================
|
|
481
|
+
let contentTypeScore = 100;
|
|
482
|
+
const contentTypeHeader = fetchResult.headers["content-type"] ?? null;
|
|
483
|
+
if (!contentTypeHeader) {
|
|
484
|
+
contentTypeScore -= 20;
|
|
485
|
+
issues.push({
|
|
486
|
+
category: "Content Type",
|
|
487
|
+
severity: "high",
|
|
488
|
+
title: "Missing Content-Type header",
|
|
489
|
+
description: "The response does not include a Content-Type header.",
|
|
490
|
+
recommendation: "Configure the server to always send a Content-Type header (e.g., text/html; charset=utf-8).",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
else if (!contentTypeHeader.toLowerCase().includes("text/html")) {
|
|
494
|
+
contentTypeScore -= 20;
|
|
495
|
+
issues.push({
|
|
496
|
+
category: "Content Type",
|
|
497
|
+
severity: "high",
|
|
498
|
+
title: "Non-HTML Content-Type header",
|
|
499
|
+
description: `Content-Type is "${contentTypeHeader}" — expected "text/html".`,
|
|
500
|
+
recommendation: "Ensure HTML pages are served with Content-Type: text/html for proper rendering and indexing.",
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// Has text/html — check for charset
|
|
505
|
+
if (!/charset=/i.test(contentTypeHeader)) {
|
|
506
|
+
contentTypeScore -= 5;
|
|
507
|
+
issues.push({
|
|
508
|
+
category: "Content Type",
|
|
509
|
+
severity: "low",
|
|
510
|
+
title: "Content-Type missing charset parameter",
|
|
511
|
+
description: `Content-Type is "${contentTypeHeader}" but no charset parameter found.`,
|
|
512
|
+
recommendation: "Add charset=utf-8 to the Content-Type header to ensure correct character decoding.",
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
categoryScores["Content Type"] = Math.max(0, contentTypeScore);
|
|
517
|
+
// ============================================================
|
|
518
|
+
// COMPRESSION & CACHING (new)
|
|
519
|
+
// ============================================================
|
|
520
|
+
let compressionCacheScore = 100;
|
|
521
|
+
const contentEncoding = fetchResult.headers["content-encoding"] ?? null;
|
|
522
|
+
const cacheControl = fetchResult.headers["cache-control"];
|
|
523
|
+
const etag = fetchResult.headers["etag"];
|
|
524
|
+
const lastModified = fetchResult.headers["last-modified"];
|
|
525
|
+
// E. Compression check
|
|
526
|
+
if (!contentEncoding) {
|
|
527
|
+
compressionCacheScore -= 5;
|
|
528
|
+
issues.push({
|
|
529
|
+
category: "Compression & Caching",
|
|
530
|
+
severity: "low",
|
|
531
|
+
title: "No content compression",
|
|
532
|
+
description: "Content-Encoding header is missing — response is sent uncompressed.",
|
|
533
|
+
recommendation: "Enable gzip or brotli compression on the server to reduce transfer size.",
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
const validEncodings = ["gzip", "br", "deflate"];
|
|
538
|
+
if (!validEncodings.includes(contentEncoding.toLowerCase())) {
|
|
539
|
+
issues.push({
|
|
540
|
+
category: "Compression & Caching",
|
|
541
|
+
severity: "low",
|
|
542
|
+
title: "Uncommon content encoding",
|
|
543
|
+
description: `Content-Encoding is "${contentEncoding}" — not one of gzip, br, deflate.`,
|
|
544
|
+
recommendation: "Use gzip or brotli for broad browser compatibility and efficiency.",
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// F. Cache headers
|
|
549
|
+
if (!cacheControl) {
|
|
550
|
+
compressionCacheScore -= 5;
|
|
551
|
+
issues.push({
|
|
552
|
+
category: "Compression & Caching",
|
|
553
|
+
severity: "low",
|
|
554
|
+
title: "Missing Cache-Control header",
|
|
555
|
+
description: "No Cache-Control header found — browsers may use heuristic caching.",
|
|
556
|
+
recommendation: "Set an explicit Cache-Control header to define caching behavior.",
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (!etag && !lastModified) {
|
|
560
|
+
compressionCacheScore -= 5;
|
|
561
|
+
issues.push({
|
|
562
|
+
category: "Compression & Caching",
|
|
563
|
+
severity: "low",
|
|
564
|
+
title: "Missing cache validation headers",
|
|
565
|
+
description: "Neither ETag nor Last-Modified header is present.",
|
|
566
|
+
recommendation: "Add ETag or Last-Modified headers to enable conditional requests and efficient revalidation.",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (cacheControl && cacheControl.toLowerCase().includes("no-store")) {
|
|
570
|
+
issues.push({
|
|
571
|
+
category: "Compression & Caching",
|
|
572
|
+
severity: "low",
|
|
573
|
+
title: "Cache-Control: no-store may impact bfcache",
|
|
574
|
+
description: "Cache-Control header contains 'no-store', which prevents back/forward cache usage.",
|
|
575
|
+
recommendation: "Avoid 'no-store' unless absolutely necessary — it degrades navigation performance for returning visitors.",
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
categoryScores["Compression & Caching"] = Math.max(0, compressionCacheScore);
|
|
579
|
+
// ============================================================
|
|
580
|
+
// COMPUTE OVERALL SCORE
|
|
581
|
+
// ============================================================
|
|
249
582
|
const scores = Object.values(categoryScores);
|
|
250
|
-
const overallScore = scores.length > 0
|
|
251
|
-
|
|
252
|
-
|
|
583
|
+
const overallScore = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
|
|
584
|
+
// ============================================================
|
|
585
|
+
// SOFT 404 RISK (derived from checks above)
|
|
586
|
+
// ============================================================
|
|
587
|
+
let soft404Risk = "none";
|
|
588
|
+
if (fetchResult.statusCode === 200) {
|
|
589
|
+
if (pageData.wordCount < 50) {
|
|
590
|
+
soft404Risk = "high";
|
|
591
|
+
}
|
|
592
|
+
else if (pageData.wordCount < 100 && pageData.h1Count === 0) {
|
|
593
|
+
soft404Risk = "high";
|
|
594
|
+
}
|
|
595
|
+
else if (fetchResult.contentLengthBytes < 1000) {
|
|
596
|
+
soft404Risk = "low";
|
|
597
|
+
}
|
|
598
|
+
}
|
|
253
599
|
return {
|
|
254
600
|
url: fetchResult.finalUrl,
|
|
255
601
|
score: overallScore,
|
|
@@ -260,9 +606,34 @@ export async function auditTechnical(url, config, pageData, fetchResult) {
|
|
|
260
606
|
sitemapDeclared: robotsParsed.sitemapDeclared,
|
|
261
607
|
aiCrawlerRules: robotsParsed.aiCrawlerRules,
|
|
262
608
|
content: robotsContent || undefined,
|
|
609
|
+
disallowRules: robotsParsed.disallowRules,
|
|
610
|
+
sitemapUrls: robotsParsed.sitemapUrls,
|
|
263
611
|
},
|
|
264
612
|
securityHeaders,
|
|
265
613
|
isHttps,
|
|
266
614
|
mobileOptimized,
|
|
615
|
+
hreflang: {
|
|
616
|
+
entries: pageData.hreflang.length,
|
|
617
|
+
issues: hreflangIssues,
|
|
618
|
+
},
|
|
619
|
+
contentType: contentTypeHeader,
|
|
620
|
+
compression: contentEncoding,
|
|
621
|
+
cacheHeaders: {
|
|
622
|
+
cacheControl,
|
|
623
|
+
etag,
|
|
624
|
+
lastModified,
|
|
625
|
+
},
|
|
626
|
+
redirectChainQuality: {
|
|
627
|
+
hops: redirectChain.length,
|
|
628
|
+
permanentRedirects: permanentCount,
|
|
629
|
+
temporaryRedirects: temporaryCount,
|
|
630
|
+
issues: redirectQualityIssues,
|
|
631
|
+
},
|
|
632
|
+
soft404Risk,
|
|
633
|
+
pagination: {
|
|
634
|
+
detected: paginationDetected,
|
|
635
|
+
hasNext,
|
|
636
|
+
hasPrev,
|
|
637
|
+
},
|
|
267
638
|
};
|
|
268
639
|
}
|
|
@@ -19,6 +19,8 @@ export interface SeoConfig {
|
|
|
19
19
|
blockRenderResources: string[];
|
|
20
20
|
/** Whether SSRF protection is enabled (blocks private/internal IPs). */
|
|
21
21
|
ssrfProtection: boolean;
|
|
22
|
+
maxResponseBytes: number;
|
|
23
|
+
stopWords: string[];
|
|
22
24
|
}
|
|
23
25
|
export declare function loadSeoConfig(): SeoConfig;
|
|
24
26
|
//# sourceMappingURL=config.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/seo/config.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,SAAS;IACxB,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,mDAAmD;IACnD,eAAe,EAAE,OAAO,CAAC;IACzB,6CAA6C;IAC7C,YAAY,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,eAAe,EAAE,SAAS,GAAG,QAAQ,CAAC;IACtC,iEAAiE;IACjE,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,wEAAwE;IACxE,cAAc,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/seo/config.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,SAAS;IACxB,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,mDAAmD;IACnD,eAAe,EAAE,OAAO,CAAC;IACzB,6CAA6C;IAC7C,YAAY,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,eAAe,EAAE,SAAS,GAAG,QAAQ,CAAC;IACtC,iEAAiE;IACjE,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,wEAAwE;IACxE,cAAc,EAAE,OAAO,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AA8BD,wBAAgB,aAAa,IAAI,SAAS,CAiBzC"}
|
|
@@ -44,5 +44,7 @@ export function loadSeoConfig() {
|
|
|
44
44
|
defaultViewport: process.env.SEO_VIEWPORT ?? "desktop",
|
|
45
45
|
blockRenderResources: envList("SEO_BLOCK_RESOURCES", ["image", "font", "stylesheet"]),
|
|
46
46
|
ssrfProtection: envBool("SEO_SSRF_PROTECTION", true),
|
|
47
|
+
maxResponseBytes: envInt("SEO_MAX_RESPONSE_BYTES", 10 * 1024 * 1024),
|
|
48
|
+
stopWords: envList("SEO_STOP_WORDS", []),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
@@ -41,7 +41,7 @@ export function registerBatchTools(ext, config) {
|
|
|
41
41
|
const pageData = parsePage(fetchResult.content, fetchResult.finalUrl);
|
|
42
42
|
const technical = await auditTechnical(url, config, pageData, fetchResult);
|
|
43
43
|
const text = extractText(fetchResult.content);
|
|
44
|
-
const content = analyzeContentQuality(text, fetchResult.finalUrl);
|
|
44
|
+
const content = analyzeContentQuality(text, fetchResult.finalUrl, pageData);
|
|
45
45
|
results.push({
|
|
46
46
|
url: fetchResult.finalUrl,
|
|
47
47
|
status: fetchResult.statusCode,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/content.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAM9C,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/content.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAM9C,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CA2D/E"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { analyzeContentQuality } from "../analyzers/content.js";
|
|
2
2
|
import { fetchPage } from "../utils/fetcher.js";
|
|
3
|
-
import {
|
|
3
|
+
import { extractContentText, parsePage } from "../utils/parser.js";
|
|
4
4
|
import { errorResult, textResult } from "./helpers.js";
|
|
5
5
|
export function registerContentTools(ext, config) {
|
|
6
6
|
const contentTool = {
|
|
@@ -22,18 +22,20 @@ export function registerContentTools(ext, config) {
|
|
|
22
22
|
try {
|
|
23
23
|
let content = text ?? "";
|
|
24
24
|
let effectiveUrl;
|
|
25
|
+
let pageData = undefined;
|
|
25
26
|
if (url && !text) {
|
|
26
27
|
const result = await fetchPage(url, config);
|
|
27
28
|
if (result.error) {
|
|
28
29
|
return errorResult(`Fetch failed: ${result.error}`);
|
|
29
30
|
}
|
|
30
|
-
content =
|
|
31
|
+
content = extractContentText(result.content);
|
|
31
32
|
effectiveUrl = result.finalUrl;
|
|
33
|
+
pageData = parsePage(result.content, result.finalUrl);
|
|
32
34
|
}
|
|
33
35
|
if (!content) {
|
|
34
36
|
return errorResult("Either 'url' or 'text' must be provided.");
|
|
35
37
|
}
|
|
36
|
-
const result = analyzeContentQuality(content, effectiveUrl);
|
|
38
|
+
const result = analyzeContentQuality(content, effectiveUrl, pageData, config.stopWords);
|
|
37
39
|
const summary = `✓ Content Quality Analysis${effectiveUrl ? `: ${effectiveUrl}` : ""}\n` +
|
|
38
40
|
`Word Count: ${result.wordCount}\n` +
|
|
39
41
|
`Overall Quality: ${result.overallQuality}/100\n\n` +
|
|
@@ -6,6 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ExtensionAPI } from "../../../sdk/coding-agent/index.js";
|
|
8
8
|
import type { SeoConfig } from "../config.js";
|
|
9
|
-
export type AuditCategory = "technical" | "content" | "schema" | "images" | "preload" | "agentUx";
|
|
9
|
+
export type AuditCategory = "technical" | "content" | "schema" | "images" | "preload" | "agentUx" | "performance";
|
|
10
10
|
export declare function registerFullAuditTools(ext: ExtensionAPI, config: SeoConfig): void;
|
|
11
11
|
//# sourceMappingURL=full-audit.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"full-audit.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/full-audit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"full-audit.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/full-audit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAe9C,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AAIlH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAqEjF"}
|