Skip to content

Commit 48d6dd2

Browse files
committed
feat: batch page analysis, default check_links, polish
- Batch page: urls param (2-10) returns per-page summary with title, description, canonical, JSON-LD types, link health, and issues. Shows hostname when URLs span multiple domains. - Default check_links: true — links always checked unless explicitly false. - Contrast position: moved before links section in combined output. - Hreflang warning: flags locale-patterned URLs missing hreflang tags. - Analytics compare: always shows Regressed section with (none) when empty.
1 parent 54c2cfa commit 48d6dd2

2 files changed

Lines changed: 175 additions & 36 deletions

File tree

‎src/tools/page.ts‎

Lines changed: 171 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,17 @@ function formatMetatags(url: string, parsed: ParsedHead): string {
571571
lines.push(`${h.lang}: ${h.href}`);
572572
}
573573
lines.push("");
574+
} else {
575+
// Warn if URL suggests locale-specific content but no hreflang
576+
const localePattern =
577+
/\/(?:en|es|fr|de|ja|ko|pt|zh|ru|it|nl|sv|da|fi|nb|pl|tr|ar|hi|th|vi|id|ms|uk|cs|ro|hu|el|he|bg|hr|sk|sl|sr|lt|lv|et|ca|gl|eu|cy)(?:[-_][a-z]{2,4})?(?:\/|$)/i;
578+
const checkUrl = parsed.canonical ?? url;
579+
if (localePattern.test(checkUrl)) {
580+
lines.push(
581+
"WARN: URL appears locale-specific but no hreflang tags found — search engines may not discover alternate language versions",
582+
);
583+
lines.push("");
584+
}
574585
}
575586

