@aexol/spectral 0.9.126 → 0.9.128

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 (89) hide show
  1. package/dist/agent/index.d.ts.map +1 -1
  2. package/dist/agent/index.js +5 -0
  3. package/dist/extensions/seo/analyzers/content.d.ts +29 -6
  4. package/dist/extensions/seo/analyzers/content.d.ts.map +1 -1
  5. package/dist/extensions/seo/analyzers/content.js +243 -8
  6. package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -1
  7. package/dist/extensions/seo/analyzers/crawler.js +133 -7
  8. package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -1
  9. package/dist/extensions/seo/analyzers/drift.js +86 -29
  10. package/dist/extensions/seo/analyzers/images.d.ts +10 -4
  11. package/dist/extensions/seo/analyzers/images.d.ts.map +1 -1
  12. package/dist/extensions/seo/analyzers/images.js +153 -5
  13. package/dist/extensions/seo/analyzers/performance.d.ts +3 -3
  14. package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -1
  15. package/dist/extensions/seo/analyzers/performance.js +77 -6
  16. package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -1
  17. package/dist/extensions/seo/analyzers/readability.js +17 -9
  18. package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -1
  19. package/dist/extensions/seo/analyzers/schema.js +99 -10
  20. package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -1
  21. package/dist/extensions/seo/analyzers/sitemap.js +57 -16
  22. package/dist/extensions/seo/analyzers/technical.d.ts +0 -7
  23. package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -1
  24. package/dist/extensions/seo/analyzers/technical.js +408 -37
  25. package/dist/extensions/seo/config.d.ts +2 -0
  26. package/dist/extensions/seo/config.d.ts.map +1 -1
  27. package/dist/extensions/seo/config.js +2 -0
  28. package/dist/extensions/seo/tools/batch.js +1 -1
  29. package/dist/extensions/seo/tools/content.d.ts.map +1 -1
  30. package/dist/extensions/seo/tools/content.js +5 -3
  31. package/dist/extensions/seo/tools/full-audit.d.ts +1 -1
  32. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  33. package/dist/extensions/seo/tools/full-audit.js +391 -66
  34. package/dist/extensions/seo/tools/helpers.d.ts +15 -4
  35. package/dist/extensions/seo/tools/helpers.d.ts.map +1 -1
  36. package/dist/extensions/seo/tools/helpers.js +57 -6
  37. package/dist/extensions/seo/tools/performance.d.ts.map +1 -1
  38. package/dist/extensions/seo/tools/performance.js +17 -11
  39. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  40. package/dist/extensions/seo/tools/report.js +513 -47
  41. package/dist/extensions/seo/types.d.ts +295 -3
  42. package/dist/extensions/seo/types.d.ts.map +1 -1
  43. package/dist/extensions/seo/utils/fetcher.d.ts +4 -0
  44. package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -1
  45. package/dist/extensions/seo/utils/fetcher.js +77 -11
  46. package/dist/extensions/seo/utils/parser.d.ts +3 -1
  47. package/dist/extensions/seo/utils/parser.d.ts.map +1 -1
  48. package/dist/extensions/seo/utils/parser.js +105 -25
  49. package/dist/extensions/seo/utils/renderer.d.ts +6 -1
  50. package/dist/extensions/seo/utils/renderer.d.ts.map +1 -1
  51. package/dist/extensions/seo/utils/renderer.js +207 -1
  52. package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -1
  53. package/dist/extensions/seo/utils/url-safety.js +19 -3
  54. package/dist/extensions/web/tools/fetch.js +2 -2
  55. package/dist/memory/tools/compact-context.d.ts.map +1 -1
  56. package/dist/memory/tools/compact-context.js +10 -3
  57. package/dist/sdk/agent-core/agent-loop.d.ts.map +1 -1
  58. package/dist/sdk/agent-core/agent-loop.js +87 -4
  59. package/dist/sdk/agent-core/types.d.ts +1 -0
  60. package/dist/sdk/agent-core/types.d.ts.map +1 -1
  61. package/dist/sdk/ai/utils/validation.d.ts +0 -7
  62. package/dist/sdk/ai/utils/validation.d.ts.map +1 -1
  63. package/dist/sdk/ai/utils/validation.js +21 -1
  64. package/dist/sdk/coding-agent/core/extensions/runner.d.ts.map +1 -1
  65. package/dist/sdk/coding-agent/core/extensions/runner.js +15 -1
  66. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts +7 -2
  67. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts.map +1 -1
  68. package/dist/sdk/coding-agent/core/tools/apply-patch.js +219 -41
  69. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts +30 -0
  70. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts.map +1 -0
  71. package/dist/sdk/coding-agent/core/tools/atomic-write.js +42 -0
  72. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts +10 -4
  73. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts.map +1 -1
  74. package/dist/sdk/coding-agent/core/tools/edit-diff.js +44 -24
  75. package/dist/sdk/coding-agent/core/tools/edit.d.ts +6 -0
  76. package/dist/sdk/coding-agent/core/tools/edit.d.ts.map +1 -1
  77. package/dist/sdk/coding-agent/core/tools/edit.js +31 -5
  78. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts +9 -3
  79. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts.map +1 -1
  80. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.js +42 -3
  81. package/dist/sdk/coding-agent/core/tools/read.d.ts.map +1 -1
  82. package/dist/sdk/coding-agent/core/tools/read.js +79 -47
  83. package/dist/sdk/coding-agent/core/tools/truncate.d.ts +4 -2
  84. package/dist/sdk/coding-agent/core/tools/truncate.d.ts.map +1 -1
  85. package/dist/sdk/coding-agent/core/tools/truncate.js +43 -23
  86. package/dist/sdk/coding-agent/core/tools/write.d.ts +5 -8
  87. package/dist/sdk/coding-agent/core/tools/write.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/tools/write.js +29 -11
  89. 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
- // ignore
74
+ // robots.txt fetch failed gracefully
70
75
  }
71
76
  const robotsContent = robotsResult?.statusCode === 200 ? robotsResult.content : "";
72
- const robotsParsed = robotsContent ? parseRobotsTxt(robotsContent) : { sitemapDeclared: false, aiCrawlerRules: [] };
73
- // --- Crawlability ---
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
- // --- Indexability ---
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: "low",
174
+ severity: "medium",
123
175
  title: "Multiple H1 headings",
124
- description: `Found ${pageData.h1Count} H1 elements. Best practice is one H1 per page.`,
125
- recommendation: "Use a single H1 per page; demote additional H1s to H2.",
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
- if (pageData.canonical) {
129
- const canonicalUrl = new URL(pageData.canonical, fetchResult.finalUrl).href;
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
- // --- Security ---
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
- // --- URL Structure ---
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
- // Check for uppercase in path.
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
- // ignore
374
+ // URL parse failed — skip
218
375
  }
219
376
  categoryScores["URL Structure"] = Math.max(0, urlScore);
220
- // --- Mobile Optimization ---
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
- // --- AI Crawler Management ---
393
+ // ============================================================
394
+ // AI CRAWLERS (existing)
395
+ // ============================================================
235
396
  let aiScore = 100;
236
397
  if (robotsParsed.aiCrawlerRules.length === 0) {
237
- // Not an error, but an opportunity.
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
- // --- Overall score ---
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
- ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
252
- : 0;
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;CACzB;AA8BD,wBAAgB,aAAa,IAAI,SAAS,CAezC"}
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,CAyD/E"}
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 { extractText } from "../utils/parser.js";
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 = extractText(result.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;AAa9C,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,CAAC;AAIlG,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CA+DjF"}
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"}