pagesight 0.17.0 → 0.18.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/README.md +17 -83
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/snapshots.md +123 -0
- package/docs/usage.md +130 -0
- package/package.json +26 -29
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +377 -0
- package/src/api/discover.ts +39 -0
- package/src/api/doctor.ts +35 -0
- package/src/api/evidence-schema.ts +78 -0
- package/src/api/evidence.ts +99 -0
- package/src/api/execute.ts +147 -0
- package/src/api/index.ts +5 -0
- package/src/api/reports.ts +90 -0
- package/src/api/schema.ts +207 -0
- package/src/api/snapshot.ts +183 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/cli.ts +182 -0
- package/src/http.ts +39 -0
- package/src/index.ts +8 -25
- package/src/mcp-server.ts +27 -0
- package/src/mcp.ts +5 -0
- package/src/providers/bing.ts +48 -0
- package/src/{lib → providers}/crux.ts +4 -9
- package/src/providers/ga.ts +114 -0
- package/src/providers/google-tokens.ts +86 -0
- package/src/providers/gsc-auth.ts +93 -0
- package/src/{lib → providers}/gsc.ts +27 -24
- package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
- package/src/shared/dates.ts +32 -0
- package/src/shared/http.ts +63 -0
- package/src/tools/ai.ts +19 -27
- package/src/tools/audit.ts +18 -98
- package/src/tools/observe.ts +28 -0
- package/src/tools/page/analyze.ts +194 -0
- package/src/tools/page/batch.ts +163 -0
- package/src/tools/page/contrast.ts +128 -0
- package/src/tools/page/links.ts +200 -0
- package/src/tools/page/metadata.ts +225 -0
- package/src/tools/page/structured-data.ts +288 -0
- package/src/tools/page/tool.ts +48 -0
- package/src/tools/search/actions.ts +56 -0
- package/src/tools/search/analytics.ts +266 -0
- package/src/tools/search/coverage.ts +247 -0
- package/src/tools/search/gaps.ts +129 -0
- package/src/tools/search/inspection.ts +160 -0
- package/src/tools/search/result.ts +3 -0
- package/src/tools/search/sample.ts +110 -0
- package/src/tools/search/schema.ts +62 -0
- package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
- package/src/tools/search/sites.ts +86 -0
- package/src/tools/search/tool.ts +11 -0
- package/src/tools/setup.ts +1 -1
- package/src/tools/speed/analyze.ts +191 -0
- package/src/tools/speed/batch.ts +265 -0
- package/src/tools/speed/crux.ts +176 -0
- package/src/tools/speed/pagespeed.ts +273 -0
- package/src/tools/speed/schema.ts +39 -0
- package/src/tools/speed/tool.ts +11 -0
- package/src/web/fetch.ts +31 -0
- package/src/web/images.ts +62 -0
- package/src/web/page-observation.ts +69 -0
- package/src/{lib → web}/robots.ts +20 -12
- package/src/web/sitemap-inventory.ts +64 -0
- package/src/web/sitemap-parser.ts +59 -0
- package/src/lib/auth.ts +0 -187
- package/src/tools/page.ts +0 -1241
- package/src/tools/search.ts +0 -1118
- package/src/tools/speed.ts +0 -956
package/src/tools/audit.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { inspectUrl, listSitemaps } from "../
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { type GscSitemap, inspectUrl, listSitemaps } from "../providers/gsc.js";
|
|
4
|
+
import { RequestError } from "../shared/http.js";
|
|
5
|
+
import { type PsiCategoryType, type PsiResult, runPagespeed } from "../providers/pagespeed.js";
|
|
6
|
+
import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../web/robots.js";
|
|
7
7
|
|
|
8
8
|
type Severity = "HIGH" | "MEDIUM" | "LOW";
|
|
9
9
|
|
|
@@ -13,8 +13,6 @@ interface Finding {
|
|
|
13
13
|
source: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// --- Metatags (inline, no external dep needed) ---
|
|
17
|
-
|
|
18
16
|
interface MetaCheckResult {
|
|
19
17
|
title: string | null;
|
|
20
18
|
description: string | null;
|
|
@@ -66,7 +64,7 @@ async function checkMeta(url: string): Promise<MetaCheckResult> {
|
|
|
66
64
|
};
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
//
|
|
67
|
+
// The hop limit or a redirect without Location prevented a page response.
|
|
70
68
|
return {
|
|
71
69
|
title: null,
|
|
72
70
|
description: null,
|
|
@@ -80,8 +78,6 @@ async function checkMeta(url: string): Promise<MetaCheckResult> {
|
|
|
80
78
|
};
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
// --- Audit logic ---
|
|
84
|
-
|
|
85
81
|
function addMetaFindings(meta: MetaCheckResult, findings: Finding[]) {
|
|
86
82
|
if (!meta.title) findings.push({ severity: "HIGH", message: "Missing <title> tag", source: "metatags" });
|
|
87
83
|
if (!meta.description) findings.push({ severity: "HIGH", message: "Missing meta description", source: "metatags" });
|
|
@@ -182,70 +178,30 @@ function addRobotsFindings(
|
|
|
182
178
|
}
|
|
183
179
|
}
|
|
184
180
|
|
|
185
|
-
function addSitemapFindings(
|
|
186
|
-
if (
|
|
181
|
+
export function addSitemapFindings(sitemaps: GscSitemap[], findings: Finding[]) {
|
|
182
|
+
if (sitemaps.length === 0) {
|
|
187
183
|
findings.push({ severity: "MEDIUM", message: "No sitemaps submitted to GSC", source: "sitemaps" });
|
|
188
|
-
return;
|
|
189
184
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
source: "sitemaps",
|
|
195
|
-
});
|
|
196
|
-
} else if (totalSubmitted > 0) {
|
|
197
|
-
const pct = Math.round((totalIndexed / totalSubmitted) * 100);
|
|
198
|
-
if (pct < 50) {
|
|
185
|
+
// contents[].indexed is deprecated; sitemap submission is not indexing evidence.
|
|
186
|
+
// https://developers.google.com/webmaster-tools/v1/sitemaps
|
|
187
|
+
for (const sitemap of sitemaps) {
|
|
188
|
+
if (Number(sitemap.errors) > 0) {
|
|
199
189
|
findings.push({
|
|
200
190
|
severity: "HIGH",
|
|
201
|
-
message: `${
|
|
191
|
+
message: `${sitemap.path}: ${sitemap.errors} sitemap errors`,
|
|
202
192
|
source: "sitemaps",
|
|
203
193
|
});
|
|
204
|
-
}
|
|
194
|
+
}
|
|
195
|
+
if (Number(sitemap.warnings) > 0) {
|
|
205
196
|
findings.push({
|
|
206
197
|
severity: "MEDIUM",
|
|
207
|
-
message: `${
|
|
198
|
+
message: `${sitemap.path}: ${sitemap.warnings} sitemap warnings`,
|
|
208
199
|
source: "sitemaps",
|
|
209
200
|
});
|
|
210
201
|
}
|
|
211
202
|
}
|
|
212
203
|
}
|
|
213
204
|
|
|
214
|
-
function formatDrillDown(
|
|
215
|
-
inspections: Array<{ url: string; verdict: string; coverageState: string; error: string | null }>,
|
|
216
|
-
): string {
|
|
217
|
-
const valid = inspections.filter((r) => !r.error);
|
|
218
|
-
if (valid.length === 0) return "";
|
|
219
|
-
|
|
220
|
-
const indexed = valid.filter((r) => r.verdict === "PASS").length;
|
|
221
|
-
const lines: string[] = [` Auto-inspected ${valid.length} URLs:`];
|
|
222
|
-
lines.push(` - ${indexed}/${valid.length} indexed`);
|
|
223
|
-
|
|
224
|
-
const stateCounts: Record<string, { count: number; urls: string[] }> = {};
|
|
225
|
-
for (const r of valid) {
|
|
226
|
-
if (r.verdict !== "PASS") {
|
|
227
|
-
let path: string;
|
|
228
|
-
try {
|
|
229
|
-
path = new URL(r.url).pathname;
|
|
230
|
-
} catch {
|
|
231
|
-
path = r.url;
|
|
232
|
-
}
|
|
233
|
-
const existing = stateCounts[r.coverageState];
|
|
234
|
-
if (existing) {
|
|
235
|
-
existing.count++;
|
|
236
|
-
existing.urls.push(path);
|
|
237
|
-
} else {
|
|
238
|
-
stateCounts[r.coverageState] = { count: 1, urls: [path] };
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
for (const [state, { count, urls }] of Object.entries(stateCounts)) {
|
|
243
|
-
lines.push(` - ${count}/${valid.length} ${state}: ${urls.join(", ")}`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return lines.join("\n");
|
|
247
|
-
}
|
|
248
|
-
|
|
249
205
|
function addInspectFindings(verdict: string, coverageState: string, findings: Finding[]) {
|
|
250
206
|
if (verdict === "FAIL") {
|
|
251
207
|
findings.push({ severity: "HIGH", message: `URL not indexed: ${coverageState}`, source: "inspect" });
|
|
@@ -266,7 +222,6 @@ function formatAudit(url: string, findings: Finding[], errors: string[]): string
|
|
|
266
222
|
lines.push("");
|
|
267
223
|
}
|
|
268
224
|
|
|
269
|
-
// Sort: HIGH first, then MEDIUM, then LOW
|
|
270
225
|
const order: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
271
226
|
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
272
227
|
|
|
@@ -299,7 +254,6 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
299
254
|
const errors: string[] = [];
|
|
300
255
|
const origin = new URL(url).origin;
|
|
301
256
|
|
|
302
|
-
// Run independent checks in parallel
|
|
303
257
|
const [metaResult, pagespeedResult, robotsResult, sitemapResult, inspectResult] = await Promise.allSettled([
|
|
304
258
|
checkMeta(url),
|
|
305
259
|
runPagespeed(url, {
|
|
@@ -312,7 +266,7 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
312
266
|
return { robotsTxt, crawlers, googlebotAllowed };
|
|
313
267
|
}),
|
|
314
268
|
site_url ? listSitemaps(site_url) : Promise.resolve(null),
|
|
315
|
-
site_url ? inspectUrl(url, site_url)
|
|
269
|
+
site_url ? inspectUrl(url, site_url) : Promise.resolve(null),
|
|
316
270
|
]);
|
|
317
271
|
|
|
318
272
|
// Process meta
|
|
@@ -327,8 +281,7 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
327
281
|
addPagespeedFindings(pagespeedResult.value, findings);
|
|
328
282
|
} else {
|
|
329
283
|
const psiErr = String(pagespeedResult.reason);
|
|
330
|
-
const
|
|
331
|
-
const status = statusMatch ? Number(statusMatch[1]) : 0;
|
|
284
|
+
const status = pagespeedResult.reason instanceof RequestError ? pagespeedResult.reason.status : null;
|
|
332
285
|
if (status === 403) {
|
|
333
286
|
errors.push("PageSpeed: SKIPPED (API key not authorized — enable PageSpeed Insights API in Google Cloud)");
|
|
334
287
|
} else if (status === 429) {
|
|
@@ -349,40 +302,7 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
349
302
|
|
|
350
303
|
// Process sitemaps
|
|
351
304
|
if (sitemapResult.status === "fulfilled" && sitemapResult.value) {
|
|
352
|
-
|
|
353
|
-
let totalSubmitted = 0;
|
|
354
|
-
let totalIndexed = 0;
|
|
355
|
-
for (const sm of sitemaps) {
|
|
356
|
-
for (const c of sm.contents ?? []) {
|
|
357
|
-
totalSubmitted += Number(c.submitted ?? 0);
|
|
358
|
-
totalIndexed += Number(c.indexed ?? 0);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
|
|
362
|
-
|
|
363
|
-
// Auto-drill-down: when indexing is low, sample-inspect to explain why
|
|
364
|
-
const indexPct = totalSubmitted > 0 ? (totalIndexed / totalSubmitted) * 100 : 100;
|
|
365
|
-
if (site_url && indexPct < 50 && sitemaps.length > 0) {
|
|
366
|
-
try {
|
|
367
|
-
const sitemapPath = (sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0]).path;
|
|
368
|
-
let parsed = await fetchSitemap(sitemapPath);
|
|
369
|
-
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
370
|
-
parsed = await fetchSitemap(parsed.childSitemaps[0]);
|
|
371
|
-
}
|
|
372
|
-
if (parsed.urls.length > 0) {
|
|
373
|
-
const sampled = sampleUrls(parsed.urls, 5, "spread");
|
|
374
|
-
const inspections = await Promise.all(sampled.map((u) => inspectSingle(u, site_url)));
|
|
375
|
-
const drillDown = formatDrillDown(inspections);
|
|
376
|
-
// Append drill-down to the sitemap finding
|
|
377
|
-
const sitemapFinding = findings.find((f) => f.source === "sitemaps" && f.severity === "HIGH");
|
|
378
|
-
if (sitemapFinding) {
|
|
379
|
-
sitemapFinding.message += `\n${drillDown}`;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
} catch {
|
|
383
|
-
// Drill-down is best-effort — don't fail the audit
|
|
384
|
-
}
|
|
385
|
-
}
|
|
305
|
+
addSitemapFindings(sitemapResult.value, findings);
|
|
386
306
|
} else if (sitemapResult.status === "rejected") {
|
|
387
307
|
errors.push(`Sitemaps: ${sitemapResult.reason}`);
|
|
388
308
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { execute, operationSchema } from "../api/index.js";
|
|
3
|
+
import { RequestError } from "../shared/http.js";
|
|
4
|
+
|
|
5
|
+
export function registerObserveTool(server: McpServer): void {
|
|
6
|
+
server.tool(
|
|
7
|
+
"observe",
|
|
8
|
+
"Run the shared Pagesight API. Read-only operations: discover; bing.sites/queries/pages/traffic/crawl-stats/crawl-issues/url-info/link-counts/url-links; evidence.import (unverified UI findings); gsc.sites/sitemaps/inspect/report; ga.accounts/property/key-events/report; page (metadata and image ALT evidence); speed.psi/crux/history; doctor; snapshot; compare. Pass the API operation object as request. Returns structured evidence, exact effective requests, provider responses and partial errors. See the CLI --help and README for report/config shapes.",
|
|
9
|
+
{ request: operationSchema },
|
|
10
|
+
async ({ request }) => {
|
|
11
|
+
try {
|
|
12
|
+
const result = await execute(request);
|
|
13
|
+
return {
|
|
14
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
15
|
+
structuredContent: { ...result },
|
|
16
|
+
isError: result.status === "error",
|
|
17
|
+
};
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{ type: "text" as const, text: error instanceof RequestError ? error.message : "Invalid operation" },
|
|
22
|
+
],
|
|
23
|
+
isError: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { parseHead, getMeta, formatMetatags } from "./metadata.js";
|
|
3
|
+
import { validateJsonLd, formatValidation } from "./structured-data.js";
|
|
4
|
+
import {
|
|
5
|
+
followRedirects,
|
|
6
|
+
checkImage,
|
|
7
|
+
checkLink,
|
|
8
|
+
extractInternalLinks,
|
|
9
|
+
formatLinkResults,
|
|
10
|
+
type LinkResult,
|
|
11
|
+
} from "./links.js";
|
|
12
|
+
import { parseHex, relativeLuminance, contrastRatio, wcagLevel, toHex, findNearestPassing } from "./contrast.js";
|
|
13
|
+
|
|
14
|
+
export async function analyzePage(
|
|
15
|
+
url: string,
|
|
16
|
+
options: {
|
|
17
|
+
check_links?: boolean;
|
|
18
|
+
user_agent?: string;
|
|
19
|
+
foreground?: string;
|
|
20
|
+
background?: string;
|
|
21
|
+
large_text?: boolean;
|
|
22
|
+
},
|
|
23
|
+
): Promise<CallToolResult> {
|
|
24
|
+
const { check_links, user_agent, foreground, background, large_text } = options;
|
|
25
|
+
try {
|
|
26
|
+
const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
|
|
27
|
+
const { chain, response: res } = await followRedirects(url, ua);
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
return {
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: `Error fetching ${url}: HTTP ${res.status} ${res.statusText}`,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const html = await res.text();
|
|
41
|
+
const parsed = parseHead(html);
|
|
42
|
+
const output: string[] = [];
|
|
43
|
+
|
|
44
|
+
// Redirect chain (only if there were redirects)
|
|
45
|
+
if (chain.length > 1) {
|
|
46
|
+
output.push("--- Redirect Chain ---", "");
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
for (const hop of chain) {
|
|
49
|
+
parts.push(hop.url);
|
|
50
|
+
parts.push(String(hop.status));
|
|
51
|
+
}
|
|
52
|
+
// Format: URL → status → URL → status (drop trailing status)
|
|
53
|
+
parts.pop();
|
|
54
|
+
output.push(parts.join(" → "));
|
|
55
|
+
output.push(`Hops: ${chain.length - 1}`);
|
|
56
|
+
output.push("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Main meta tag report
|
|
60
|
+
const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : url;
|
|
61
|
+
output.push(formatMetatags(finalUrl, parsed));
|
|
62
|
+
|
|
63
|
+
// OG/Twitter image validation
|
|
64
|
+
const ogImage = getMeta(parsed.meta, "og:image");
|
|
65
|
+
const twitterImage = getMeta(parsed.meta, "twitter:image");
|
|
66
|
+
const imagesToCheck: Array<{ url: string; tag: string }> = [];
|
|
67
|
+
if (ogImage) imagesToCheck.push({ url: ogImage, tag: "og:image" });
|
|
68
|
+
if (twitterImage && twitterImage !== ogImage) imagesToCheck.push({ url: twitterImage, tag: "twitter:image" });
|
|
69
|
+
|
|
70
|
+
if (imagesToCheck.length > 0) {
|
|
71
|
+
const checks = await Promise.all(imagesToCheck.map((img) => checkImage(img.url, img.tag)));
|
|
72
|
+
output.push("", "--- Image Validation ---", "");
|
|
73
|
+
for (const check of checks) {
|
|
74
|
+
if (check.error) {
|
|
75
|
+
output.push(`${check.tag}: FAILED — ${check.error}`);
|
|
76
|
+
output.push(` URL: ${check.url}`);
|
|
77
|
+
} else if (check.status && check.status >= 400) {
|
|
78
|
+
output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
|
|
79
|
+
output.push(` URL: ${check.url}`);
|
|
80
|
+
} else {
|
|
81
|
+
const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
|
|
82
|
+
const size = sizeKB ? ` (${sizeKB} KB)` : "";
|
|
83
|
+
const type = check.contentType ? ` ${check.contentType}` : "";
|
|
84
|
+
output.push(`${check.tag}: OK —${type}${size}`);
|
|
85
|
+
if (sizeKB && sizeKB > 1024) {
|
|
86
|
+
output.push(` Warning: ${sizeKB} KB is large for social previews — consider compressing below 1 MB`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check declared dimensions from meta tags
|
|
91
|
+
if (check.tag === "og:image") {
|
|
92
|
+
const w = getMeta(parsed.meta, "og:image:width");
|
|
93
|
+
const h = getMeta(parsed.meta, "og:image:height");
|
|
94
|
+
if (w && h) {
|
|
95
|
+
output.push(` Declared dimensions: ${w}x${h}`);
|
|
96
|
+
const wn = Number(w);
|
|
97
|
+
const hn = Number(h);
|
|
98
|
+
if (wn && hn && (wn < 1200 || hn < 630)) {
|
|
99
|
+
output.push(" Note: recommended minimum for og:image is 1200x630");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Structured data validation
|
|
107
|
+
if (parsed.jsonLd.length > 0) {
|
|
108
|
+
const { issues, imageUrls, validatedTypes } = validateJsonLd(parsed.jsonLd);
|
|
109
|
+
if (validatedTypes.size > 0 || imageUrls.length > 0) {
|
|
110
|
+
output.push("", "--- Structured Data Validation ---", "");
|
|
111
|
+
output.push(...formatValidation(issues, validatedTypes));
|
|
112
|
+
|
|
113
|
+
// HEAD-check image URLs from structured data
|
|
114
|
+
if (imageUrls.length > 0) {
|
|
115
|
+
const deduped = [...new Set(imageUrls)];
|
|
116
|
+
const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
|
|
117
|
+
for (const check of imgChecks) {
|
|
118
|
+
if (check.error) {
|
|
119
|
+
output.push(`BROKEN Image: ${check.url} — ${check.error}`);
|
|
120
|
+
} else if (check.status && check.status >= 400) {
|
|
121
|
+
output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
|
|
122
|
+
} else {
|
|
123
|
+
const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
|
|
124
|
+
output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (foreground && background) {
|
|
132
|
+
const fg = parseHex(foreground);
|
|
133
|
+
const bg = parseHex(background);
|
|
134
|
+
if (fg && bg) {
|
|
135
|
+
const fgLum = relativeLuminance(...fg);
|
|
136
|
+
const bgLum = relativeLuminance(...bg);
|
|
137
|
+
const ratio = contrastRatio(fgLum, bgLum);
|
|
138
|
+
const isLarge = large_text ?? false;
|
|
139
|
+
const level = wcagLevel(ratio, isLarge);
|
|
140
|
+
const aaThreshold = isLarge ? 3 : 4.5;
|
|
141
|
+
const aaaThreshold = isLarge ? 4.5 : 7;
|
|
142
|
+
output.push(
|
|
143
|
+
"",
|
|
144
|
+
"--- Contrast Check ---",
|
|
145
|
+
"",
|
|
146
|
+
`Foreground: ${toHex(...fg)} Background: ${toHex(...bg)}`,
|
|
147
|
+
`Ratio: ${ratio.toFixed(2)}:1 AA: ${ratio >= aaThreshold ? "PASS" : "FAIL"} AAA: ${ratio >= aaaThreshold ? "PASS" : "FAIL"} Result: ${level}`,
|
|
148
|
+
);
|
|
149
|
+
if (ratio < aaThreshold) {
|
|
150
|
+
const suggested = findNearestPassing(fg, bg, aaThreshold);
|
|
151
|
+
output.push(`Nearest AA-passing foreground: ${suggested}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Link check (last section) ---
|
|
157
|
+
if (check_links !== false) {
|
|
158
|
+
const origin = new URL(url).origin;
|
|
159
|
+
const links = extractInternalLinks(html, origin);
|
|
160
|
+
|
|
161
|
+
if (links.length === 0) {
|
|
162
|
+
output.push(
|
|
163
|
+
"",
|
|
164
|
+
"--- Internal Links ---",
|
|
165
|
+
"",
|
|
166
|
+
"No internal links found.",
|
|
167
|
+
"Note: this page may use client-side rendering (SPA). Only static <a href> links in the HTML are detected.",
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
const concurrency = 5;
|
|
171
|
+
const results: LinkResult[] = [];
|
|
172
|
+
let i = 0;
|
|
173
|
+
|
|
174
|
+
while (i < links.length) {
|
|
175
|
+
const batch = links.slice(i, i + concurrency);
|
|
176
|
+
const settled = await Promise.all(batch.map((href) => checkLink(href)));
|
|
177
|
+
results.push(...settled);
|
|
178
|
+
i += concurrency;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
output.push("", formatLinkResults(url, results));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
content: [{ type: "text", text: output.join("\n") }],
|
|
187
|
+
};
|
|
188
|
+
} catch (err) {
|
|
189
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
190
|
+
return {
|
|
191
|
+
content: [{ type: "text", text: `Error analyzing page: ${msg}` }],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { parseHead, getMeta } from "./metadata.js";
|
|
3
|
+
import { formatJsonLdType, validateJsonLd } from "./structured-data.js";
|
|
4
|
+
import { followRedirects, checkLink, extractInternalLinks } from "./links.js";
|
|
5
|
+
|
|
6
|
+
export async function analyzeBatch(
|
|
7
|
+
urls: string[],
|
|
8
|
+
options: { check_links?: boolean; user_agent?: string },
|
|
9
|
+
): Promise<CallToolResult> {
|
|
10
|
+
const { check_links, user_agent } = options;
|
|
11
|
+
const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
|
|
12
|
+
const results: Array<{
|
|
13
|
+
url: string;
|
|
14
|
+
title: string;
|
|
15
|
+
description: string;
|
|
16
|
+
canonical: string;
|
|
17
|
+
jsonLd: string;
|
|
18
|
+
links: string;
|
|
19
|
+
issues: string[];
|
|
20
|
+
error?: string;
|
|
21
|
+
}> = [];
|
|
22
|
+
|
|
23
|
+
for (const pageUrl of urls) {
|
|
24
|
+
try {
|
|
25
|
+
const { chain, response: res } = await followRedirects(pageUrl, ua);
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
results.push({
|
|
28
|
+
url: pageUrl,
|
|
29
|
+
title: "-",
|
|
30
|
+
description: "-",
|
|
31
|
+
canonical: "-",
|
|
32
|
+
jsonLd: "-",
|
|
33
|
+
links: "-",
|
|
34
|
+
issues: [`HTTP ${res.status}`],
|
|
35
|
+
});
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const html = await res.text();
|
|
39
|
+
const parsed = parseHead(html);
|
|
40
|
+
const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : pageUrl;
|
|
41
|
+
|
|
42
|
+
const issues: string[] = [];
|
|
43
|
+
if (!parsed.title) issues.push("no title");
|
|
44
|
+
if (!getMeta(parsed.meta, "description")) issues.push("no description");
|
|
45
|
+
else if ((getMeta(parsed.meta, "description") ?? "").length > 155) issues.push("description >155 chars");
|
|
46
|
+
if (!parsed.canonical) issues.push("no canonical");
|
|
47
|
+
if (parsed.jsonLd.length === 0) issues.push("no JSON-LD");
|
|
48
|
+
if (chain.length > 2) issues.push(`${chain.length - 1} redirects`);
|
|
49
|
+
|
|
50
|
+
// Quick link check
|
|
51
|
+
let linkSummary = "-";
|
|
52
|
+
if (check_links !== false) {
|
|
53
|
+
const origin = new URL(finalUrl).origin;
|
|
54
|
+
const links = extractInternalLinks(html, origin);
|
|
55
|
+
if (links.length > 0) {
|
|
56
|
+
const checked = await Promise.all(links.slice(0, 20).map((href) => checkLink(href)));
|
|
57
|
+
const broken = checked.filter((r) => r.error || (r.status && r.status >= 400)).length;
|
|
58
|
+
const redirected = checked.filter(
|
|
59
|
+
(r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400,
|
|
60
|
+
).length;
|
|
61
|
+
const parts: string[] = [`${links.length} found`];
|
|
62
|
+
if (broken > 0) parts.push(`${broken} broken`);
|
|
63
|
+
if (redirected > 0) parts.push(`${redirected} redirected`);
|
|
64
|
+
if (broken === 0 && redirected === 0) parts.push("all OK");
|
|
65
|
+
linkSummary = parts.join(", ");
|
|
66
|
+
if (broken > 0) issues.push(`${broken} broken links`);
|
|
67
|
+
if (redirected > 0) issues.push(`${redirected} redirect chains`);
|
|
68
|
+
} else {
|
|
69
|
+
linkSummary = "0 (SPA?)";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const jsonLdTypes = parsed.jsonLd
|
|
74
|
+
.map((block) => {
|
|
75
|
+
if (block && typeof block === "object" && "@type" in block)
|
|
76
|
+
return formatJsonLdType((block as Record<string, unknown>)["@type"], ",");
|
|
77
|
+
return null;
|
|
78
|
+
})
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
|
|
81
|
+
// Run structured data validation for batch summary
|
|
82
|
+
let jsonLdSummary = "none";
|
|
83
|
+
if (parsed.jsonLd.length > 0) {
|
|
84
|
+
const { issues: valIssues } = validateJsonLd(parsed.jsonLd);
|
|
85
|
+
const requiredMissing = valIssues.filter((i) => i.level === "required").length;
|
|
86
|
+
if (jsonLdTypes.length > 0) {
|
|
87
|
+
jsonLdSummary = `${jsonLdTypes.join(", ")} (selected fields only)`;
|
|
88
|
+
if (requiredMissing > 0) {
|
|
89
|
+
jsonLdSummary += ` (${requiredMissing} required missing)`;
|
|
90
|
+
issues.push(`${requiredMissing} missing required fields in checked JSON-LD subset`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Description with length
|
|
96
|
+
const descVal = getMeta(parsed.meta, "description");
|
|
97
|
+
let descSummary: string;
|
|
98
|
+
if (descVal) {
|
|
99
|
+
descSummary = descVal.length > 155 ? `yes (${descVal.length} chars ⚠)` : `yes (${descVal.length})`;
|
|
100
|
+
} else {
|
|
101
|
+
descSummary = "no";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
results.push({
|
|
105
|
+
url: finalUrl,
|
|
106
|
+
title: parsed.title
|
|
107
|
+
? parsed.title.length > 60
|
|
108
|
+
? `${parsed.title.slice(0, parsed.title.lastIndexOf(" ", 60) > 20 ? parsed.title.lastIndexOf(" ", 60) : 60)}...`
|
|
109
|
+
: parsed.title
|
|
110
|
+
: "(missing)",
|
|
111
|
+
description: descSummary,
|
|
112
|
+
canonical: parsed.canonical ? "yes" : "no",
|
|
113
|
+
jsonLd: jsonLdSummary,
|
|
114
|
+
links: linkSummary,
|
|
115
|
+
issues,
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
results.push({
|
|
119
|
+
url: pageUrl,
|
|
120
|
+
title: "-",
|
|
121
|
+
description: "-",
|
|
122
|
+
canonical: "-",
|
|
123
|
+
jsonLd: "-",
|
|
124
|
+
links: "-",
|
|
125
|
+
issues: [err instanceof Error ? err.message : String(err)],
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const lines: string[] = [`=== Batch Page Analysis (${results.length} URLs) ===`, ""];
|
|
131
|
+
|
|
132
|
+
for (const r of results) {
|
|
133
|
+
let label: string;
|
|
134
|
+
try {
|
|
135
|
+
const u = new URL(r.url);
|
|
136
|
+
const allSameHost = results.every((x) => {
|
|
137
|
+
try {
|
|
138
|
+
return new URL(x.url).hostname === u.hostname;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
label = allSameHost ? u.pathname : `${u.hostname}${u.pathname}`;
|
|
144
|
+
} catch {
|
|
145
|
+
label = r.url;
|
|
146
|
+
}
|
|
147
|
+
lines.push(label);
|
|
148
|
+
lines.push(` Title: ${r.title}`);
|
|
149
|
+
lines.push(` Description: ${r.description} Canonical: ${r.canonical} JSON-LD: ${r.jsonLd}`);
|
|
150
|
+
if (check_links !== false) lines.push(` Links: ${r.links}`);
|
|
151
|
+
if (r.issues.length > 0) {
|
|
152
|
+
lines.push(` Issues: ${r.issues.join(", ")}`);
|
|
153
|
+
} else {
|
|
154
|
+
lines.push(" Issues: none");
|
|
155
|
+
}
|
|
156
|
+
lines.push("");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const issueCount = results.reduce((sum, r) => sum + r.issues.length, 0);
|
|
160
|
+
lines.push(`Total: ${results.length} pages, ${issueCount} issues`);
|
|
161
|
+
|
|
162
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
163
|
+
}
|