mcp-web-validator 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +126 -83
- package/SECURITY.md +35 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +443 -668
- package/dist/index.js.map +1 -1
- package/dist/network.d.ts +36 -0
- package/dist/network.js +281 -0
- package/dist/network.js.map +1 -0
- package/dist/presentation.d.ts +15 -0
- package/dist/presentation.js +347 -0
- package/dist/presentation.js.map +1 -0
- package/dist/report.d.ts +43 -0
- package/dist/report.js +143 -0
- package/dist/report.js.map +1 -0
- package/dist/screenshot.d.ts +1 -0
- package/dist/screenshot.js +150 -14
- package/dist/screenshot.js.map +1 -1
- package/dist/seo-auditor.d.ts +11 -2
- package/dist/seo-auditor.js +154 -61
- package/dist/seo-auditor.js.map +1 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/w3c-validator.d.ts +2 -0
- package/dist/w3c-validator.js +72 -16
- package/dist/w3c-validator.js.map +1 -1
- package/package.json +18 -7
package/dist/screenshot.js
CHANGED
|
@@ -1,11 +1,113 @@
|
|
|
1
1
|
import puppeteer from "puppeteer";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as fs from "fs/promises";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { assertPublicHttpUrl, getErrorMessage } from "./network.js";
|
|
4
6
|
const DEFAULT_VIEWPORTS = [
|
|
5
7
|
{ name: "desktop", width: 1440, height: 900 },
|
|
6
8
|
{ name: "tablet", width: 768, height: 1024 },
|
|
7
9
|
{ name: "mobile", width: 375, height: 812 }
|
|
8
10
|
];
|
|
11
|
+
const MAX_VIEWPORTS = 10;
|
|
12
|
+
const MIN_VIEWPORT_DIMENSION = 100;
|
|
13
|
+
const MAX_VIEWPORT_DIMENSION = 7_680;
|
|
14
|
+
const SAFE_VIEWPORT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
15
|
+
const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
16
|
+
export const SCREENSHOT_HEADLESS_MODE = "shell";
|
|
17
|
+
function validateViewports(viewports) {
|
|
18
|
+
if (viewports.length === 0 || viewports.length > MAX_VIEWPORTS) {
|
|
19
|
+
throw new Error(`Between 1 and ${MAX_VIEWPORTS} viewports are required`);
|
|
20
|
+
}
|
|
21
|
+
const outputNames = new Set();
|
|
22
|
+
return viewports.map((viewport, index) => {
|
|
23
|
+
if (!viewport || typeof viewport !== "object") {
|
|
24
|
+
throw new Error(`Viewport #${index + 1} must be an object`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof viewport.name !== "string" || !SAFE_VIEWPORT_NAME.test(viewport.name)) {
|
|
27
|
+
throw new Error(`Viewport #${index + 1} name must match ${SAFE_VIEWPORT_NAME.source}`);
|
|
28
|
+
}
|
|
29
|
+
for (const [label, value] of [["width", viewport.width], ["height", viewport.height]]) {
|
|
30
|
+
if (!Number.isSafeInteger(value)
|
|
31
|
+
|| value < MIN_VIEWPORT_DIMENSION
|
|
32
|
+
|| value > MAX_VIEWPORT_DIMENSION) {
|
|
33
|
+
throw new Error(`Viewport #${index + 1} ${label} must be an integer between ${MIN_VIEWPORT_DIMENSION} and ${MAX_VIEWPORT_DIMENSION}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const outputName = `${viewport.name}_${viewport.width}x${viewport.height}.png`.toLowerCase();
|
|
37
|
+
if (outputNames.has(outputName)) {
|
|
38
|
+
throw new Error(`Duplicate viewport output filename: ${outputName}`);
|
|
39
|
+
}
|
|
40
|
+
outputNames.add(outputName);
|
|
41
|
+
return { name: viewport.name, width: viewport.width, height: viewport.height };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function resolveContainedOutputPath(outputDirectory, fileName) {
|
|
45
|
+
const outputPath = path.resolve(outputDirectory, fileName);
|
|
46
|
+
const relativePath = path.relative(outputDirectory, outputPath);
|
|
47
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
48
|
+
throw new Error("Screenshot output path escapes the requested output directory");
|
|
49
|
+
}
|
|
50
|
+
return outputPath;
|
|
51
|
+
}
|
|
52
|
+
function isPathWithinDirectory(directory, filePath) {
|
|
53
|
+
const relativePath = path.relative(directory, filePath);
|
|
54
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
55
|
+
}
|
|
56
|
+
async function assertLocalScreenshotRequestAllowed(requestUrl, localDirectory) {
|
|
57
|
+
if (requestUrl.hostname) {
|
|
58
|
+
throw new Error("Blocked local file request with a host name");
|
|
59
|
+
}
|
|
60
|
+
let resolvedPath;
|
|
61
|
+
try {
|
|
62
|
+
resolvedPath = await fs.realpath(fileURLToPath(requestUrl));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`Blocked local file request: ${getErrorMessage(error)}`);
|
|
66
|
+
}
|
|
67
|
+
if (!isPathWithinDirectory(localDirectory, resolvedPath)) {
|
|
68
|
+
throw new Error("Blocked local file request outside the selected file directory");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function assertScreenshotRequestAllowed(requestUrl, localDirectory) {
|
|
72
|
+
let parsedUrl;
|
|
73
|
+
try {
|
|
74
|
+
parsedUrl = new URL(requestUrl);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error("Blocked invalid page request URL");
|
|
78
|
+
}
|
|
79
|
+
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
|
|
80
|
+
await assertPublicHttpUrl(parsedUrl);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (parsedUrl.protocol === "data:" || parsedUrl.protocol === "blob:") {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (parsedUrl.protocol === "file:" && localDirectory) {
|
|
87
|
+
await assertLocalScreenshotRequestAllowed(parsedUrl, localDirectory);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`Blocked unsupported page request protocol ${parsedUrl.protocol}`);
|
|
91
|
+
}
|
|
92
|
+
async function resolveScreenshotRequest(request, localDirectory, onBlocked) {
|
|
93
|
+
if (request.isInterceptResolutionHandled()) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await assertScreenshotRequestAllowed(request.url(), localDirectory);
|
|
98
|
+
if (request.isInterceptResolutionHandled()) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await request.continue();
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
onBlocked(getErrorMessage(error));
|
|
105
|
+
if (request.isInterceptResolutionHandled()) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await request.abort("blockedbyclient");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
9
111
|
/**
|
|
10
112
|
* Captures screenshots of a local HTML file or remote URL at different viewport sizes.
|
|
11
113
|
* @param targetPath Local file path or HTTP(S) URL
|
|
@@ -13,23 +115,49 @@ const DEFAULT_VIEWPORTS = [
|
|
|
13
115
|
* @param customViewports Optional custom viewport settings
|
|
14
116
|
*/
|
|
15
117
|
export async function captureScreenshots(targetPath, outputDirectory, customViewports) {
|
|
16
|
-
|
|
118
|
+
if (typeof targetPath !== "string" || targetPath.trim() === "") {
|
|
119
|
+
throw new Error("targetPath must be a non-empty string");
|
|
120
|
+
}
|
|
121
|
+
if (typeof outputDirectory !== "string" || outputDirectory.trim() === "") {
|
|
122
|
+
throw new Error("outputDirectory must be a non-empty string");
|
|
123
|
+
}
|
|
124
|
+
const viewports = validateViewports(customViewports && customViewports.length > 0 ? customViewports : DEFAULT_VIEWPORTS);
|
|
125
|
+
const resolvedOutputDirectory = path.resolve(outputDirectory);
|
|
17
126
|
// Resolve target to file:// URL if it is a local file path
|
|
18
|
-
let targetUrl
|
|
19
|
-
|
|
127
|
+
let targetUrl;
|
|
128
|
+
let localDirectory;
|
|
129
|
+
if (/^https?:\/\//i.test(targetPath)) {
|
|
130
|
+
targetUrl = (await assertPublicHttpUrl(targetPath)).href;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
20
133
|
const absolutePath = path.resolve(targetPath);
|
|
21
|
-
|
|
134
|
+
const resolvedPath = await fs.realpath(absolutePath);
|
|
135
|
+
const stats = await fs.stat(resolvedPath);
|
|
136
|
+
if (!stats.isFile()) {
|
|
137
|
+
throw new Error(`Screenshot target is not a regular file: ${resolvedPath}`);
|
|
138
|
+
}
|
|
139
|
+
localDirectory = path.dirname(resolvedPath);
|
|
140
|
+
targetUrl = pathToFileURL(resolvedPath).href;
|
|
22
141
|
}
|
|
23
142
|
// Ensure output directory exists
|
|
24
|
-
await fs.mkdir(
|
|
25
|
-
// Launch
|
|
143
|
+
await fs.mkdir(resolvedOutputDirectory, { recursive: true });
|
|
144
|
+
// Launch Puppeteer's bundled headless shell.
|
|
26
145
|
const browser = await puppeteer.launch({
|
|
27
|
-
headless:
|
|
28
|
-
args: ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
146
|
+
headless: SCREENSHOT_HEADLESS_MODE,
|
|
29
147
|
});
|
|
30
148
|
const results = [];
|
|
31
149
|
try {
|
|
32
150
|
const page = await browser.newPage();
|
|
151
|
+
page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
152
|
+
let blockedRequestMessage;
|
|
153
|
+
await page.setRequestInterception(true);
|
|
154
|
+
page.on("request", (request) => {
|
|
155
|
+
void resolveScreenshotRequest(request, localDirectory, (message) => {
|
|
156
|
+
blockedRequestMessage ??= message;
|
|
157
|
+
}).catch(() => {
|
|
158
|
+
// Puppeteer will surface navigation failures; avoid an unhandled listener rejection.
|
|
159
|
+
});
|
|
160
|
+
});
|
|
33
161
|
for (const vp of viewports) {
|
|
34
162
|
await page.setViewport({
|
|
35
163
|
width: vp.width,
|
|
@@ -37,14 +165,22 @@ export async function captureScreenshots(targetPath, outputDirectory, customView
|
|
|
37
165
|
deviceScaleFactor: 1
|
|
38
166
|
});
|
|
39
167
|
// Load URL (wait until network is idle or DOM loaded)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
168
|
+
try {
|
|
169
|
+
await page.goto(targetUrl, {
|
|
170
|
+
waitUntil: "domcontentloaded",
|
|
171
|
+
timeout: NAVIGATION_TIMEOUT_MS,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (blockedRequestMessage) {
|
|
176
|
+
throw new Error(`Screenshot navigation blocked: ${blockedRequestMessage}`);
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`Screenshot navigation failed: ${getErrorMessage(error)}`);
|
|
179
|
+
}
|
|
44
180
|
// Short wait for any animations or layout adjustments to settle
|
|
45
181
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
46
182
|
const fileName = `${vp.name}_${vp.width}x${vp.height}.png`;
|
|
47
|
-
const outputPath =
|
|
183
|
+
const outputPath = resolveContainedOutputPath(resolvedOutputDirectory, fileName);
|
|
48
184
|
await page.screenshot({
|
|
49
185
|
path: outputPath,
|
|
50
186
|
fullPage: false // Captures above the fold viewport
|
|
@@ -53,7 +189,7 @@ export async function captureScreenshots(targetPath, outputDirectory, customView
|
|
|
53
189
|
viewportName: vp.name,
|
|
54
190
|
width: vp.width,
|
|
55
191
|
height: vp.height,
|
|
56
|
-
outputPath
|
|
192
|
+
outputPath
|
|
57
193
|
});
|
|
58
194
|
}
|
|
59
195
|
}
|
package/dist/screenshot.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"screenshot.js","sourceRoot":"","sources":["../src/screenshot.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"screenshot.js","sourceRoot":"","sources":["../src/screenshot.ts"],"names":[],"mappings":"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","sourcesContent":["import puppeteer from \"puppeteer\";\nimport * as path from \"path\";\nimport * as fs from \"fs/promises\";\nimport { fileURLToPath, pathToFileURL } from \"node:url\";\nimport { assertPublicHttpUrl, getErrorMessage } from \"./network.js\";\n\nexport interface ViewportConfig {\n name: string;\n width: number;\n height: number;\n}\n\nexport interface ScreenshotResult {\n viewportName: string;\n width: number;\n height: number;\n outputPath: string;\n}\n\nconst DEFAULT_VIEWPORTS: ViewportConfig[] = [\n { name: \"desktop\", width: 1440, height: 900 },\n { name: \"tablet\", width: 768, height: 1024 },\n { name: \"mobile\", width: 375, height: 812 }\n];\n\nconst MAX_VIEWPORTS = 10;\nconst MIN_VIEWPORT_DIMENSION = 100;\nconst MAX_VIEWPORT_DIMENSION = 7_680;\nconst SAFE_VIEWPORT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\nconst NAVIGATION_TIMEOUT_MS = 30_000;\n\nexport const SCREENSHOT_HEADLESS_MODE = \"shell\" as const;\n\ninterface InterceptedRequest {\n url(): string;\n isInterceptResolutionHandled(): boolean;\n continue(): Promise<unknown>;\n abort(errorCode?: string): Promise<unknown>;\n}\n\nfunction validateViewports(viewports: ViewportConfig[]): ViewportConfig[] {\n if (viewports.length === 0 || viewports.length > MAX_VIEWPORTS) {\n throw new Error(`Between 1 and ${MAX_VIEWPORTS} viewports are required`);\n }\n\n const outputNames = new Set<string>();\n return viewports.map((viewport, index) => {\n if (!viewport || typeof viewport !== \"object\") {\n throw new Error(`Viewport #${index + 1} must be an object`);\n }\n if (typeof viewport.name !== \"string\" || !SAFE_VIEWPORT_NAME.test(viewport.name)) {\n throw new Error(\n `Viewport #${index + 1} name must match ${SAFE_VIEWPORT_NAME.source}`,\n );\n }\n for (const [label, value] of [[\"width\", viewport.width], [\"height\", viewport.height]] as const) {\n if (\n !Number.isSafeInteger(value)\n || value < MIN_VIEWPORT_DIMENSION\n || value > MAX_VIEWPORT_DIMENSION\n ) {\n throw new Error(\n `Viewport #${index + 1} ${label} must be an integer between ${MIN_VIEWPORT_DIMENSION} and ${MAX_VIEWPORT_DIMENSION}`,\n );\n }\n }\n\n const outputName = `${viewport.name}_${viewport.width}x${viewport.height}.png`.toLowerCase();\n if (outputNames.has(outputName)) {\n throw new Error(`Duplicate viewport output filename: ${outputName}`);\n }\n outputNames.add(outputName);\n return { name: viewport.name, width: viewport.width, height: viewport.height };\n });\n}\n\nfunction resolveContainedOutputPath(outputDirectory: string, fileName: string): string {\n const outputPath = path.resolve(outputDirectory, fileName);\n const relativePath = path.relative(outputDirectory, outputPath);\n if (relativePath.startsWith(\"..\") || path.isAbsolute(relativePath)) {\n throw new Error(\"Screenshot output path escapes the requested output directory\");\n }\n return outputPath;\n}\n\nfunction isPathWithinDirectory(directory: string, filePath: string): boolean {\n const relativePath = path.relative(directory, filePath);\n return relativePath === \"\" || (!relativePath.startsWith(\"..\") && !path.isAbsolute(relativePath));\n}\n\nasync function assertLocalScreenshotRequestAllowed(requestUrl: URL, localDirectory: string): Promise<void> {\n if (requestUrl.hostname) {\n throw new Error(\"Blocked local file request with a host name\");\n }\n\n let resolvedPath: string;\n try {\n resolvedPath = await fs.realpath(fileURLToPath(requestUrl));\n } catch (error: unknown) {\n throw new Error(`Blocked local file request: ${getErrorMessage(error)}`);\n }\n\n if (!isPathWithinDirectory(localDirectory, resolvedPath)) {\n throw new Error(\"Blocked local file request outside the selected file directory\");\n }\n}\n\nasync function assertScreenshotRequestAllowed(requestUrl: string, localDirectory?: string): Promise<void> {\n let parsedUrl: URL;\n try {\n parsedUrl = new URL(requestUrl);\n } catch {\n throw new Error(\"Blocked invalid page request URL\");\n }\n\n if (parsedUrl.protocol === \"http:\" || parsedUrl.protocol === \"https:\") {\n await assertPublicHttpUrl(parsedUrl);\n return;\n }\n\n if (parsedUrl.protocol === \"data:\" || parsedUrl.protocol === \"blob:\") {\n return;\n }\n\n if (parsedUrl.protocol === \"file:\" && localDirectory) {\n await assertLocalScreenshotRequestAllowed(parsedUrl, localDirectory);\n return;\n }\n\n throw new Error(`Blocked unsupported page request protocol ${parsedUrl.protocol}`);\n}\n\nasync function resolveScreenshotRequest(\n request: InterceptedRequest,\n localDirectory: string | undefined,\n onBlocked: (message: string) => void,\n): Promise<void> {\n if (request.isInterceptResolutionHandled()) {\n return;\n }\n\n try {\n await assertScreenshotRequestAllowed(request.url(), localDirectory);\n if (request.isInterceptResolutionHandled()) {\n return;\n }\n await request.continue();\n } catch (error: unknown) {\n onBlocked(getErrorMessage(error));\n if (request.isInterceptResolutionHandled()) {\n return;\n }\n await request.abort(\"blockedbyclient\");\n }\n}\n\n/**\n * Captures screenshots of a local HTML file or remote URL at different viewport sizes.\n * @param targetPath Local file path or HTTP(S) URL\n * @param outputDirectory Absolute path to save screenshots\n * @param customViewports Optional custom viewport settings\n */\nexport async function captureScreenshots(\n targetPath: string,\n outputDirectory: string,\n customViewports?: ViewportConfig[]\n): Promise<ScreenshotResult[]> {\n if (typeof targetPath !== \"string\" || targetPath.trim() === \"\") {\n throw new Error(\"targetPath must be a non-empty string\");\n }\n if (typeof outputDirectory !== \"string\" || outputDirectory.trim() === \"\") {\n throw new Error(\"outputDirectory must be a non-empty string\");\n }\n\n const viewports = validateViewports(\n customViewports && customViewports.length > 0 ? customViewports : DEFAULT_VIEWPORTS,\n );\n const resolvedOutputDirectory = path.resolve(outputDirectory);\n\n // Resolve target to file:// URL if it is a local file path\n let targetUrl: string;\n let localDirectory: string | undefined;\n if (/^https?:\\/\\//i.test(targetPath)) {\n targetUrl = (await assertPublicHttpUrl(targetPath)).href;\n } else {\n const absolutePath = path.resolve(targetPath);\n const resolvedPath = await fs.realpath(absolutePath);\n const stats = await fs.stat(resolvedPath);\n if (!stats.isFile()) {\n throw new Error(`Screenshot target is not a regular file: ${resolvedPath}`);\n }\n localDirectory = path.dirname(resolvedPath);\n targetUrl = pathToFileURL(resolvedPath).href;\n }\n\n // Ensure output directory exists\n await fs.mkdir(resolvedOutputDirectory, { recursive: true });\n\n // Launch Puppeteer's bundled headless shell.\n const browser = await puppeteer.launch({\n headless: SCREENSHOT_HEADLESS_MODE,\n });\n\n const results: ScreenshotResult[] = [];\n\n try {\n const page = await browser.newPage();\n page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);\n\n let blockedRequestMessage: string | undefined;\n await page.setRequestInterception(true);\n page.on(\"request\", (request) => {\n void resolveScreenshotRequest(request, localDirectory, (message) => {\n blockedRequestMessage ??= message;\n }).catch(() => {\n // Puppeteer will surface navigation failures; avoid an unhandled listener rejection.\n });\n });\n\n for (const vp of viewports) {\n await page.setViewport({\n width: vp.width,\n height: vp.height,\n deviceScaleFactor: 1\n });\n\n // Load URL (wait until network is idle or DOM loaded)\n try {\n await page.goto(targetUrl, {\n waitUntil: \"domcontentloaded\",\n timeout: NAVIGATION_TIMEOUT_MS,\n });\n } catch (error: unknown) {\n if (blockedRequestMessage) {\n throw new Error(`Screenshot navigation blocked: ${blockedRequestMessage}`);\n }\n throw new Error(`Screenshot navigation failed: ${getErrorMessage(error)}`);\n }\n\n // Short wait for any animations or layout adjustments to settle\n await new Promise((resolve) => setTimeout(resolve, 500));\n\n const fileName = `${vp.name}_${vp.width}x${vp.height}.png`;\n const outputPath = resolveContainedOutputPath(resolvedOutputDirectory, fileName);\n\n await page.screenshot({\n path: outputPath,\n fullPage: false // Captures above the fold viewport\n });\n\n results.push({\n viewportName: vp.name,\n width: vp.width,\n height: vp.height,\n outputPath\n });\n }\n } finally {\n await browser.close();\n }\n\n return results;\n}\n"]}
|
package/dist/seo-auditor.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const MAX_AUDIT_ISSUES = 200;
|
|
2
|
+
export declare const MAX_LINKS_TO_CHECK = 25;
|
|
1
3
|
export interface SEOIssue {
|
|
2
4
|
severity: "error" | "warning" | "info";
|
|
3
5
|
category: "SEO" | "Schema" | "BrokenLinks" | "Accessibility";
|
|
@@ -10,15 +12,22 @@ export interface LinkStatus {
|
|
|
10
12
|
ok: boolean;
|
|
11
13
|
message?: string;
|
|
12
14
|
}
|
|
15
|
+
export interface AuditDetails {
|
|
16
|
+
issues: SEOIssue[];
|
|
17
|
+
totalIssues: number;
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
}
|
|
13
20
|
/**
|
|
14
21
|
* Audits technical SEO and accessibility basics on HTML content using Cheerio
|
|
15
22
|
*/
|
|
23
|
+
export declare function auditSeoMetadataDetailed(htmlContent: string): AuditDetails;
|
|
16
24
|
export declare function auditSeoMetadata(htmlContent: string): SEOIssue[];
|
|
17
25
|
/**
|
|
18
26
|
* Parses and validates JSON-LD Schema markup
|
|
19
27
|
*/
|
|
28
|
+
export declare function validateSchemaMarkupDetailed(htmlContent: string): AuditDetails;
|
|
20
29
|
export declare function validateSchemaMarkup(htmlContent: string): SEOIssue[];
|
|
21
30
|
/**
|
|
22
|
-
* Extracts and tests
|
|
31
|
+
* Extracts and tests links, treating 3xx responses as reachable redirects and 4xx/5xx as broken.
|
|
23
32
|
*/
|
|
24
|
-
export declare function checkBrokenLinks(htmlContent: string, baseUrl?: string): Promise<LinkStatus[]>;
|
|
33
|
+
export declare function checkBrokenLinks(htmlContent: string, baseUrl?: string, maxLinks?: number): Promise<LinkStatus[]>;
|
package/dist/seo-auditor.js
CHANGED
|
@@ -1,41 +1,72 @@
|
|
|
1
1
|
import * as cheerio from "cheerio";
|
|
2
|
+
import { assertPublicHttpUrl, cancelResponseBody, fetchPublicHttp, getErrorMessage, PublicUrlError, } from "./network.js";
|
|
3
|
+
import { PACKAGE_VERSION } from "./version.js";
|
|
4
|
+
export const MAX_AUDIT_ISSUES = 200;
|
|
5
|
+
export const MAX_LINKS_TO_CHECK = 25;
|
|
6
|
+
const LINK_CHECK_CONCURRENCY = 5;
|
|
7
|
+
const LINK_CHECK_TIMEOUT_MS = 5_000;
|
|
8
|
+
const LINK_CHECK_USER_AGENT = `mcp-web-validator/${PACKAGE_VERSION} (+https://digestseo.com/validator-mcp/)`;
|
|
9
|
+
function addIssue(issues, issue) {
|
|
10
|
+
if (issues.length < MAX_AUDIT_ISSUES) {
|
|
11
|
+
issues.push(issue);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function createIssueCollector() {
|
|
15
|
+
const issues = [];
|
|
16
|
+
let totalIssues = 0;
|
|
17
|
+
return {
|
|
18
|
+
issues,
|
|
19
|
+
add(issue) {
|
|
20
|
+
totalIssues += 1;
|
|
21
|
+
addIssue(issues, issue);
|
|
22
|
+
},
|
|
23
|
+
details() {
|
|
24
|
+
return {
|
|
25
|
+
issues,
|
|
26
|
+
totalIssues,
|
|
27
|
+
truncated: totalIssues > issues.length,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
2
32
|
/**
|
|
3
33
|
* Audits technical SEO and accessibility basics on HTML content using Cheerio
|
|
4
34
|
*/
|
|
5
|
-
export function
|
|
35
|
+
export function auditSeoMetadataDetailed(htmlContent) {
|
|
6
36
|
const $ = cheerio.load(htmlContent);
|
|
7
|
-
const
|
|
37
|
+
const collector = createIssueCollector();
|
|
38
|
+
const { add } = collector;
|
|
8
39
|
// --- Title Tag Audits ---
|
|
9
40
|
const titleTag = $("title");
|
|
10
41
|
if (titleTag.length === 0) {
|
|
11
|
-
|
|
42
|
+
add({
|
|
12
43
|
severity: "error",
|
|
13
44
|
category: "SEO",
|
|
14
|
-
message: "Missing <title> tag.
|
|
45
|
+
message: "Missing <title> tag. Add a concise, descriptive title to help represent the page in search results.",
|
|
15
46
|
});
|
|
16
47
|
}
|
|
17
48
|
else {
|
|
18
49
|
const titleText = titleTag.text().trim();
|
|
19
50
|
if (titleText.length === 0) {
|
|
20
|
-
|
|
51
|
+
add({
|
|
21
52
|
severity: "error",
|
|
22
53
|
category: "SEO",
|
|
23
54
|
message: "The <title> tag is empty.",
|
|
24
55
|
});
|
|
25
56
|
}
|
|
26
57
|
else if (titleText.length < 30) {
|
|
27
|
-
|
|
58
|
+
add({
|
|
28
59
|
severity: "warning",
|
|
29
60
|
category: "SEO",
|
|
30
|
-
message: `Title
|
|
61
|
+
message: `Title is ${titleText.length} characters. This is shorter than the audit's common editorial range; review whether it describes the page clearly.`,
|
|
31
62
|
element: `<title>${titleText}</title>`,
|
|
32
63
|
});
|
|
33
64
|
}
|
|
34
65
|
else if (titleText.length > 60) {
|
|
35
|
-
|
|
66
|
+
add({
|
|
36
67
|
severity: "warning",
|
|
37
68
|
category: "SEO",
|
|
38
|
-
message: `Title
|
|
69
|
+
message: `Title is ${titleText.length} characters. This is longer than the audit's common editorial range; Google title links may be shortened or rewritten depending on context and device.`,
|
|
39
70
|
element: `<title>${titleText}</title>`,
|
|
40
71
|
});
|
|
41
72
|
}
|
|
@@ -43,34 +74,34 @@ export function auditSeoMetadata(htmlContent) {
|
|
|
43
74
|
// --- Meta Description Audits ---
|
|
44
75
|
const metaDescription = $('meta[name="description"]');
|
|
45
76
|
if (metaDescription.length === 0) {
|
|
46
|
-
|
|
77
|
+
add({
|
|
47
78
|
severity: "error",
|
|
48
79
|
category: "SEO",
|
|
49
|
-
message: "Missing <meta name=\"description\">.
|
|
80
|
+
message: "Missing <meta name=\"description\">. Add a concise, accurate page summary; Google may use page content or this description to generate a snippet.",
|
|
50
81
|
});
|
|
51
82
|
}
|
|
52
83
|
else {
|
|
53
84
|
const descText = metaDescription.attr("content")?.trim() || "";
|
|
54
85
|
if (descText.length === 0) {
|
|
55
|
-
|
|
86
|
+
add({
|
|
56
87
|
severity: "error",
|
|
57
88
|
category: "SEO",
|
|
58
89
|
message: "Meta description content attribute is empty.",
|
|
59
90
|
});
|
|
60
91
|
}
|
|
61
92
|
else if (descText.length < 120) {
|
|
62
|
-
|
|
93
|
+
add({
|
|
63
94
|
severity: "warning",
|
|
64
95
|
category: "SEO",
|
|
65
|
-
message: `Meta description is
|
|
96
|
+
message: `Meta description is ${descText.length} characters. This is shorter than the audit's common editorial range; review whether it provides a useful page summary.`,
|
|
66
97
|
element: `<meta name="description" content="${descText}">`,
|
|
67
98
|
});
|
|
68
99
|
}
|
|
69
100
|
else if (descText.length > 160) {
|
|
70
|
-
|
|
101
|
+
add({
|
|
71
102
|
severity: "warning",
|
|
72
103
|
category: "SEO",
|
|
73
|
-
message: `Meta description is
|
|
104
|
+
message: `Meta description is ${descText.length} characters. This is longer than the audit's common editorial range; displayed snippets may be shortened depending on the query and device.`,
|
|
74
105
|
element: `<meta name="description" content="${descText}">`,
|
|
75
106
|
});
|
|
76
107
|
}
|
|
@@ -78,7 +109,7 @@ export function auditSeoMetadata(htmlContent) {
|
|
|
78
109
|
// --- Canonical Link ---
|
|
79
110
|
const canonical = $('link[rel="canonical"]');
|
|
80
111
|
if (canonical.length === 0) {
|
|
81
|
-
|
|
112
|
+
add({
|
|
82
113
|
severity: "warning",
|
|
83
114
|
category: "SEO",
|
|
84
115
|
message: "Missing canonical tag (<link rel=\"canonical\">). This helps prevent duplicate content issues.",
|
|
@@ -87,23 +118,23 @@ export function auditSeoMetadata(htmlContent) {
|
|
|
87
118
|
// --- Viewport Meta Tag (Mobile Responsiveness) ---
|
|
88
119
|
const viewport = $('meta[name="viewport"]');
|
|
89
120
|
if (viewport.length === 0) {
|
|
90
|
-
|
|
121
|
+
add({
|
|
91
122
|
severity: "error",
|
|
92
123
|
category: "SEO",
|
|
93
|
-
message: "Missing <meta name=\"viewport\"> tag.
|
|
124
|
+
message: "Missing <meta name=\"viewport\"> tag. Review mobile rendering; this tag helps browsers size and scale the page on mobile devices.",
|
|
94
125
|
});
|
|
95
126
|
}
|
|
96
127
|
// --- Heading Structure ---
|
|
97
128
|
const h1Tags = $("h1");
|
|
98
129
|
if (h1Tags.length === 0) {
|
|
99
|
-
|
|
130
|
+
add({
|
|
100
131
|
severity: "error",
|
|
101
132
|
category: "SEO",
|
|
102
133
|
message: "Missing <h1> tag. Every page must have exactly one <h1> representing the main topic.",
|
|
103
134
|
});
|
|
104
135
|
}
|
|
105
136
|
else if (h1Tags.length > 1) {
|
|
106
|
-
|
|
137
|
+
add({
|
|
107
138
|
severity: "warning",
|
|
108
139
|
category: "SEO",
|
|
109
140
|
message: `Found multiple (${h1Tags.length}) <h1> tags. Multiple <h1> tags dilutes topic keyword focus.`,
|
|
@@ -115,7 +146,7 @@ export function auditSeoMetadata(htmlContent) {
|
|
|
115
146
|
const src = img.attr("src") || "unknown-source";
|
|
116
147
|
const alt = img.attr("alt");
|
|
117
148
|
if (alt === undefined) {
|
|
118
|
-
|
|
149
|
+
add({
|
|
119
150
|
severity: "error",
|
|
120
151
|
category: "Accessibility",
|
|
121
152
|
message: "Missing 'alt' attribute on image. This makes it inaccessible to screen readers.",
|
|
@@ -124,7 +155,7 @@ export function auditSeoMetadata(htmlContent) {
|
|
|
124
155
|
}
|
|
125
156
|
else if (alt.trim() === "") {
|
|
126
157
|
// Empty alt is acceptable for purely decorative images, but worth warning
|
|
127
|
-
|
|
158
|
+
add({
|
|
128
159
|
severity: "info",
|
|
129
160
|
category: "Accessibility",
|
|
130
161
|
message: "Empty 'alt' attribute found. Ensure this image is purely decorative, otherwise add descriptive text.",
|
|
@@ -136,24 +167,28 @@ export function auditSeoMetadata(htmlContent) {
|
|
|
136
167
|
const ogTitle = $('meta[property="og:title"]');
|
|
137
168
|
const ogImage = $('meta[property="og:image"]');
|
|
138
169
|
if (ogTitle.length === 0 || ogImage.length === 0) {
|
|
139
|
-
|
|
170
|
+
add({
|
|
140
171
|
severity: "info",
|
|
141
172
|
category: "SEO",
|
|
142
173
|
message: "Missing Open Graph social metadata (og:title / og:image). Add these to control preview cards on platforms like LinkedIn and X.",
|
|
143
174
|
});
|
|
144
175
|
}
|
|
145
|
-
return
|
|
176
|
+
return collector.details();
|
|
177
|
+
}
|
|
178
|
+
export function auditSeoMetadata(htmlContent) {
|
|
179
|
+
return auditSeoMetadataDetailed(htmlContent).issues;
|
|
146
180
|
}
|
|
147
181
|
/**
|
|
148
182
|
* Parses and validates JSON-LD Schema markup
|
|
149
183
|
*/
|
|
150
|
-
export function
|
|
184
|
+
export function validateSchemaMarkupDetailed(htmlContent) {
|
|
151
185
|
const $ = cheerio.load(htmlContent);
|
|
152
|
-
const
|
|
186
|
+
const collector = createIssueCollector();
|
|
187
|
+
const { add } = collector;
|
|
153
188
|
$('script[type="application/ld+json"]').each((index, element) => {
|
|
154
189
|
const scriptText = $(element).html() || "";
|
|
155
190
|
if (scriptText.trim() === "") {
|
|
156
|
-
|
|
191
|
+
add({
|
|
157
192
|
severity: "warning",
|
|
158
193
|
category: "Schema",
|
|
159
194
|
message: `JSON-LD block #${index + 1} is empty.`,
|
|
@@ -163,71 +198,129 @@ export function validateSchemaMarkup(htmlContent) {
|
|
|
163
198
|
try {
|
|
164
199
|
JSON.parse(scriptText);
|
|
165
200
|
}
|
|
166
|
-
catch (
|
|
167
|
-
|
|
201
|
+
catch (error) {
|
|
202
|
+
add({
|
|
168
203
|
severity: "error",
|
|
169
204
|
category: "Schema",
|
|
170
|
-
message: `Invalid JSON-LD schema syntax: ${
|
|
205
|
+
message: `Invalid JSON-LD schema syntax: ${getErrorMessage(error)}`,
|
|
171
206
|
element: `<script type="application/ld+json">...</script>`,
|
|
172
207
|
});
|
|
173
208
|
}
|
|
174
209
|
});
|
|
175
|
-
return
|
|
210
|
+
return collector.details();
|
|
211
|
+
}
|
|
212
|
+
export function validateSchemaMarkup(htmlContent) {
|
|
213
|
+
return validateSchemaMarkupDetailed(htmlContent).issues;
|
|
176
214
|
}
|
|
177
215
|
/**
|
|
178
|
-
* Extracts and tests
|
|
216
|
+
* Extracts and tests links, treating 3xx responses as reachable redirects and 4xx/5xx as broken.
|
|
179
217
|
*/
|
|
180
|
-
export async function checkBrokenLinks(htmlContent, baseUrl) {
|
|
218
|
+
export async function checkBrokenLinks(htmlContent, baseUrl, maxLinks = MAX_LINKS_TO_CHECK) {
|
|
219
|
+
if (!Number.isSafeInteger(maxLinks) || maxLinks <= 0) {
|
|
220
|
+
throw new Error("maxLinks must be a positive integer");
|
|
221
|
+
}
|
|
222
|
+
const linkLimit = Math.min(maxLinks, MAX_LINKS_TO_CHECK);
|
|
181
223
|
const $ = cheerio.load(htmlContent);
|
|
224
|
+
const parsedBaseUrl = baseUrl ? await assertPublicHttpUrl(baseUrl) : undefined;
|
|
182
225
|
const urls = [];
|
|
226
|
+
const seenUrls = new Set();
|
|
183
227
|
$("a").each((_, element) => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
228
|
+
if (urls.length >= linkLimit) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
const href = $(element).attr("href")?.trim();
|
|
232
|
+
if (!href || href.startsWith("#")) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
let resolvedUrl;
|
|
236
|
+
try {
|
|
237
|
+
resolvedUrl = parsedBaseUrl ? new URL(href, parsedBaseUrl) : new URL(href);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Relative links require a public base URL; unsupported or malformed links are skipped.
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (resolvedUrl.protocol !== "http:" && resolvedUrl.protocol !== "https:") {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
resolvedUrl.hash = "";
|
|
247
|
+
const normalizedUrl = resolvedUrl.href;
|
|
248
|
+
if (!seenUrls.has(normalizedUrl)) {
|
|
249
|
+
seenUrls.add(normalizedUrl);
|
|
250
|
+
urls.push(normalizedUrl);
|
|
193
251
|
}
|
|
194
252
|
});
|
|
195
|
-
const results =
|
|
196
|
-
|
|
197
|
-
|
|
253
|
+
const results = new Array(urls.length);
|
|
254
|
+
let nextIndex = 0;
|
|
255
|
+
async function checkLink(url) {
|
|
198
256
|
try {
|
|
199
|
-
const
|
|
257
|
+
const headResult = await fetchPublicHttp(url, {
|
|
200
258
|
method: "HEAD",
|
|
201
|
-
|
|
202
|
-
|
|
259
|
+
headers: { "User-Agent": LINK_CHECK_USER_AGENT },
|
|
260
|
+
timeoutMs: LINK_CHECK_TIMEOUT_MS,
|
|
261
|
+
maxRedirects: 0,
|
|
203
262
|
});
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
263
|
+
const headStatus = headResult.response.status;
|
|
264
|
+
const headFinalUrl = headResult.url.href;
|
|
265
|
+
await cancelResponseBody(headResult.response);
|
|
266
|
+
// Some sites reject or do not implement HEAD even when the linked resource is available.
|
|
267
|
+
if (headStatus === 405 || headStatus === 403 || headStatus === 501) {
|
|
268
|
+
const getResult = await fetchPublicHttp(url, {
|
|
207
269
|
method: "GET",
|
|
208
|
-
|
|
270
|
+
headers: {
|
|
271
|
+
Range: "bytes=0-0",
|
|
272
|
+
"User-Agent": LINK_CHECK_USER_AGENT,
|
|
273
|
+
},
|
|
274
|
+
timeoutMs: LINK_CHECK_TIMEOUT_MS,
|
|
275
|
+
maxRedirects: 0,
|
|
209
276
|
});
|
|
277
|
+
const status = getResult.response.status;
|
|
278
|
+
const ok = status >= 200 && status < 400;
|
|
279
|
+
const finalUrl = getResult.url.href;
|
|
280
|
+
await cancelResponseBody(getResult.response);
|
|
210
281
|
return {
|
|
211
282
|
url,
|
|
212
|
-
status
|
|
213
|
-
ok
|
|
283
|
+
status,
|
|
284
|
+
ok,
|
|
285
|
+
message: status >= 300 && status < 400
|
|
286
|
+
? "Redirect not followed"
|
|
287
|
+
: finalUrl !== url
|
|
288
|
+
? `Redirected to ${finalUrl}`
|
|
289
|
+
: undefined,
|
|
214
290
|
};
|
|
215
291
|
}
|
|
216
292
|
return {
|
|
217
293
|
url,
|
|
218
|
-
status:
|
|
219
|
-
ok:
|
|
294
|
+
status: headStatus,
|
|
295
|
+
ok: headStatus >= 200 && headStatus < 400,
|
|
296
|
+
message: headStatus >= 300 && headStatus < 400
|
|
297
|
+
? "Redirect not followed"
|
|
298
|
+
: headFinalUrl !== url
|
|
299
|
+
? `Redirected to ${headFinalUrl}`
|
|
300
|
+
: undefined,
|
|
220
301
|
};
|
|
221
302
|
}
|
|
222
303
|
catch (error) {
|
|
223
304
|
return {
|
|
224
305
|
url,
|
|
225
|
-
status: "
|
|
306
|
+
status: error instanceof PublicUrlError ? "blocked" : "failed",
|
|
226
307
|
ok: false,
|
|
227
|
-
message: error
|
|
308
|
+
message: getErrorMessage(error),
|
|
228
309
|
};
|
|
229
310
|
}
|
|
230
|
-
}
|
|
231
|
-
|
|
311
|
+
}
|
|
312
|
+
async function worker() {
|
|
313
|
+
while (true) {
|
|
314
|
+
const index = nextIndex;
|
|
315
|
+
nextIndex += 1;
|
|
316
|
+
if (index >= urls.length) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
results[index] = await checkLink(urls[index]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const workerCount = Math.min(LINK_CHECK_CONCURRENCY, urls.length);
|
|
323
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
324
|
+
return results;
|
|
232
325
|
}
|
|
233
326
|
//# sourceMappingURL=seo-auditor.js.map
|