@aexol/spectral 0.9.126 → 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/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/package.json +1 -1
|
@@ -1,27 +1,28 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* seo_report tool — generate formatted reports from audit results.
|
|
3
|
-
*/
|
|
4
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
-
import {
|
|
2
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
6
3
|
import { errorResult, textResult } from "./helpers.js";
|
|
7
4
|
export function registerReportTools(ext, config) {
|
|
8
5
|
const reportTool = {
|
|
9
6
|
name: "seo_report",
|
|
10
7
|
label: "SEO Report Generator",
|
|
11
8
|
description: "Generate a formatted SEO report from audit results. " +
|
|
12
|
-
"Accepts a FullAuditResult object (from seo_audit)
|
|
9
|
+
"Accepts a FullAuditResult object (from seo_audit) OR an array of FullAuditResult objects for batch reporting. " +
|
|
10
|
+
"Produces a Markdown, JSON, SARIF, or HTML report. " +
|
|
13
11
|
"Optionally writes the report to a file in the SEO output directory (default: ~/.spectral/seo-reports/).",
|
|
14
|
-
promptSnippet: "`seo_report { auditResult, format?, outputPath? }` — generate formatted SEO report",
|
|
12
|
+
promptSnippet: "`seo_report { auditResult, format?, outputPath? }` — generate formatted SEO report (single or batch)",
|
|
15
13
|
parameters: {
|
|
16
14
|
type: "object",
|
|
17
15
|
properties: {
|
|
18
16
|
auditResult: {
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
oneOf: [
|
|
18
|
+
{ type: "object", description: "A single FullAuditResult object from seo_audit." },
|
|
19
|
+
{ type: "array", items: { type: "object" }, description: "An array of FullAuditResult objects for batch reporting." },
|
|
20
|
+
],
|
|
21
|
+
description: "The FullAuditResult object (or array of them) from seo_audit.",
|
|
21
22
|
},
|
|
22
23
|
format: {
|
|
23
24
|
type: "string",
|
|
24
|
-
enum: ["markdown", "json", "html"],
|
|
25
|
+
enum: ["markdown", "json", "sarif", "html"],
|
|
25
26
|
description: "Output format. Default: 'markdown'.",
|
|
26
27
|
},
|
|
27
28
|
outputPath: {
|
|
@@ -36,26 +37,57 @@ export function registerReportTools(ext, config) {
|
|
|
36
37
|
try {
|
|
37
38
|
const fmt = format ?? "markdown";
|
|
38
39
|
let content;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
if (Array.isArray(auditResult)) {
|
|
41
|
+
switch (fmt) {
|
|
42
|
+
case "json":
|
|
43
|
+
content = JSON.stringify(auditResult, null, 2);
|
|
44
|
+
break;
|
|
45
|
+
case "sarif":
|
|
46
|
+
const allResults = Array.isArray(auditResult) ? auditResult : [auditResult];
|
|
47
|
+
content = JSON.stringify(generateSarifBatchReport(allResults, config), null, 2);
|
|
48
|
+
break;
|
|
49
|
+
case "html":
|
|
50
|
+
content = auditResult.map((r) => generateHtmlReport(r, config)).join("\n<hr>\n");
|
|
51
|
+
break;
|
|
52
|
+
case "markdown":
|
|
53
|
+
default:
|
|
54
|
+
content = generateMarkdownReport(auditResult, config);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Cast to extended type for Phase 3B fields
|
|
60
|
+
const result = auditResult;
|
|
61
|
+
switch (fmt) {
|
|
62
|
+
case "json":
|
|
63
|
+
content = JSON.stringify(result, null, 2);
|
|
64
|
+
break;
|
|
65
|
+
case "sarif":
|
|
66
|
+
content = JSON.stringify(generateSarifReport(result), null, 2);
|
|
67
|
+
break;
|
|
68
|
+
case "html":
|
|
69
|
+
content = generateHtmlReport(result, config);
|
|
70
|
+
break;
|
|
71
|
+
case "markdown":
|
|
72
|
+
default:
|
|
73
|
+
content = generateMarkdownReport(result, config);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
50
76
|
}
|
|
51
77
|
let savedPath;
|
|
52
78
|
if (outputPath) {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
79
|
+
const resolvedOutputDir = resolve(config.outputDir);
|
|
80
|
+
const resolvedPath = isAbsolute(outputPath)
|
|
81
|
+
? resolve(outputPath)
|
|
82
|
+
: resolve(config.outputDir, outputPath);
|
|
83
|
+
// Path traversal guard: resolved path must stay within outputDir
|
|
84
|
+
const rel = relative(resolvedOutputDir, resolvedPath);
|
|
85
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
86
|
+
throw new Error(`SECURITY: Output path must be within the configured output directory (${resolvedOutputDir})`);
|
|
87
|
+
}
|
|
88
|
+
mkdirSync(resolve(resolvedPath, ".."), { recursive: true });
|
|
89
|
+
writeFileSync(resolvedPath, content, "utf-8");
|
|
90
|
+
savedPath = resolvedPath;
|
|
59
91
|
}
|
|
60
92
|
const summary = `✓ Report generated (${fmt})\n` +
|
|
61
93
|
(savedPath ? `Saved to: ${savedPath}\n` : "") +
|
|
@@ -73,20 +105,89 @@ export function registerReportTools(ext, config) {
|
|
|
73
105
|
};
|
|
74
106
|
ext.registerTool(reportTool);
|
|
75
107
|
}
|
|
76
|
-
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Report generators
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
function generateMarkdownReport(audit, config) {
|
|
112
|
+
if (Array.isArray(audit)) {
|
|
113
|
+
return generateBatchMarkdownReport(audit, config);
|
|
114
|
+
}
|
|
115
|
+
const esc = escapeMarkdown;
|
|
77
116
|
const lines = [
|
|
78
117
|
`# SEO Audit Report`,
|
|
79
118
|
``,
|
|
80
|
-
`**URL:** ${audit.url}`,
|
|
81
|
-
`**Date:** ${audit.auditedAt}`,
|
|
119
|
+
`**URL:** ${esc(audit.url)}`,
|
|
120
|
+
`**Date:** ${esc(audit.auditedAt)}`,
|
|
82
121
|
`**Overall Score:** ${audit.overallScore}/100`,
|
|
83
122
|
``,
|
|
84
123
|
];
|
|
124
|
+
// --- Severity Summary ---
|
|
125
|
+
lines.push(...buildSeveritySummary(audit.allIssues));
|
|
126
|
+
lines.push(``);
|
|
127
|
+
// Unified score breakdown table
|
|
128
|
+
lines.push(`## Category Scores`);
|
|
129
|
+
lines.push(`| Category | Score | Status |`);
|
|
130
|
+
lines.push(`|----------|-------|--------|`);
|
|
131
|
+
if (audit.technical) {
|
|
132
|
+
lines.push(`| Technical SEO | ${audit.technical.score}/100 | ✅ |`);
|
|
133
|
+
}
|
|
134
|
+
if (audit.content) {
|
|
135
|
+
lines.push(`| Content Quality | ${audit.content.overallQuality}/100 | ✅ |`);
|
|
136
|
+
}
|
|
137
|
+
if (audit.schema) {
|
|
138
|
+
lines.push(`| Schema | ${audit.schema.score}/100 | ✅ |`);
|
|
139
|
+
}
|
|
140
|
+
if (audit.images) {
|
|
141
|
+
lines.push(`| Images | ${audit.images.score}/100 | ✅ |`);
|
|
142
|
+
}
|
|
143
|
+
if (audit.preload) {
|
|
144
|
+
lines.push(`| Preload | ${audit.preload.score}/100 | ✅ |`);
|
|
145
|
+
}
|
|
146
|
+
if (audit.agentUx) {
|
|
147
|
+
lines.push(`| Agent UX | ${audit.agentUx.agentUxScore}/100 | ✅ |`);
|
|
148
|
+
}
|
|
149
|
+
else if (audit.skippedCategories.includes("agentUx") || !audit.rendererAvailable) {
|
|
150
|
+
lines.push(`| Agent UX | — | ⏭️ Skipped |`);
|
|
151
|
+
}
|
|
152
|
+
if (audit.performance) {
|
|
153
|
+
const perfSource = audit.performance.source ? ` (${audit.performance.source})` : "";
|
|
154
|
+
lines.push(`| Performance | ${audit.performance.score}/100 | ✅${perfSource} |`);
|
|
155
|
+
}
|
|
156
|
+
lines.push(``);
|
|
157
|
+
// --- Reproducibility ---
|
|
158
|
+
lines.push(`## Reproducibility`, ``);
|
|
159
|
+
lines.push(`- **User Agent:** \`${esc(config.userAgent)}\``);
|
|
160
|
+
lines.push(`- **Viewport:** ${esc(config.defaultViewport)}`);
|
|
161
|
+
lines.push(`- **Timeout:** ${config.timeout}ms`);
|
|
162
|
+
lines.push(`- **Timestamp:** ${esc(audit.auditedAt)}`);
|
|
163
|
+
lines.push(`- **Renderer:** ${audit.rendererAvailable ? "Available (Playwright/Chromium)" : "Unavailable"}`);
|
|
164
|
+
lines.push(`- **Audit Version:** ${audit.version ?? 1}`);
|
|
165
|
+
lines.push(``);
|
|
166
|
+
// Skipped / Failed Categories
|
|
167
|
+
if (audit.skippedCategories.length > 0) {
|
|
168
|
+
lines.push(`## Skipped / Failed Categories`);
|
|
169
|
+
lines.push(``);
|
|
170
|
+
for (const cat of audit.skippedCategories) {
|
|
171
|
+
lines.push(`- **${esc(cat)}** — not analyzed in this audit`);
|
|
172
|
+
}
|
|
173
|
+
if (!audit.rendererAvailable) {
|
|
174
|
+
lines.push(``);
|
|
175
|
+
lines.push(`_Browser rendering was not available. Agent UX and measured performance data are unavailable._`);
|
|
176
|
+
}
|
|
177
|
+
lines.push(``);
|
|
178
|
+
}
|
|
179
|
+
else if (!audit.rendererAvailable) {
|
|
180
|
+
lines.push(`## Skipped / Failed Categories`);
|
|
181
|
+
lines.push(``);
|
|
182
|
+
lines.push(`_Browser rendering was not available. Agent UX and measured performance data are unavailable._`);
|
|
183
|
+
lines.push(``);
|
|
184
|
+
}
|
|
185
|
+
// --- Technical SEO ---
|
|
85
186
|
if (audit.technical) {
|
|
86
187
|
lines.push(`## Technical SEO`, `**Score:** ${audit.technical.score}/100`, ``);
|
|
87
188
|
lines.push(`| Category | Score |`, `|----------|-------|`);
|
|
88
189
|
for (const [cat, score] of Object.entries(audit.technical.categoryScores)) {
|
|
89
|
-
lines.push(`| ${cat} | ${score}/100 |`);
|
|
190
|
+
lines.push(`| ${esc(cat)} | ${score}/100 |`);
|
|
90
191
|
}
|
|
91
192
|
lines.push(``);
|
|
92
193
|
lines.push(`**HTTPS:** ${audit.technical.isHttps ? "✓" : "✗"}`);
|
|
@@ -94,49 +195,225 @@ function generateMarkdownReport(audit) {
|
|
|
94
195
|
lines.push(`**robots.txt:** ${audit.technical.robotsTxt.exists ? "✓" : "✗"}`);
|
|
95
196
|
lines.push(``);
|
|
96
197
|
}
|
|
198
|
+
// --- Content Quality ---
|
|
97
199
|
if (audit.content) {
|
|
98
|
-
lines.push(`## Content Quality`, `**Overall Quality:** ${audit.content.overallQuality}/100`, ``, `- Word count: ${audit.content.wordCount}`, `- Filler score: ${audit.content.fillerScore}/100`, `- AI pattern score: ${audit.content.aiPatternScore}/100`, `- Information density: ${audit.content.informationDensity}/100`, `- Repetition score: ${audit.content.repetitionScore}/100`, `- Flags: ${audit.content.flags.join(", ") || "none"}`, ``);
|
|
200
|
+
lines.push(`## Content Quality`, `**Overall Quality:** ${audit.content.overallQuality}/100`, ``, `- Word count: ${audit.content.wordCount}`, `- Filler score: ${audit.content.fillerScore}/100`, `- AI pattern score: ${audit.content.aiPatternScore}/100`, `- Information density: ${audit.content.informationDensity}/100`, `- Repetition score: ${audit.content.repetitionScore}/100`, `- Flags: ${audit.content.flags.map((f) => esc(f)).join(", ") || "none"}`, ``);
|
|
99
201
|
}
|
|
202
|
+
// --- Schema Markup ---
|
|
100
203
|
if (audit.schema) {
|
|
101
|
-
lines.push(`## Schema Markup`, `**Detected schemas:** ${audit.schema.detectedSchemas.length}`, `**Deprecated types:** ${audit.schema.deprecatedTypes.join(", ") || "none"}`, ``);
|
|
204
|
+
lines.push(`## Schema Markup`, `**Detected schemas:** ${audit.schema.detectedSchemas.length}`, `**Deprecated types:** ${audit.schema.deprecatedTypes.map((t) => esc(t)).join(", ") || "none"}`, ``);
|
|
102
205
|
if (audit.schema.recommendations.length > 0) {
|
|
103
206
|
lines.push(`**Recommendations:**`);
|
|
104
207
|
for (const rec of audit.schema.recommendations) {
|
|
105
|
-
lines.push(`- ${rec}`);
|
|
208
|
+
lines.push(`- ${esc(rec)}`);
|
|
106
209
|
}
|
|
107
210
|
lines.push(``);
|
|
108
211
|
}
|
|
109
212
|
}
|
|
213
|
+
// --- Images ---
|
|
110
214
|
if (audit.images) {
|
|
111
215
|
lines.push(`## Images`, `**Total:** ${audit.images.totalImages}`, `**Missing alt:** ${audit.images.missingAlt}`, `**Missing dimensions:** ${audit.images.missingDimensions}`, `**Modern formats:** ${audit.images.modernFormats}`, ``);
|
|
112
216
|
}
|
|
217
|
+
// --- Preload & bfcache ---
|
|
113
218
|
if (audit.preload) {
|
|
114
219
|
lines.push(`## Preload & bfcache`, `**Score:** ${audit.preload.score}/100`, `**Speculation rules:** ${audit.preload.speculationRules.inlineBlocks} inline blocks`, `**Preload hints:** ${audit.preload.preloadHints}`, ``);
|
|
115
220
|
}
|
|
221
|
+
// --- Agent UX ---
|
|
116
222
|
if (audit.agentUx) {
|
|
117
223
|
lines.push(`## Agent UX`, `**Score:** ${audit.agentUx.agentUxScore}/100`, `**Real buttons:** ${audit.agentUx.html.realButtons}`, `**Real anchors:** ${audit.agentUx.html.realAnchors}`, `**div onclick widgets:** ${audit.agentUx.html.divOnclickWidgets}`, ``);
|
|
118
224
|
}
|
|
225
|
+
// --- Performance ---
|
|
226
|
+
if (audit.performance) {
|
|
227
|
+
const perf = audit.performance;
|
|
228
|
+
lines.push(`## Performance`, `**Score:** ${perf.score}/100`, ``);
|
|
229
|
+
lines.push(`- **LCP:** ${perf.lcp.value.toFixed(1)}ms (${perf.lcp.rating})` +
|
|
230
|
+
(perf.lcp.element
|
|
231
|
+
? typeof perf.lcp.element === "string"
|
|
232
|
+
? ` — element: \`${esc(perf.lcp.element)}\``
|
|
233
|
+
: ` — element: \`<${perf.lcp.element.tagName}>\`${perf.lcp.element.id ? ` #${perf.lcp.element.id}` : ""}${perf.lcp.element.src ? ` src=${perf.lcp.element.src}` : ""}${perf.lcp.element.size ? ` ${perf.lcp.element.size}` : ""}`
|
|
234
|
+
: ""));
|
|
235
|
+
lines.push(`- **CLS:** ${perf.cls.value.toFixed(3)} (${perf.cls.rating})`);
|
|
236
|
+
lines.push(`- **TBT:** ${perf.tbt.value.toFixed(1)}ms (${perf.tbt.rating})`);
|
|
237
|
+
lines.push(`- **FCP:** ${perf.fcp.value.toFixed(1)}ms (${perf.fcp.rating})`);
|
|
238
|
+
lines.push(`- **TTFB:** ${perf.ttfb.value.toFixed(1)}ms (${perf.ttfb.rating})`);
|
|
239
|
+
if (perf.source) {
|
|
240
|
+
lines.push(`- **Source:** ${perf.source}`);
|
|
241
|
+
}
|
|
242
|
+
lines.push(``);
|
|
243
|
+
if (perf.desktop) {
|
|
244
|
+
const d = perf.desktop;
|
|
245
|
+
lines.push(`### Desktop vs Mobile Comparison`, ``);
|
|
246
|
+
lines.push(`| Metric | Mobile | Desktop | Difference |`);
|
|
247
|
+
lines.push(`|--------|--------|---------|------------|`);
|
|
248
|
+
lines.push(`| LCP | ${perf.lcp.value.toFixed(1)}ms (${perf.lcp.rating}) | ${d.lcp.value.toFixed(1)}ms (${d.lcp.rating}) | ${diffMs(perf.lcp.value, d.lcp.value)} |`);
|
|
249
|
+
lines.push(`| CLS | ${perf.cls.value.toFixed(3)} (${perf.cls.rating}) | ${d.cls.value.toFixed(3)} (${d.cls.rating}) | ${diffMs(perf.cls.value, d.cls.value)} |`);
|
|
250
|
+
lines.push(`| TBT | ${perf.tbt.value.toFixed(1)}ms (${perf.tbt.rating}) | ${d.tbt.value.toFixed(1)}ms (${d.tbt.rating}) | ${diffMs(perf.tbt.value, d.tbt.value)} |`);
|
|
251
|
+
lines.push(``);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// --- Page Weight ---
|
|
255
|
+
if (audit.pageWeight) {
|
|
256
|
+
const pw = audit.pageWeight;
|
|
257
|
+
lines.push(`## Page Weight`, `**Total:** ${pw.totalKb.toFixed(1)} KB`, `**Resources:** ${pw.resourceCount}`, ``, `| Type | Size (KB) |`, `|------|-----------|`, `| HTML | ${pw.htmlKb.toFixed(1)} |`, `| CSS | ${pw.cssKb.toFixed(1)} |`, `| JS | ${pw.jsKb.toFixed(1)} |`, `| Images | ${pw.imageKb.toFixed(1)} |`, `| Fonts | ${pw.fontKb.toFixed(1)} |`, `| Other | ${pw.otherKb.toFixed(1)} |`, ``);
|
|
258
|
+
if (pw.topHeavyResources.length > 0) {
|
|
259
|
+
lines.push(`**Heaviest resources:**`);
|
|
260
|
+
for (const r of pw.topHeavyResources) {
|
|
261
|
+
lines.push(`- \`${esc(r.url)}\` — ${r.sizeKb.toFixed(1)} KB (${esc(r.type)})`);
|
|
262
|
+
}
|
|
263
|
+
lines.push(``);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// --- Render Blocking ---
|
|
267
|
+
if (audit.renderBlocking) {
|
|
268
|
+
const rb = audit.renderBlocking;
|
|
269
|
+
lines.push(`## Render Blocking`, `**Total blocking URLs:** ${rb.totalBlockingUrls}`, ``);
|
|
270
|
+
if (rb.blockingCss.length > 0) {
|
|
271
|
+
lines.push(`**Blocking CSS:**`);
|
|
272
|
+
for (const css of rb.blockingCss) {
|
|
273
|
+
lines.push(`- \`${esc(css.href)}\` (est. ${css.sizeEstimate} bytes)`);
|
|
274
|
+
}
|
|
275
|
+
lines.push(``);
|
|
276
|
+
}
|
|
277
|
+
if (rb.blockingJs.length > 0) {
|
|
278
|
+
lines.push(`**Blocking JS:**`);
|
|
279
|
+
for (const js of rb.blockingJs) {
|
|
280
|
+
lines.push(`- \`${esc(js.src)}\` (est. ${js.sizeEstimate} bytes)`);
|
|
281
|
+
}
|
|
282
|
+
lines.push(``);
|
|
283
|
+
}
|
|
284
|
+
if (rb.recommendations.length > 0) {
|
|
285
|
+
lines.push(`**Recommendations:**`);
|
|
286
|
+
for (const rec of rb.recommendations) {
|
|
287
|
+
lines.push(`- ${esc(rec)}`);
|
|
288
|
+
}
|
|
289
|
+
lines.push(``);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// --- Font Audit ---
|
|
293
|
+
if (audit.fontAudit) {
|
|
294
|
+
const fa = audit.fontAudit;
|
|
295
|
+
lines.push(`## Font Audit`, `**Score:** ${fa.score}/100`, `**Has font-display:** ${fa.hasFontDisplay ? "✓" : "✗"}`, `**Fonts found:** ${fa.fontsFound.length}`, ``);
|
|
296
|
+
if (fa.fontsFound.length > 0) {
|
|
297
|
+
lines.push(`| Family | Source | Format | font-display | Subset |`);
|
|
298
|
+
lines.push(`|--------|--------|--------|--------------|--------|`);
|
|
299
|
+
for (const f of fa.fontsFound) {
|
|
300
|
+
lines.push(`| ${esc(f.family)} | ${esc(f.source)} | ${esc(f.format)} | ${f.hasDisplay ? "✓" : "✗"} | ${f.isSubset ? "✓" : "✗"} |`);
|
|
301
|
+
}
|
|
302
|
+
lines.push(``);
|
|
303
|
+
}
|
|
304
|
+
if (fa.recommendations.length > 0) {
|
|
305
|
+
lines.push(`**Recommendations:**`);
|
|
306
|
+
for (const rec of fa.recommendations) {
|
|
307
|
+
lines.push(`- ${esc(rec)}`);
|
|
308
|
+
}
|
|
309
|
+
lines.push(``);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// --- Sitemap ---
|
|
313
|
+
if (audit.sitemap) {
|
|
314
|
+
const sm = audit.sitemap;
|
|
315
|
+
lines.push(`## Sitemap`, `**URL:** ${esc(sm.url)}`, `**URL count:** ${sm.urlCount}`, `**Size:** ${(sm.sizeBytes / 1024).toFixed(1)} KB`, `**Lastmod coverage:** ${(sm.lastmodCoverage * 100).toFixed(0)}%`, `**Invalid entries:** ${sm.invalidEntries}`, `**Is sitemap index:** ${sm.isSitemapIndex ? "Yes" : "No"}`, ``);
|
|
316
|
+
if (sm.warnings.length > 0) {
|
|
317
|
+
lines.push(`**Warnings:**`);
|
|
318
|
+
for (const w of sm.warnings) {
|
|
319
|
+
lines.push(`- ${esc(w)}`);
|
|
320
|
+
}
|
|
321
|
+
lines.push(``);
|
|
322
|
+
}
|
|
323
|
+
if (sm.childSitemaps.length > 0) {
|
|
324
|
+
lines.push(`**Child sitemaps:**`);
|
|
325
|
+
for (const cs of sm.childSitemaps) {
|
|
326
|
+
lines.push(`- ${esc(cs)}`);
|
|
327
|
+
}
|
|
328
|
+
lines.push(``);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// --- Issues ---
|
|
119
332
|
if (audit.allIssues.length > 0) {
|
|
120
333
|
lines.push(`## Issues (${audit.allIssues.length})`, ``);
|
|
121
334
|
const sorted = audit.allIssues.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
122
335
|
for (const issue of sorted) {
|
|
123
|
-
lines.push(`### [${issue.severity.toUpperCase()}] ${issue.title}`);
|
|
124
|
-
lines.push(`**Category:** ${issue.category}`);
|
|
125
|
-
lines.push(`**Description:** ${issue.description}`);
|
|
126
|
-
lines.push(`**Recommendation:** ${issue.recommendation}`);
|
|
336
|
+
lines.push(`### [${issue.severity.toUpperCase()}] ${esc(issue.title)}`);
|
|
337
|
+
lines.push(`**Category:** ${esc(issue.category)}`);
|
|
338
|
+
lines.push(`**Description:** ${esc(issue.description)}`);
|
|
339
|
+
lines.push(`**Recommendation:** ${esc(issue.recommendation)}`);
|
|
127
340
|
lines.push(``);
|
|
128
341
|
}
|
|
129
342
|
}
|
|
130
343
|
return lines.join("\n");
|
|
131
344
|
}
|
|
132
|
-
function
|
|
133
|
-
const
|
|
134
|
-
|
|
345
|
+
function generateBatchMarkdownReport(audits, config) {
|
|
346
|
+
const esc = escapeMarkdown;
|
|
347
|
+
if (audits.length === 0)
|
|
348
|
+
return "# Batch SEO Report\n\nNo results provided.";
|
|
349
|
+
const lines = [
|
|
350
|
+
`# Batch SEO Report`,
|
|
351
|
+
``,
|
|
352
|
+
`**Pages audited:** ${audits.length}`,
|
|
353
|
+
`**Generated:** ${new Date().toISOString()}`,
|
|
354
|
+
``,
|
|
355
|
+
];
|
|
356
|
+
const scores = audits.map((a) => a.overallScore);
|
|
357
|
+
const avgScore = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
|
|
358
|
+
const minScore = Math.min(...scores);
|
|
359
|
+
const maxScore = Math.max(...scores);
|
|
360
|
+
lines.push(`## Summary`, ``, `- **Average score:** ${avgScore}/100`, `- **Range:** ${minScore} — ${maxScore}`, `- **Worst page:** ${esc(audits.find((a) => a.overallScore === minScore)?.url ?? "-")}`, `- **Best page:** ${esc(audits.find((a) => a.overallScore === maxScore)?.url ?? "-")}`, ``);
|
|
361
|
+
const allIssues = audits.flatMap((a) => a.allIssues);
|
|
362
|
+
const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
363
|
+
for (const issue of allIssues) {
|
|
364
|
+
severityCounts[issue.severity] = (severityCounts[issue.severity] ?? 0) + 1;
|
|
365
|
+
}
|
|
366
|
+
lines.push(`## Aggregate Severity`, ``, `| Severity | Count |`, `|----------|-------|`, `| Critical | ${severityCounts.critical} |`, `| High | ${severityCounts.high} |`, `| Medium | ${severityCounts.medium} |`, `| Low | ${severityCounts.low} |`, `| **Total** | **${allIssues.length}** |`, ``);
|
|
367
|
+
// Skipped categories summary in batch
|
|
368
|
+
const pagesWithSkipped = audits.filter((a) => a.skippedCategories.length > 0);
|
|
369
|
+
if (pagesWithSkipped.length > 0) {
|
|
370
|
+
lines.push(`## Skipped / Failed Categories`, ``, `**${pagesWithSkipped.length}** of **${audits.length}** pages had skipped categories (e.g. renderer unavailable, fetch failed).`, ``);
|
|
371
|
+
}
|
|
372
|
+
lines.push(`## Per-Page Scores`, ``, `| URL | Score | Issues |`, `|-----|-------|--------|`);
|
|
373
|
+
const sorted = [...audits].sort((a, b) => b.overallScore - a.overallScore);
|
|
374
|
+
for (const a of sorted) {
|
|
375
|
+
lines.push(`| ${esc(a.url)} | ${a.overallScore}/100 | ${a.allIssues.length} |`);
|
|
376
|
+
}
|
|
377
|
+
lines.push(``);
|
|
378
|
+
const issueFreq = new Map();
|
|
379
|
+
for (const issue of allIssues) {
|
|
380
|
+
const key = issue.title.toLowerCase();
|
|
381
|
+
if (issueFreq.has(key)) {
|
|
382
|
+
issueFreq.get(key).count++;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
issueFreq.set(key, { title: issue.title, count: 1, severity: issue.severity });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const topIssues = [...issueFreq.values()]
|
|
389
|
+
.sort((a, b) => b.count - a.count)
|
|
390
|
+
.slice(0, 10);
|
|
391
|
+
if (topIssues.length > 0) {
|
|
392
|
+
lines.push(`## Top 10 Issues Across All Pages`, ``, `| Issue | Severity | Pages Affected |`, `|-------|----------|----------------|`);
|
|
393
|
+
for (const ti of topIssues) {
|
|
394
|
+
lines.push(`| ${esc(ti.title)} | ${ti.severity} | ${ti.count} |`);
|
|
395
|
+
}
|
|
396
|
+
lines.push(``);
|
|
397
|
+
}
|
|
398
|
+
return lines.join("\n");
|
|
399
|
+
}
|
|
400
|
+
function diffMs(a, b) {
|
|
401
|
+
const delta = a - b;
|
|
402
|
+
const sign = delta > 0 ? "+" : "";
|
|
403
|
+
const absDelta = Math.abs(delta);
|
|
404
|
+
if (absDelta < 1)
|
|
405
|
+
return `${sign}${delta.toFixed(3)}`;
|
|
406
|
+
if (absDelta < 10)
|
|
407
|
+
return `${sign}${delta.toFixed(1)}ms`;
|
|
408
|
+
return `${sign}${Math.round(delta)}ms`;
|
|
409
|
+
}
|
|
410
|
+
function generateHtmlReport(audit, config) {
|
|
411
|
+
const md = generateMarkdownReport(audit, config);
|
|
135
412
|
return `<!DOCTYPE html>
|
|
136
413
|
<html>
|
|
137
414
|
<head>
|
|
138
415
|
<meta charset="utf-8">
|
|
139
|
-
<title>SEO Audit Report — ${audit.url}</title>
|
|
416
|
+
<title>SEO Audit Report — ${escapeHtml(audit.url)}</title>
|
|
140
417
|
<style>
|
|
141
418
|
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }
|
|
142
419
|
h1 { border-bottom: 2px solid #333; }
|
|
@@ -150,18 +427,207 @@ td, th { border: 1px solid #ddd; padding: 0.5rem; }
|
|
|
150
427
|
</body>
|
|
151
428
|
</html>`;
|
|
152
429
|
}
|
|
430
|
+
function generateSarifReport(audit) {
|
|
431
|
+
return generateSarifSingleReport(audit, audit.allIssues ?? []);
|
|
432
|
+
}
|
|
433
|
+
function generateSarifSingleReport(audit, issues) {
|
|
434
|
+
// Build unique rules
|
|
435
|
+
const rules = [];
|
|
436
|
+
const seenRuleIds = new Set();
|
|
437
|
+
for (const issue of issues) {
|
|
438
|
+
const categorySlug = issue.category.toLowerCase().replace(/\s+/g, "-");
|
|
439
|
+
const ruleId = issue.issueId ?? `seo-${categorySlug}-${issue.severity}`;
|
|
440
|
+
if (!seenRuleIds.has(ruleId)) {
|
|
441
|
+
seenRuleIds.add(ruleId);
|
|
442
|
+
rules.push({
|
|
443
|
+
id: ruleId,
|
|
444
|
+
name: ruleId,
|
|
445
|
+
shortDescription: { text: `${issue.category}: ${issue.title}` },
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Build results with ruleIndex, properties, partialFingerprints
|
|
450
|
+
const results = issues.map((issue, i) => {
|
|
451
|
+
const categorySlug = issue.category.toLowerCase().replace(/\s+/g, "-");
|
|
452
|
+
const ruleId = issue.issueId ?? `seo-${categorySlug}-${issue.severity}`;
|
|
453
|
+
const ruleIndex = rules.findIndex((r) => r.id === ruleId);
|
|
454
|
+
return {
|
|
455
|
+
ruleId,
|
|
456
|
+
ruleIndex,
|
|
457
|
+
level: severityToSarifLevel(issue.severity),
|
|
458
|
+
message: { text: `${issue.title}: ${issue.description} \u2014 ${issue.recommendation}` },
|
|
459
|
+
locations: [{
|
|
460
|
+
physicalLocation: { artifactLocation: { uri: audit.url } },
|
|
461
|
+
}],
|
|
462
|
+
properties: {
|
|
463
|
+
category: issue.category,
|
|
464
|
+
severity: issue.severity,
|
|
465
|
+
score: audit.overallScore,
|
|
466
|
+
},
|
|
467
|
+
partialFingerprints: {
|
|
468
|
+
issueHash: `${ruleId}-${i}`,
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
});
|
|
472
|
+
return {
|
|
473
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
474
|
+
version: "2.1.0",
|
|
475
|
+
runs: [
|
|
476
|
+
{
|
|
477
|
+
tool: {
|
|
478
|
+
driver: {
|
|
479
|
+
name: "Spectral SEO Auditor",
|
|
480
|
+
version: "1.0.0",
|
|
481
|
+
semanticVersion: "1.0.0",
|
|
482
|
+
informationUri: "https://aexol.com",
|
|
483
|
+
rules,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
invocation: {
|
|
487
|
+
executionSuccessful: true,
|
|
488
|
+
startTimeUtc: audit.auditedAt,
|
|
489
|
+
endTimeUtc: new Date().toISOString(),
|
|
490
|
+
arguments: [
|
|
491
|
+
`url=${audit.url}`,
|
|
492
|
+
],
|
|
493
|
+
},
|
|
494
|
+
results,
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function generateSarifBatchReport(results, _config) {
|
|
500
|
+
const allIssues = results.flatMap((r) => r.allIssues ?? []);
|
|
501
|
+
// Build unique rules across all audits
|
|
502
|
+
const rules = [];
|
|
503
|
+
const seenRuleIds = new Set();
|
|
504
|
+
for (const issue of allIssues) {
|
|
505
|
+
const categorySlug = issue.category.toLowerCase().replace(/\s+/g, "-");
|
|
506
|
+
const ruleId = issue.issueId ?? `seo-${categorySlug}-${issue.severity}`;
|
|
507
|
+
if (!seenRuleIds.has(ruleId)) {
|
|
508
|
+
seenRuleIds.add(ruleId);
|
|
509
|
+
rules.push({
|
|
510
|
+
id: ruleId,
|
|
511
|
+
name: ruleId,
|
|
512
|
+
shortDescription: { text: `${issue.category}: ${issue.title}` },
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const sarifResults = allIssues.map((issue, i) => {
|
|
517
|
+
const categorySlug = issue.category.toLowerCase().replace(/\s+/g, "-");
|
|
518
|
+
const ruleId = issue.issueId ?? `seo-${categorySlug}-${issue.severity}`;
|
|
519
|
+
const ruleIndex = rules.findIndex((r) => r.id === ruleId);
|
|
520
|
+
return {
|
|
521
|
+
ruleId,
|
|
522
|
+
ruleIndex,
|
|
523
|
+
level: severityToSarifLevel(issue.severity),
|
|
524
|
+
message: { text: `${issue.title}: ${issue.description} \u2014 ${issue.recommendation}` },
|
|
525
|
+
locations: [{
|
|
526
|
+
physicalLocation: { artifactLocation: { uri: results[0]?.url ?? "unknown" } },
|
|
527
|
+
}],
|
|
528
|
+
properties: {
|
|
529
|
+
category: issue.category,
|
|
530
|
+
severity: issue.severity,
|
|
531
|
+
},
|
|
532
|
+
partialFingerprints: {
|
|
533
|
+
issueHash: `${ruleId}-${i}`,
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
});
|
|
537
|
+
return {
|
|
538
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
539
|
+
version: "2.1.0",
|
|
540
|
+
runs: [
|
|
541
|
+
{
|
|
542
|
+
tool: {
|
|
543
|
+
driver: {
|
|
544
|
+
name: "Spectral SEO Auditor",
|
|
545
|
+
version: "1.0.0",
|
|
546
|
+
semanticVersion: "1.0.0",
|
|
547
|
+
informationUri: "https://aexol.com",
|
|
548
|
+
rules,
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
invocation: {
|
|
552
|
+
executionSuccessful: true,
|
|
553
|
+
startTimeUtc: results.reduce((earliest, r) => r.auditedAt < earliest ? r.auditedAt : earliest, results[0]?.auditedAt ?? new Date().toISOString()),
|
|
554
|
+
endTimeUtc: new Date().toISOString(),
|
|
555
|
+
arguments: results.map((r) => `url=${r.url}`),
|
|
556
|
+
},
|
|
557
|
+
results: sarifResults,
|
|
558
|
+
},
|
|
559
|
+
],
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Helpers
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
function buildSeveritySummary(issues) {
|
|
566
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
567
|
+
for (const issue of issues) {
|
|
568
|
+
counts[issue.severity] = (counts[issue.severity] ?? 0) + 1;
|
|
569
|
+
}
|
|
570
|
+
const total = issues.length;
|
|
571
|
+
return [
|
|
572
|
+
`## Severity Summary`,
|
|
573
|
+
``,
|
|
574
|
+
`| Severity | Count |`,
|
|
575
|
+
`|----------|-------|`,
|
|
576
|
+
`| Critical | ${counts.critical} |`,
|
|
577
|
+
`| High | ${counts.high} |`,
|
|
578
|
+
`| Medium | ${counts.medium} |`,
|
|
579
|
+
`| Low | ${counts.low} |`,
|
|
580
|
+
`| **Total** | **${total}** |`,
|
|
581
|
+
];
|
|
582
|
+
}
|
|
583
|
+
// toSarifResult removed — logic inlined in generateSarifSingleReport and generateSarifBatchReport
|
|
584
|
+
function severityToSarifLevel(s) {
|
|
585
|
+
switch (s) {
|
|
586
|
+
case "critical":
|
|
587
|
+
case "high":
|
|
588
|
+
return "error";
|
|
589
|
+
case "medium":
|
|
590
|
+
return "warning";
|
|
591
|
+
case "low":
|
|
592
|
+
return "note";
|
|
593
|
+
default:
|
|
594
|
+
return "none";
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/** HTML-escape user-supplied text to prevent injection and Markdown breakage. */
|
|
153
598
|
function escapeHtml(text) {
|
|
154
599
|
return text
|
|
155
600
|
.replace(/&/g, "&")
|
|
156
601
|
.replace(/</g, "<")
|
|
157
|
-
.replace(/>/g, ">")
|
|
602
|
+
.replace(/>/g, ">")
|
|
603
|
+
.replace(/"/g, """)
|
|
604
|
+
.replace(/'/g, "'")
|
|
605
|
+
.replace(/=/g, "=");
|
|
606
|
+
}
|
|
607
|
+
/** Escape user-supplied values for safe embedding in Markdown content. */
|
|
608
|
+
function escapeMarkdown(text) {
|
|
609
|
+
return escapeHtml(text)
|
|
610
|
+
.replace(/\|/g, "\\|")
|
|
611
|
+
.replace(/`/g, "\\`")
|
|
612
|
+
.replace(/\*/g, "\\*")
|
|
613
|
+
.replace(/_/g, "\\_")
|
|
614
|
+
.replace(/\[/g, "\\[")
|
|
615
|
+
.replace(/\]/g, "\\]")
|
|
616
|
+
.replace(/#/g, "\\#")
|
|
617
|
+
.replace(/!/g, "\\!")
|
|
618
|
+
.replace(/~/g, "\\~");
|
|
158
619
|
}
|
|
159
620
|
function severityRank(s) {
|
|
160
621
|
switch (s) {
|
|
161
|
-
case "critical":
|
|
162
|
-
|
|
163
|
-
case "
|
|
164
|
-
|
|
165
|
-
|
|
622
|
+
case "critical":
|
|
623
|
+
return 0;
|
|
624
|
+
case "high":
|
|
625
|
+
return 1;
|
|
626
|
+
case "medium":
|
|
627
|
+
return 2;
|
|
628
|
+
case "low":
|
|
629
|
+
return 3;
|
|
630
|
+
default:
|
|
631
|
+
return 4;
|
|
166
632
|
}
|
|
167
633
|
}
|