576587
// Summary of missing tags relevant to search and social
@@ -798,15 +809,19 @@ export function registerPageTool(server: McpServer): void {
798809
"page",
799810
"Analyze what's on a page — meta tags, Open Graph, Twitter Card, structured data (JSON-LD), internal links, and redirect chains. Also includes a WCAG contrast checker for accessibility fixes.",
800811
{
801-
url: z
802-
.string()
803-
.url()
812+
url: z.string().url().optional().describe("Single URL to analyze. Use this OR urls, not both."),
813+
urls: z
814+
.array(z.string().url())
815+
.min(2)
816+
.max(10)
804817
.optional()
805-
.describe("Page URL to analyze. Returns meta tags, structured data, and optionally internal links."),
818+
.describe(
819+
"Multiple URLs (2-10) for batch analysis. Returns a summary table of meta tags, structured data, and link health per page.",
820+
),
806821
check_links: z
807822
.boolean()
808823
.optional()
809-
.describe("Also check all internal links for broken links and redirect chains. Default: false."),
824+
.describe("Check internal links for broken links and redirect chains. Default: true. Set false to skip."),
810825
user_agent: z.string().optional().describe("Custom User-Agent for page fetch. Default: Googlebot-compatible."),
811826
foreground: z
812827
.string()
@@ -818,7 +833,129 @@ export function registerPageTool(server: McpServer): void {
818833
.optional()
819834
.describe("Whether text is large (≥18pt or ≥14pt bold). Lowers AA threshold to 3:1."),
820835
},
821-
async ({ url, check_links, user_agent, foreground, background, large_text }) => {
836+
async ({ url, urls, check_links, user_agent, foreground, background, large_text }) => {
837+
// --- Batch mode ---
838+
if (urls) {
839+
const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
840+
const results: Array<{
841+
url: string;
842+
title: string;
843+
description: string;
844+
canonical: string;
845+
jsonLd: string;
846+
links: string;
847+
issues: string[];
848+
error?: string;
849+
}> = [];
850+
851+
for (const pageUrl of urls) {
852+
try {
853+
const { chain, response: res } = await followRedirects(pageUrl, ua);
854+
if (!res.ok) {
855+
results.push({
856+
url: pageUrl,
857+
title: "-",
858+
description: "-",
859+
canonical: "-",
860+
jsonLd: "-",
861+
links: "-",
862+
issues: [`HTTP ${res.status}`],
863+
});
864+
continue;
865+
}
866+
const html = await res.text();
867+
const parsed = parseHead(html);
868+
const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : pageUrl;
869+
870+
const issues: string[] = [];
871+
if (!parsed.title) issues.push("no title");
872+
if (!getMeta(parsed.meta, "description")) issues.push("no description");
873+
else if ((getMeta(parsed.meta, "description") ?? "").length > 155) issues.push("description >155 chars");
874+
if (!parsed.canonical) issues.push("no canonical");
875+
if (parsed.jsonLd.length === 0) issues.push("no JSON-LD");
876+
if (chain.length > 2) issues.push(`${chain.length - 1} redirects`);
877+
878+
// Quick link check
879+
let linkSummary = "-";
880+
if (check_links !== false) {
881+
const origin = new URL(finalUrl).origin;
882+
const links = extractInternalLinks(html, origin);
883+
if (links.length > 0) {
884+
const checked = await Promise.all(links.slice(0, 20).map((href) => checkLink(href)));
885+
const broken = checked.filter((r) => r.error || (r.status && r.status >= 400)).length;
886+
const redirected = checked.filter(
887+
(r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400,
888+
).length;
889+
const parts: string[] = [`${links.length} found`];
890+
if (broken > 0) parts.push(`${broken} broken`);
891+
if (redirected > 0) parts.push(`${redirected} redirected`);
892+
if (broken === 0 && redirected === 0) parts.push("all OK");
893+
linkSummary = parts.join(", ");
894+
if (broken > 0) issues.push(`${broken} broken links`);
895+
if (redirected > 0) issues.push(`${redirected} redirect chains`);
896+
} else {
897+
linkSummary = "0 (SPA?)";
898+
}
899+
}
900+
901+
const jsonLdTypes = parsed.jsonLd
902+
.map((block) => {
903+
if (block && typeof block === "object" && "@type" in block)
904+
return String((block as Record<string, unknown>)["@type"]);
905+
return null;
906+
})
907+
.filter(Boolean);
908+
909+
results.push({
910+
url: finalUrl,
911+
title: parsed.title
912+
? parsed.title.length > 40
913+
? `${parsed.title.slice(0, 40)}...`
914+
: parsed.title
915+
: "(missing)",
916+
description: getMeta(parsed.meta, "description") ? "yes" : "no",
917+
canonical: parsed.canonical ? "yes" : "no",
918+
jsonLd: jsonLdTypes.length > 0 ? jsonLdTypes.join(", ") : "none",
919+
links: linkSummary,
920+
issues,
921+
});
922+
} catch (err) {
923+
results.push({
924+
url: pageUrl,
925+
title: "-",
926+
description: "-",
927+
canonical: "-",
928+
jsonLd: "-",
929+
links: "-",
930+
issues: [err instanceof Error ? err.message : String(err)],
931+
});
932+
}
933+
}
934+
935+
const lines: string[] = [`=== Batch Page Analysis (${results.length} URLs) ===`, ""];
936+
937+
for (const r of results) {
938+
const u = new URL(r.url);
939+
const allSameHost = results.every((x) => new URL(x.url).hostname === u.hostname);
940+
const label = allSameHost ? u.pathname : `${u.hostname}${u.pathname}`;
941+
lines.push(label);
942+
lines.push(` Title: ${r.title}`);
943+
lines.push(` Description: ${r.description} Canonical: ${r.canonical} JSON-LD: ${r.jsonLd}`);
944+
if (check_links !== false) lines.push(` Links: ${r.links}`);
945+
if (r.issues.length > 0) {
946+
lines.push(` Issues: ${r.issues.join(", ")}`);
947+
} else {
948+
lines.push(" Issues: none");
949+
}
950+
lines.push("");
951+
}
952+
953+
const issueCount = results.reduce((sum, r) => sum + r.issues.length, 0);
954+
lines.push(`Total: ${results.length} pages, ${issueCount} issues`);
955+
956+
return { content: [{ type: "text", text: lines.join("\n") }] };
957+
}
958+
822959
// --- Contrast-only mode (no URL) ---
823960
if (foreground && background && !url) {
824961
const fg = parseHex(foreground);
@@ -971,8 +1108,34 @@ export function registerPageTool(server: McpServer): void {
9711108
}
9721109
}
9731110

974-
// --- Link check (appended after metatags output) ---
975-
if (check_links) {
1111+
// Contrast check (before links, after structured data)
1112+
if (foreground && background) {
1113+
const fg = parseHex(foreground);
1114+
const bg = parseHex(background);
1115+
if (fg && bg) {
1116+
const fgLum = relativeLuminance(...fg);
1117+
const bgLum = relativeLuminance(...bg);
1118+
const ratio = contrastRatio(fgLum, bgLum);
1119+
const isLarge = large_text ?? false;
1120+
const level = wcagLevel(ratio, isLarge);
1121+
const aaThreshold = isLarge ? 3 : 4.5;
1122+
const aaaThreshold = isLarge ? 4.5 : 7;
1123+
output.push(
1124+
"",
1125+
"--- Contrast Check ---",
1126+
"",
1127+
`Foreground: ${toHex(...fg)} Background: ${toHex(...bg)}`,
1128+
`Ratio: ${ratio.toFixed(2)}:1 AA: ${ratio >= aaThreshold ? "PASS" : "FAIL"} AAA: ${ratio >= aaaThreshold ? "PASS" : "FAIL"} Result: ${level}`,
1129+
);
1130+
if (ratio < aaThreshold) {
1131+
const suggested = findNearestPassing(fg, bg, aaThreshold);
1132+
output.push(`Nearest AA-passing foreground: ${suggested}`);
1133+
}
1134+
}
1135+
}
1136+
1137+
// --- Link check (last section) ---
1138+
if (check_links !== false) {
9761139
const origin = new URL(url).origin;
9771140
const links = extractInternalLinks(html, origin);
9781141

@@ -1001,32 +1164,6 @@ export function registerPageTool(server: McpServer): void {
10011164
}
10021165
}
10031166

1004-
// Append contrast check if colors were also provided
1005-
if (foreground && background) {
1006-
const fg = parseHex(foreground);
1007-
const bg = parseHex(background);
1008-
if (fg && bg) {
1009-
const fgLum = relativeLuminance(...fg);
1010-
const bgLum = relativeLuminance(...bg);
1011-
const ratio = contrastRatio(fgLum, bgLum);
1012-
const isLarge = large_text ?? false;
1013-
const level = wcagLevel(ratio, isLarge);
1014-
const aaThreshold = isLarge ? 3 : 4.5;
1015-
const aaaThreshold = isLarge ? 4.5 : 7;
1016-
output.push(
1017-
"",
1018-
"--- Contrast Check ---",
1019-
"",
1020-
`Foreground: ${toHex(...fg)} Background: ${toHex(...bg)}`,
1021-
`Ratio: ${ratio.toFixed(2)}:1 AA: ${ratio >= aaThreshold ? "PASS" : "FAIL"} AAA: ${ratio >= aaaThreshold ? "PASS" : "FAIL"} Result: ${level}`,
1022-
);
1023-
if (ratio < aaThreshold) {
1024-
const suggested = findNearestPassing(fg, bg, aaThreshold);
1025-
output.push(`Nearest AA-passing foreground: ${suggested}`);
1026-
}
1027-
}
1028-
}
1029-
10301167
return {
10311168
content: [{ type: "text", text: output.join("\n") }],
10321169
};

‎src/tools/search.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,16 +413,18 @@ function formatComparison(
413413
lines.push("");
414414
}
415415

416+
lines.push("--- Regressed ---", "");
416417
if (regressed.length > 0) {
417-
lines.push("--- Regressed ---", "");
418418
for (const m of regressed) {
419419
const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
420420
const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
421421
lines.push(`${keys}`);
422422
lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
423423
}
424-
lines.push("");
424+
} else {
425+
lines.push("(none)");
425426
}
427+
lines.push("");
426428

427429
return lines.join("\n");
428430
}

0 commit comments

Comments
 (0)