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.
@@ -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
- const viewports = customViewports && customViewports.length > 0 ? customViewports : DEFAULT_VIEWPORTS;
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 = targetPath;
19
- if (!targetPath.startsWith("http://") && !targetPath.startsWith("https://")) {
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
- targetUrl = `file://${absolutePath.replace(/\\/g, "/")}`;
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(outputDirectory, { recursive: true });
25
- // Launch browser in headless mode
143
+ await fs.mkdir(resolvedOutputDirectory, { recursive: true });
144
+ // Launch Puppeteer's bundled headless shell.
26
145
  const browser = await puppeteer.launch({
27
- headless: true,
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
- await page.goto(targetUrl, {
41
- waitUntil: "domcontentloaded",
42
- timeout: 30000
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 = path.join(outputDirectory, fileName);
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: path.resolve(outputPath)
192
+ outputPath
57
193
  });
58
194
  }
59
195
  }
@@ -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"]}
@@ -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 all links inside the HTML content for broken links (4xx / 5xx)
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[]>;
@@ -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 auditSeoMetadata(htmlContent) {
35
+ export function auditSeoMetadataDetailed(htmlContent) {
6
36
  const $ = cheerio.load(htmlContent);
7
- const issues = [];
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
- issues.push({
42
+ add({
12
43
  severity: "error",
13
44
  category: "SEO",
14
- message: "Missing <title> tag. This is critical for search indexing and click-through rates.",
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
- issues.push({
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
- issues.push({
58
+ add({
28
59
  severity: "warning",
29
60
  category: "SEO",
30
- message: `Title length (${titleText.length} chars) is too short. Try to make it at least 30-50 characters.`,
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
- issues.push({
66
+ add({
36
67
  severity: "warning",
37
68
  category: "SEO",
38
- message: `Title length (${titleText.length} chars) is too long. Search engines will truncate it. Keep it under 60 characters.`,
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
- issues.push({
77
+ add({
47
78
  severity: "error",
48
79
  category: "SEO",
49
- message: "Missing <meta name=\"description\">. Search engines will automatically generate snippets, which may lower CTR.",
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
- issues.push({
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
- issues.push({
93
+ add({
63
94
  severity: "warning",
64
95
  category: "SEO",
65
- message: `Meta description is too short (${descText.length} chars). Aim for 120-160 characters to optimize your search snippet.`,
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
- issues.push({
101
+ add({
71
102
  severity: "warning",
72
103
  category: "SEO",
73
- message: `Meta description is too long (${descText.length} chars). Search engines will truncate it. Keep it under 160 characters.`,
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
- issues.push({
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
- issues.push({
121
+ add({
91
122
  severity: "error",
92
123
  category: "SEO",
93
- message: "Missing <meta name=\"viewport\"> tag. Mobile friendliness is a critical ranking factor.",
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
- issues.push({
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
- issues.push({
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
- issues.push({
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
- issues.push({
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
- issues.push({
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 issues;
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 validateSchemaMarkup(htmlContent) {
184
+ export function validateSchemaMarkupDetailed(htmlContent) {
151
185
  const $ = cheerio.load(htmlContent);
152
- const issues = [];
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
- issues.push({
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 (e) {
167
- issues.push({
201
+ catch (error) {
202
+ add({
168
203
  severity: "error",
169
204
  category: "Schema",
170
- message: `Invalid JSON-LD schema syntax: ${e.message}`,
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 issues;
210
+ return collector.details();
211
+ }
212
+ export function validateSchemaMarkup(htmlContent) {
213
+ return validateSchemaMarkupDetailed(htmlContent).issues;
176
214
  }
177
215
  /**
178
- * Extracts and tests all links inside the HTML content for broken links (4xx / 5xx)
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
- const href = $(element).attr("href");
185
- if (href && !href.startsWith("#") && !href.startsWith("mailto:") && !href.startsWith("tel:") && !href.startsWith("javascript:")) {
186
- let resolvedUrl = href;
187
- if (href.startsWith("/") && baseUrl) {
188
- resolvedUrl = new URL(href, baseUrl).toString();
189
- }
190
- if (!urls.includes(resolvedUrl)) {
191
- urls.push(resolvedUrl);
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
- // Parallel requests with timeout limits to keep validation fast
197
- const requests = urls.map(async (url) => {
253
+ const results = new Array(urls.length);
254
+ let nextIndex = 0;
255
+ async function checkLink(url) {
198
256
  try {
199
- const response = await fetch(url, {
257
+ const headResult = await fetchPublicHttp(url, {
200
258
  method: "HEAD",
201
- // Avoid rejection on self-signed certs or SSL errors commonly found on local testing servers
202
- signal: AbortSignal.timeout(5000),
259
+ headers: { "User-Agent": LINK_CHECK_USER_AGENT },
260
+ timeoutMs: LINK_CHECK_TIMEOUT_MS,
261
+ maxRedirects: 0,
203
262
  });
204
- // If HEAD is not allowed (e.g. Cloudflare / 405 Method Not Allowed), retry with GET
205
- if (response.status === 405 || response.status === 403) {
206
- const getResponse = await fetch(url, {
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
- signal: AbortSignal.timeout(5000),
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: getResponse.status,
213
- ok: getResponse.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: response.status,
219
- ok: response.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: "FAILED",
306
+ status: error instanceof PublicUrlError ? "blocked" : "failed",
226
307
  ok: false,
227
- message: error.name === "TimeoutError" ? "Timeout" : error.message,
308
+ message: getErrorMessage(error),
228
309
  };
229
310
  }
230
- });
231
- return Promise.all(requests);
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