pagesight 0.17.0 → 0.19.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 +20 -82
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/opportunities.md +78 -0
- package/docs/snapshots.md +130 -0
- package/docs/usage.md +135 -0
- package/package.json +26 -29
- package/src/api/assessment.ts +315 -0
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +167 -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 +160 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/index.ts +5 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +108 -0
- package/src/api/schema.ts +271 -0
- package/src/api/snapshot.ts +202 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +217 -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/opportunities-text.ts +61 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
export function parseHex(hex: string): [number, number, number] | null {
|
|
3
|
+
const clean = hex.replace(/^#/, "");
|
|
4
|
+
if (clean.length === 3) {
|
|
5
|
+
const r = Number.parseInt(clean[0] + clean[0], 16);
|
|
6
|
+
const g = Number.parseInt(clean[1] + clean[1], 16);
|
|
7
|
+
const b = Number.parseInt(clean[2] + clean[2], 16);
|
|
8
|
+
return [r, g, b];
|
|
9
|
+
}
|
|
10
|
+
if (clean.length === 6) {
|
|
11
|
+
const r = Number.parseInt(clean.slice(0, 2), 16);
|
|
12
|
+
const g = Number.parseInt(clean.slice(2, 4), 16);
|
|
13
|
+
const b = Number.parseInt(clean.slice(4, 6), 16);
|
|
14
|
+
return [r, g, b];
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toHex(r: number, g: number, b: number): string {
|
|
20
|
+
return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function relativeLuminance(r: number, g: number, b: number): number {
|
|
24
|
+
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
25
|
+
const s = c / 255;
|
|
26
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
27
|
+
});
|
|
28
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function contrastRatio(l1: number, l2: number): number {
|
|
32
|
+
const lighter = Math.max(l1, l2);
|
|
33
|
+
const darker = Math.min(l1, l2);
|
|
34
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function wcagLevel(ratio: number, isLargeText: boolean): string {
|
|
38
|
+
if (isLargeText) {
|
|
39
|
+
if (ratio >= 4.5) return "AAA";
|
|
40
|
+
if (ratio >= 3) return "AA";
|
|
41
|
+
return "FAIL";
|
|
42
|
+
}
|
|
43
|
+
if (ratio >= 7) return "AAA";
|
|
44
|
+
if (ratio >= 4.5) return "AA";
|
|
45
|
+
return "FAIL";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findNearestPassing(
|
|
49
|
+
fg: [number, number, number],
|
|
50
|
+
bg: [number, number, number],
|
|
51
|
+
targetRatio: number,
|
|
52
|
+
): string {
|
|
53
|
+
const bgLum = relativeLuminance(...bg);
|
|
54
|
+
|
|
55
|
+
// Try both directions: darken and lighten, pick the one closest to original
|
|
56
|
+
let bestDarken: [number, number, number] | null = null;
|
|
57
|
+
let bestLighten: [number, number, number] | null = null;
|
|
58
|
+
|
|
59
|
+
for (let step = 0; step <= 100; step++) {
|
|
60
|
+
const t = step / 100;
|
|
61
|
+
const dark: [number, number, number] = [fg[0] * (1 - t), fg[1] * (1 - t), fg[2] * (1 - t)];
|
|
62
|
+
if (!bestDarken && contrastRatio(relativeLuminance(...dark), bgLum) >= targetRatio) {
|
|
63
|
+
bestDarken = dark;
|
|
64
|
+
}
|
|
65
|
+
const light: [number, number, number] = [
|
|
66
|
+
fg[0] + (255 - fg[0]) * t,
|
|
67
|
+
fg[1] + (255 - fg[1]) * t,
|
|
68
|
+
fg[2] + (255 - fg[2]) * t,
|
|
69
|
+
];
|
|
70
|
+
if (!bestLighten && contrastRatio(relativeLuminance(...light), bgLum) >= targetRatio) {
|
|
71
|
+
bestLighten = light;
|
|
72
|
+
}
|
|
73
|
+
if (bestDarken && bestLighten) break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Pick the direction that changes the color least
|
|
77
|
+
if (bestDarken && bestLighten) {
|
|
78
|
+
const darkDist =
|
|
79
|
+
Math.abs(fg[0] - bestDarken[0]) + Math.abs(fg[1] - bestDarken[1]) + Math.abs(fg[2] - bestDarken[2]);
|
|
80
|
+
const lightDist =
|
|
81
|
+
Math.abs(fg[0] - bestLighten[0]) + Math.abs(fg[1] - bestLighten[1]) + Math.abs(fg[2] - bestLighten[2]);
|
|
82
|
+
return toHex(...(darkDist <= lightDist ? bestDarken : bestLighten));
|
|
83
|
+
}
|
|
84
|
+
return toHex(...(bestDarken ?? bestLighten ?? fg));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function analyzeContrast(foreground: string, background: string, large_text?: boolean): CallToolResult {
|
|
88
|
+
const fg = parseHex(foreground);
|
|
89
|
+
const bg = parseHex(background);
|
|
90
|
+
|
|
91
|
+
if (!fg) return { content: [{ type: "text", text: `Invalid foreground color: ${foreground}` }] };
|
|
92
|
+
if (!bg) return { content: [{ type: "text", text: `Invalid background color: ${background}` }] };
|
|
93
|
+
|
|
94
|
+
const fgLum = relativeLuminance(...fg);
|
|
95
|
+
const bgLum = relativeLuminance(...bg);
|
|
96
|
+
const ratio = contrastRatio(fgLum, bgLum);
|
|
97
|
+
const isLarge = large_text ?? false;
|
|
98
|
+
const level = wcagLevel(ratio, isLarge);
|
|
99
|
+
|
|
100
|
+
const aaThreshold = isLarge ? 3 : 4.5;
|
|
101
|
+
const aaaThreshold = isLarge ? 4.5 : 7;
|
|
102
|
+
|
|
103
|
+
const lines: string[] = [
|
|
104
|
+
"=== Contrast Check ===",
|
|
105
|
+
"",
|
|
106
|
+
`Foreground: ${toHex(...fg)}`,
|
|
107
|
+
`Background: ${toHex(...bg)}`,
|
|
108
|
+
`Text size: ${isLarge ? "large (≥18pt)" : "normal"}`,
|
|
109
|
+
"",
|
|
110
|
+
`Contrast ratio: ${ratio.toFixed(2)}:1`,
|
|
111
|
+
`WCAG AA (${aaThreshold}:1): ${ratio >= aaThreshold ? "PASS" : "FAIL"}`,
|
|
112
|
+
`WCAG AAA (${aaaThreshold}:1): ${ratio >= aaaThreshold ? "PASS" : "FAIL"}`,
|
|
113
|
+
`Result: ${level}`,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
if (ratio < aaThreshold) {
|
|
117
|
+
const suggested = findNearestPassing(fg, bg, aaThreshold);
|
|
118
|
+
lines.push("", `Nearest AA-passing foreground: ${suggested}`);
|
|
119
|
+
|
|
120
|
+
const sugParsed = parseHex(suggested);
|
|
121
|
+
if (sugParsed) {
|
|
122
|
+
const sugRatio = contrastRatio(relativeLuminance(...sugParsed), bgLum);
|
|
123
|
+
lines.push(`New ratio: ${sugRatio.toFixed(2)}:1`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
128
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
interface RedirectHop {
|
|
2
|
+
url: string;
|
|
3
|
+
status: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface ImageCheck {
|
|
7
|
+
url: string;
|
|
8
|
+
tag: string;
|
|
9
|
+
status: number | null;
|
|
10
|
+
contentType: string | null;
|
|
11
|
+
contentLength: number | null;
|
|
12
|
+
error: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LinkResult {
|
|
16
|
+
href: string;
|
|
17
|
+
status: number | null;
|
|
18
|
+
redirectChain: Array<{ url: string; status: number }>;
|
|
19
|
+
finalUrl: string | null;
|
|
20
|
+
error: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function followRedirects(
|
|
24
|
+
url: string,
|
|
25
|
+
ua: string,
|
|
26
|
+
maxHops = 10,
|
|
27
|
+
): Promise<{ chain: RedirectHop[]; response: Response }> {
|
|
28
|
+
const chain: RedirectHop[] = [];
|
|
29
|
+
let current = url;
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < maxHops; i++) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
34
|
+
const res = await fetch(current, {
|
|
35
|
+
headers: { "User-Agent": ua, Accept: "text/html" },
|
|
36
|
+
redirect: "manual",
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
clearTimeout(timeout);
|
|
40
|
+
|
|
41
|
+
chain.push({ url: current, status: res.status });
|
|
42
|
+
|
|
43
|
+
if (res.status >= 300 && res.status < 400) {
|
|
44
|
+
const location = res.headers.get("location");
|
|
45
|
+
if (!location) break;
|
|
46
|
+
current = new URL(location, current).href;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { chain, response: res };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Retry with automatic redirects after the hop limit or a missing Location header.
|
|
54
|
+
const res = await fetch(current, {
|
|
55
|
+
headers: { "User-Agent": ua, Accept: "text/html" },
|
|
56
|
+
redirect: "follow",
|
|
57
|
+
});
|
|
58
|
+
return { chain, response: res };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function checkImage(imageUrl: string, tag: string): Promise<ImageCheck> {
|
|
62
|
+
try {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
65
|
+
const res = await fetch(imageUrl, { method: "HEAD", redirect: "follow", signal: controller.signal });
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
return {
|
|
68
|
+
url: imageUrl,
|
|
69
|
+
tag,
|
|
70
|
+
status: res.status,
|
|
71
|
+
contentType: res.headers.get("content-type"),
|
|
72
|
+
contentLength: res.headers.has("content-length") ? Number(res.headers.get("content-length")) : null,
|
|
73
|
+
error: null,
|
|
74
|
+
};
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
url: imageUrl,
|
|
78
|
+
tag,
|
|
79
|
+
status: null,
|
|
80
|
+
contentType: null,
|
|
81
|
+
contentLength: null,
|
|
82
|
+
error: err instanceof Error ? err.message : String(err),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function checkLink(href: string): Promise<LinkResult> {
|
|
88
|
+
const chain: Array<{ url: string; status: number }> = [];
|
|
89
|
+
let current = href;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
for (let i = 0; i < 10; i++) {
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
95
|
+
const res = await fetch(current, {
|
|
96
|
+
method: "HEAD",
|
|
97
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
98
|
+
redirect: "manual",
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
});
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
|
|
103
|
+
chain.push({ url: current, status: res.status });
|
|
104
|
+
|
|
105
|
+
if (res.status >= 300 && res.status < 400) {
|
|
106
|
+
const location = res.headers.get("location");
|
|
107
|
+
if (!location) break;
|
|
108
|
+
current = new URL(location, current).href;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
href,
|
|
114
|
+
status: res.status,
|
|
115
|
+
redirectChain: chain,
|
|
116
|
+
finalUrl: chain.length > 1 ? current : null,
|
|
117
|
+
error: null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
href,
|
|
123
|
+
status: chain[chain.length - 1]?.status ?? null,
|
|
124
|
+
redirectChain: chain,
|
|
125
|
+
finalUrl: current,
|
|
126
|
+
error: "Too many redirects",
|
|
127
|
+
};
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return {
|
|
130
|
+
href,
|
|
131
|
+
status: null,
|
|
132
|
+
redirectChain: chain,
|
|
133
|
+
finalUrl: null,
|
|
134
|
+
error: err instanceof Error ? err.message : String(err),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function extractInternalLinks(html: string, origin: string): string[] {
|
|
140
|
+
const links = new Set<string>();
|
|
141
|
+
|
|
142
|
+
for (const match of html.matchAll(/<a\s[^>]*href=["']([^"'#]+)/gi)) {
|
|
143
|
+
const raw = match[1].trim();
|
|
144
|
+
if (!raw || raw.startsWith("javascript:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) continue;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const resolved = new URL(raw, origin).href;
|
|
148
|
+
if (resolved.startsWith(origin) && !resolved.includes("/cdn-cgi/")) {
|
|
149
|
+
links.add(resolved);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Invalid URL, skip
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return [...links];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function formatLinkResults(url: string, results: LinkResult[]): string {
|
|
160
|
+
const lines: string[] = [`=== Internal Links: ${url} ===`, `Found ${results.length} internal links`, ""];
|
|
161
|
+
|
|
162
|
+
const broken = results.filter((r) => r.error || (r.status && r.status >= 400));
|
|
163
|
+
const redirected = results.filter((r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400);
|
|
164
|
+
const ok = results.filter((r) => !r.error && r.redirectChain.length === 1 && r.status && r.status < 400);
|
|
165
|
+
|
|
166
|
+
// Summary
|
|
167
|
+
lines.push("--- Summary ---", "");
|
|
168
|
+
lines.push(`OK: ${ok.length}`);
|
|
169
|
+
if (redirected.length > 0) lines.push(`Redirected: ${redirected.length}`);
|
|
170
|
+
if (broken.length > 0) lines.push(`Broken: ${broken.length}`);
|
|
171
|
+
lines.push("");
|
|
172
|
+
|
|
173
|
+
// Broken links
|
|
174
|
+
if (broken.length > 0) {
|
|
175
|
+
lines.push("--- Broken Links ---", "");
|
|
176
|
+
for (const r of broken) {
|
|
177
|
+
if (r.error) {
|
|
178
|
+
lines.push(`FAIL ${r.href}`);
|
|
179
|
+
lines.push(` Error: ${r.error}`);
|
|
180
|
+
} else {
|
|
181
|
+
lines.push(`FAIL ${r.href} → ${r.status}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
lines.push("");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Redirect chains
|
|
188
|
+
if (redirected.length > 0) {
|
|
189
|
+
lines.push("--- Redirect Chains ---", "");
|
|
190
|
+
for (const r of redirected) {
|
|
191
|
+
const hops = r.redirectChain.length - 1;
|
|
192
|
+
const chainStr = r.redirectChain.map((h) => `${h.status}`).join(" → ");
|
|
193
|
+
lines.push(`REDIRECT ${r.href}`);
|
|
194
|
+
lines.push(` ${chainStr} → ${r.finalUrl} (${hops} hop${hops > 1 ? "s" : ""})`);
|
|
195
|
+
}
|
|
196
|
+
lines.push("");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|