pagesight 0.12.0 → 0.12.2
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/package.json +1 -1
- package/src/index.ts +8 -18
- package/src/lib/psi.ts +4 -0
- package/src/lib/sitemap.ts +106 -0
- package/src/tools/{robots.ts → ai.ts} +3 -3
- package/src/tools/audit.ts +13 -11
- package/src/tools/{metatags.ts → page.ts} +480 -131
- package/src/tools/search.ts +685 -0
- package/src/tools/{pagespeed.ts → speed.ts} +382 -54
- package/src/tools/crux.ts +0 -296
- package/src/tools/inspect.ts +0 -121
- package/src/tools/links.ts +0 -183
- package/src/tools/performance.ts +0 -325
- package/src/tools/sample-inspect.ts +0 -284
- package/src/tools/sitemaps.ts +0 -112
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
+
// --- Types ---
|
|
5
|
+
|
|
4
6
|
interface MetaTag {
|
|
5
7
|
name?: string;
|
|
6
8
|
property?: string;
|
|
@@ -16,6 +18,43 @@ interface ParsedHead {
|
|
|
16
18
|
jsonLd: unknown[];
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
interface SchemaRule {
|
|
22
|
+
required: string[];
|
|
23
|
+
recommended: string[];
|
|
24
|
+
imageFields: string[];
|
|
25
|
+
nestedRequired?: Record<string, string[]>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ValidationIssue {
|
|
29
|
+
type: string;
|
|
30
|
+
level: "required" | "recommended";
|
|
31
|
+
field: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RedirectHop {
|
|
35
|
+
url: string;
|
|
36
|
+
status: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ImageCheck {
|
|
40
|
+
url: string;
|
|
41
|
+
tag: string;
|
|
42
|
+
status: number | null;
|
|
43
|
+
contentType: string | null;
|
|
44
|
+
contentLength: number | null;
|
|
45
|
+
error: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface LinkResult {
|
|
49
|
+
href: string;
|
|
50
|
+
status: number | null;
|
|
51
|
+
redirectChain: Array<{ url: string; status: number }>;
|
|
52
|
+
finalUrl: string | null;
|
|
53
|
+
error: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Meta tags helpers ---
|
|
57
|
+
|
|
19
58
|
function parseHead(html: string): ParsedHead {
|
|
20
59
|
// Extract <head> content (case-insensitive, handles attributes on <head>)
|
|
21
60
|
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
@@ -136,13 +175,6 @@ function formatJsonLd(data: unknown, indent = 0): string[] {
|
|
|
136
175
|
|
|
137
176
|
// --- Structured data validation (per Google Rich Results docs) ---
|
|
138
177
|
|
|
139
|
-
interface SchemaRule {
|
|
140
|
-
required: string[];
|
|
141
|
-
recommended: string[];
|
|
142
|
-
imageFields: string[];
|
|
143
|
-
nestedRequired?: Record<string, string[]>;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
178
|
// Required/recommended fields sourced from Google's Rich Results documentation
|
|
147
179
|
// https://developers.google.com/search/docs/appearance/structured-data
|
|
148
180
|
const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
@@ -241,12 +273,6 @@ const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
|
241
273
|
},
|
|
242
274
|
};
|
|
243
275
|
|
|
244
|
-
interface ValidationIssue {
|
|
245
|
-
type: string;
|
|
246
|
-
level: "required" | "recommended";
|
|
247
|
-
field: string;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
276
|
function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
|
|
251
277
|
const val = obj[field];
|
|
252
278
|
if (val !== undefined && val !== null && val !== "") return val;
|
|
@@ -403,20 +429,6 @@ function formatValidation(issues: ValidationIssue[], validatedTypes: Set<string>
|
|
|
403
429
|
return lines;
|
|
404
430
|
}
|
|
405
431
|
|
|
406
|
-
interface RedirectHop {
|
|
407
|
-
url: string;
|
|
408
|
-
status: number;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
interface ImageCheck {
|
|
412
|
-
url: string;
|
|
413
|
-
tag: string;
|
|
414
|
-
status: number | null;
|
|
415
|
-
contentType: string | null;
|
|
416
|
-
contentLength: number | null;
|
|
417
|
-
error: string | null;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
432
|
async function followRedirects(
|
|
421
433
|
url: string,
|
|
422
434
|
ua: string,
|
|
@@ -491,6 +503,15 @@ function formatMetatags(url: string, parsed: ParsedHead): string {
|
|
|
491
503
|
const author = getMeta(parsed.meta, "author");
|
|
492
504
|
if (author) lines.push(`Author: ${author}`);
|
|
493
505
|
|
|
506
|
+
// Length warnings
|
|
507
|
+
if (parsed.title && parsed.title.length > 60) {
|
|
508
|
+
lines.push(`WARN: title is ${parsed.title.length} chars — may truncate in search results (recommended: ≤60)`);
|
|
509
|
+
}
|
|
510
|
+
const desc = getMeta(parsed.meta, "description");
|
|
511
|
+
if (desc && desc.length > 155) {
|
|
512
|
+
lines.push(`WARN: description is ${desc.length} chars — may truncate in search results (recommended: ≤155)`);
|
|
513
|
+
}
|
|
514
|
+
|
|
494
515
|
// HTML entity warnings
|
|
495
516
|
const entityCheck = [
|
|
496
517
|
{ label: "title", value: parsed.title },
|
|
@@ -574,130 +595,458 @@ function formatMetatags(url: string, parsed: ParsedHead): string {
|
|
|
574
595
|
return lines.join("\n");
|
|
575
596
|
}
|
|
576
597
|
|
|
577
|
-
|
|
598
|
+
// --- Links helpers ---
|
|
599
|
+
|
|
600
|
+
async function checkLink(href: string): Promise<LinkResult> {
|
|
601
|
+
const chain: Array<{ url: string; status: number }> = [];
|
|
602
|
+
let current = href;
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
for (let i = 0; i < 10; i++) {
|
|
606
|
+
const res = await fetch(current, {
|
|
607
|
+
method: "GET",
|
|
608
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
609
|
+
redirect: "manual",
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
chain.push({ url: current, status: res.status });
|
|
613
|
+
|
|
614
|
+
if (res.status >= 300 && res.status < 400) {
|
|
615
|
+
const location = res.headers.get("location");
|
|
616
|
+
if (!location) break;
|
|
617
|
+
current = new URL(location, current).href;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
href,
|
|
623
|
+
status: res.status,
|
|
624
|
+
redirectChain: chain,
|
|
625
|
+
finalUrl: chain.length > 1 ? current : null,
|
|
626
|
+
error: null,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
href,
|
|
632
|
+
status: chain[chain.length - 1]?.status ?? null,
|
|
633
|
+
redirectChain: chain,
|
|
634
|
+
finalUrl: current,
|
|
635
|
+
error: "Too many redirects",
|
|
636
|
+
};
|
|
637
|
+
} catch (err) {
|
|
638
|
+
return {
|
|
639
|
+
href,
|
|
640
|
+
status: null,
|
|
641
|
+
redirectChain: chain,
|
|
642
|
+
finalUrl: null,
|
|
643
|
+
error: err instanceof Error ? err.message : String(err),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function extractInternalLinks(html: string, origin: string): string[] {
|
|
649
|
+
const links = new Set<string>();
|
|
650
|
+
|
|
651
|
+
for (const match of html.matchAll(/<a\s[^>]*href=["']([^"'#]+)/gi)) {
|
|
652
|
+
const raw = match[1].trim();
|
|
653
|
+
if (!raw || raw.startsWith("javascript:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) continue;
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
const resolved = new URL(raw, origin).href;
|
|
657
|
+
if (resolved.startsWith(origin) && !resolved.includes("/cdn-cgi/")) {
|
|
658
|
+
links.add(resolved);
|
|
659
|
+
}
|
|
660
|
+
} catch {
|
|
661
|
+
// Invalid URL, skip
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return [...links];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function formatLinkResults(url: string, results: LinkResult[]): string {
|
|
669
|
+
const lines: string[] = [`=== Internal Links: ${url} ===`, `Found ${results.length} internal links`, ""];
|
|
670
|
+
|
|
671
|
+
const broken = results.filter((r) => r.error || (r.status && r.status >= 400));
|
|
672
|
+
const redirected = results.filter((r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400);
|
|
673
|
+
const ok = results.filter((r) => !r.error && r.redirectChain.length === 1 && r.status && r.status < 400);
|
|
674
|
+
|
|
675
|
+
// Summary
|
|
676
|
+
lines.push("--- Summary ---", "");
|
|
677
|
+
lines.push(`OK: ${ok.length}`);
|
|
678
|
+
if (redirected.length > 0) lines.push(`Redirected: ${redirected.length}`);
|
|
679
|
+
if (broken.length > 0) lines.push(`Broken: ${broken.length}`);
|
|
680
|
+
lines.push("");
|
|
681
|
+
|
|
682
|
+
// Broken links
|
|
683
|
+
if (broken.length > 0) {
|
|
684
|
+
lines.push("--- Broken Links ---", "");
|
|
685
|
+
for (const r of broken) {
|
|
686
|
+
if (r.error) {
|
|
687
|
+
lines.push(`FAIL ${r.href}`);
|
|
688
|
+
lines.push(` Error: ${r.error}`);
|
|
689
|
+
} else {
|
|
690
|
+
lines.push(`FAIL ${r.href} → ${r.status}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
lines.push("");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Redirect chains
|
|
697
|
+
if (redirected.length > 0) {
|
|
698
|
+
lines.push("--- Redirect Chains ---", "");
|
|
699
|
+
for (const r of redirected) {
|
|
700
|
+
const hops = r.redirectChain.length - 1;
|
|
701
|
+
const chainStr = r.redirectChain.map((h) => `${h.status}`).join(" → ");
|
|
702
|
+
lines.push(`REDIRECT ${r.href}`);
|
|
703
|
+
lines.push(` ${chainStr} → ${r.finalUrl} (${hops} hop${hops > 1 ? "s" : ""})`);
|
|
704
|
+
}
|
|
705
|
+
lines.push("");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return lines.join("\n");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// --- Contrast helpers ---
|
|
712
|
+
|
|
713
|
+
function parseHex(hex: string): [number, number, number] | null {
|
|
714
|
+
const clean = hex.replace(/^#/, "");
|
|
715
|
+
if (clean.length === 3) {
|
|
716
|
+
const r = Number.parseInt(clean[0] + clean[0], 16);
|
|
717
|
+
const g = Number.parseInt(clean[1] + clean[1], 16);
|
|
718
|
+
const b = Number.parseInt(clean[2] + clean[2], 16);
|
|
719
|
+
return [r, g, b];
|
|
720
|
+
}
|
|
721
|
+
if (clean.length === 6) {
|
|
722
|
+
const r = Number.parseInt(clean.slice(0, 2), 16);
|
|
723
|
+
const g = Number.parseInt(clean.slice(2, 4), 16);
|
|
724
|
+
const b = Number.parseInt(clean.slice(4, 6), 16);
|
|
725
|
+
return [r, g, b];
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function toHex(r: number, g: number, b: number): string {
|
|
731
|
+
return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function relativeLuminance(r: number, g: number, b: number): number {
|
|
735
|
+
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
736
|
+
const s = c / 255;
|
|
737
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
738
|
+
});
|
|
739
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function contrastRatio(l1: number, l2: number): number {
|
|
743
|
+
const lighter = Math.max(l1, l2);
|
|
744
|
+
const darker = Math.min(l1, l2);
|
|
745
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function wcagLevel(ratio: number, isLargeText: boolean): string {
|
|
749
|
+
if (isLargeText) {
|
|
750
|
+
if (ratio >= 4.5) return "AAA";
|
|
751
|
+
if (ratio >= 3) return "AA";
|
|
752
|
+
return "FAIL";
|
|
753
|
+
}
|
|
754
|
+
if (ratio >= 7) return "AAA";
|
|
755
|
+
if (ratio >= 4.5) return "AA";
|
|
756
|
+
return "FAIL";
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function findNearestPassing(fg: [number, number, number], bg: [number, number, number], targetRatio: number): string {
|
|
760
|
+
const bgLum = relativeLuminance(...bg);
|
|
761
|
+
|
|
762
|
+
// Try both directions: darken and lighten, pick the one closest to original
|
|
763
|
+
let bestDarken: [number, number, number] | null = null;
|
|
764
|
+
let bestLighten: [number, number, number] | null = null;
|
|
765
|
+
|
|
766
|
+
for (let step = 0; step <= 100; step++) {
|
|
767
|
+
const t = step / 100;
|
|
768
|
+
const dark: [number, number, number] = [fg[0] * (1 - t), fg[1] * (1 - t), fg[2] * (1 - t)];
|
|
769
|
+
if (!bestDarken && contrastRatio(relativeLuminance(...dark), bgLum) >= targetRatio) {
|
|
770
|
+
bestDarken = dark;
|
|
771
|
+
}
|
|
772
|
+
const light: [number, number, number] = [
|
|
773
|
+
fg[0] + (255 - fg[0]) * t,
|
|
774
|
+
fg[1] + (255 - fg[1]) * t,
|
|
775
|
+
fg[2] + (255 - fg[2]) * t,
|
|
776
|
+
];
|
|
777
|
+
if (!bestLighten && contrastRatio(relativeLuminance(...light), bgLum) >= targetRatio) {
|
|
778
|
+
bestLighten = light;
|
|
779
|
+
}
|
|
780
|
+
if (bestDarken && bestLighten) break;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Pick the direction that changes the color least
|
|
784
|
+
if (bestDarken && bestLighten) {
|
|
785
|
+
const darkDist =
|
|
786
|
+
Math.abs(fg[0] - bestDarken[0]) + Math.abs(fg[1] - bestDarken[1]) + Math.abs(fg[2] - bestDarken[2]);
|
|
787
|
+
const lightDist =
|
|
788
|
+
Math.abs(fg[0] - bestLighten[0]) + Math.abs(fg[1] - bestLighten[1]) + Math.abs(fg[2] - bestLighten[2]);
|
|
789
|
+
return toHex(...(darkDist <= lightDist ? bestDarken : bestLighten));
|
|
790
|
+
}
|
|
791
|
+
return toHex(...(bestDarken ?? bestLighten ?? fg));
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// --- Tool registration ---
|
|
795
|
+
|
|
796
|
+
export function registerPageTool(server: McpServer): void {
|
|
578
797
|
server.tool(
|
|
579
|
-
"
|
|
580
|
-
"
|
|
798
|
+
"page",
|
|
799
|
+
"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.",
|
|
581
800
|
{
|
|
582
|
-
url: z
|
|
583
|
-
|
|
801
|
+
url: z
|
|
802
|
+
.string()
|
|
803
|
+
.url()
|
|
804
|
+
.optional()
|
|
805
|
+
.describe("Page URL to analyze. Returns meta tags, structured data, and optionally internal links."),
|
|
806
|
+
check_links: z
|
|
807
|
+
.boolean()
|
|
808
|
+
.optional()
|
|
809
|
+
.describe("Also check all internal links for broken links and redirect chains. Default: false."),
|
|
810
|
+
user_agent: z.string().optional().describe("Custom User-Agent for page fetch. Default: Googlebot-compatible."),
|
|
811
|
+
foreground: z
|
|
812
|
+
.string()
|
|
813
|
+
.optional()
|
|
814
|
+
.describe("Foreground (text) hex color for WCAG contrast check (e.g., '#D4594C'). Use with background."),
|
|
815
|
+
background: z.string().optional().describe("Background hex color for contrast check (e.g., '#FFFFFF')."),
|
|
816
|
+
large_text: z
|
|
817
|
+
.boolean()
|
|
818
|
+
.optional()
|
|
819
|
+
.describe("Whether text is large (≥18pt or ≥14pt bold). Lowers AA threshold to 3:1."),
|
|
584
820
|
},
|
|
585
|
-
async ({ url, user_agent }) => {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
821
|
+
async ({ url, check_links, user_agent, foreground, background, large_text }) => {
|
|
822
|
+
// --- Contrast-only mode (no URL) ---
|
|
823
|
+
if (foreground && background && !url) {
|
|
824
|
+
const fg = parseHex(foreground);
|
|
825
|
+
const bg = parseHex(background);
|
|
826
|
+
|
|
827
|
+
if (!fg) return { content: [{ type: "text", text: `Invalid foreground color: ${foreground}` }] };
|
|
828
|
+
if (!bg) return { content: [{ type: "text", text: `Invalid background color: ${background}` }] };
|
|
829
|
+
|
|
830
|
+
const fgLum = relativeLuminance(...fg);
|
|
831
|
+
const bgLum = relativeLuminance(...bg);
|
|
832
|
+
const ratio = contrastRatio(fgLum, bgLum);
|
|
833
|
+
const isLarge = large_text ?? false;
|
|
834
|
+
const level = wcagLevel(ratio, isLarge);
|
|
835
|
+
|
|
836
|
+
const aaThreshold = isLarge ? 3 : 4.5;
|
|
837
|
+
const aaaThreshold = isLarge ? 4.5 : 7;
|
|
838
|
+
|
|
839
|
+
const lines: string[] = [
|
|
840
|
+
"=== Contrast Check ===",
|
|
841
|
+
"",
|
|
842
|
+
`Foreground: ${toHex(...fg)}`,
|
|
843
|
+
`Background: ${toHex(...bg)}`,
|
|
844
|
+
`Text size: ${isLarge ? "large (≥18pt)" : "normal"}`,
|
|
845
|
+
"",
|
|
846
|
+
`Contrast ratio: ${ratio.toFixed(2)}:1`,
|
|
847
|
+
`WCAG AA (${aaThreshold}:1): ${ratio >= aaThreshold ? "PASS" : "FAIL"}`,
|
|
848
|
+
`WCAG AAA (${aaaThreshold}:1): ${ratio >= aaaThreshold ? "PASS" : "FAIL"}`,
|
|
849
|
+
`Result: ${level}`,
|
|
850
|
+
];
|
|
851
|
+
|
|
852
|
+
if (ratio < aaThreshold) {
|
|
853
|
+
const suggested = findNearestPassing(fg, bg, aaThreshold);
|
|
854
|
+
lines.push("", `Nearest AA-passing foreground: ${suggested}`);
|
|
855
|
+
|
|
856
|
+
const sugParsed = parseHex(suggested);
|
|
857
|
+
if (sugParsed) {
|
|
858
|
+
const sugRatio = contrastRatio(relativeLuminance(...sugParsed), bgLum);
|
|
859
|
+
lines.push(`New ratio: ${sugRatio.toFixed(2)}:1`);
|
|
860
|
+
}
|
|
599
861
|
}
|
|
600
862
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
863
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// --- Page analysis mode ---
|
|
867
|
+
if (url) {
|
|
868
|
+
try {
|
|
869
|
+
const ua = user_agent ?? "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
|
|
870
|
+
const { chain, response: res } = await followRedirects(url, ua);
|
|
871
|
+
|
|
872
|
+
if (!res.ok) {
|
|
873
|
+
return {
|
|
874
|
+
content: [
|
|
875
|
+
{
|
|
876
|
+
type: "text",
|
|
877
|
+
text: `Error fetching ${url}: HTTP ${res.status} ${res.statusText}`,
|
|
878
|
+
},
|
|
879
|
+
],
|
|
880
|
+
};
|
|
612
881
|
}
|
|
613
|
-
// Format: URL → status → URL → status (drop trailing status)
|
|
614
|
-
parts.pop();
|
|
615
|
-
output.push(parts.join(" → "));
|
|
616
|
-
output.push(`Hops: ${chain.length - 1}`);
|
|
617
|
-
output.push("");
|
|
618
|
-
}
|
|
619
882
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
if (imagesToCheck.length > 0) {
|
|
632
|
-
const checks = await Promise.all(imagesToCheck.map((img) => checkImage(img.url, img.tag)));
|
|
633
|
-
output.push("", "--- Image Validation ---", "");
|
|
634
|
-
for (const check of checks) {
|
|
635
|
-
if (check.error) {
|
|
636
|
-
output.push(`${check.tag}: FAILED — ${check.error}`);
|
|
637
|
-
output.push(` URL: ${check.url}`);
|
|
638
|
-
} else if (check.status && check.status >= 400) {
|
|
639
|
-
output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
|
|
640
|
-
output.push(` URL: ${check.url}`);
|
|
641
|
-
} else {
|
|
642
|
-
const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
|
|
643
|
-
const size = sizeKB ? ` (${sizeKB} KB)` : "";
|
|
644
|
-
const type = check.contentType ? ` ${check.contentType}` : "";
|
|
645
|
-
output.push(`${check.tag}: OK —${type}${size}`);
|
|
646
|
-
if (sizeKB && sizeKB > 1024) {
|
|
647
|
-
output.push(` Warning: ${sizeKB} KB is large for social previews — consider compressing below 1 MB`);
|
|
648
|
-
}
|
|
883
|
+
const html = await res.text();
|
|
884
|
+
const parsed = parseHead(html);
|
|
885
|
+
const output: string[] = [];
|
|
886
|
+
|
|
887
|
+
// Redirect chain (only if there were redirects)
|
|
888
|
+
if (chain.length > 1) {
|
|
889
|
+
output.push("--- Redirect Chain ---", "");
|
|
890
|
+
const parts: string[] = [];
|
|
891
|
+
for (const hop of chain) {
|
|
892
|
+
parts.push(hop.url);
|
|
893
|
+
parts.push(String(hop.status));
|
|
649
894
|
}
|
|
895
|
+
// Format: URL → status → URL → status (drop trailing status)
|
|
896
|
+
parts.pop();
|
|
897
|
+
output.push(parts.join(" → "));
|
|
898
|
+
output.push(`Hops: ${chain.length - 1}`);
|
|
899
|
+
output.push("");
|
|
900
|
+
}
|
|
650
901
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
902
|
+
// Main meta tag report
|
|
903
|
+
const finalUrl = chain.length > 1 ? chain[chain.length - 1].url : url;
|
|
904
|
+
output.push(formatMetatags(finalUrl, parsed));
|
|
905
|
+
|
|
906
|
+
// OG/Twitter image validation
|
|
907
|
+
const ogImage = getMeta(parsed.meta, "og:image");
|
|
908
|
+
const twitterImage = getMeta(parsed.meta, "twitter:image");
|
|
909
|
+
const imagesToCheck: Array<{ url: string; tag: string }> = [];
|
|
910
|
+
if (ogImage) imagesToCheck.push({ url: ogImage, tag: "og:image" });
|
|
911
|
+
if (twitterImage && twitterImage !== ogImage) imagesToCheck.push({ url: twitterImage, tag: "twitter:image" });
|
|
912
|
+
|
|
913
|
+
if (imagesToCheck.length > 0) {
|
|
914
|
+
const checks = await Promise.all(imagesToCheck.map((img) => checkImage(img.url, img.tag)));
|
|
915
|
+
output.push("", "--- Image Validation ---", "");
|
|
916
|
+
for (const check of checks) {
|
|
917
|
+
if (check.error) {
|
|
918
|
+
output.push(`${check.tag}: FAILED — ${check.error}`);
|
|
919
|
+
output.push(` URL: ${check.url}`);
|
|
920
|
+
} else if (check.status && check.status >= 400) {
|
|
921
|
+
output.push(`${check.tag}: BROKEN — HTTP ${check.status}`);
|
|
922
|
+
output.push(` URL: ${check.url}`);
|
|
923
|
+
} else {
|
|
924
|
+
const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
|
|
925
|
+
const size = sizeKB ? ` (${sizeKB} KB)` : "";
|
|
926
|
+
const type = check.contentType ? ` ${check.contentType}` : "";
|
|
927
|
+
output.push(`${check.tag}: OK —${type}${size}`);
|
|
928
|
+
if (sizeKB && sizeKB > 1024) {
|
|
929
|
+
output.push(` Warning: ${sizeKB} KB is large for social previews — consider compressing below 1 MB`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Check declared dimensions from meta tags
|
|
934
|
+
if (check.tag === "og:image") {
|
|
935
|
+
const w = getMeta(parsed.meta, "og:image:width");
|
|
936
|
+
const h = getMeta(parsed.meta, "og:image:height");
|
|
937
|
+
if (w && h) {
|
|
938
|
+
output.push(` Declared dimensions: ${w}x${h}`);
|
|
939
|
+
const wn = Number(w);
|
|
940
|
+
const hn = Number(h);
|
|
941
|
+
if (wn && hn && (wn < 1200 || hn < 630)) {
|
|
942
|
+
output.push(" Note: recommended minimum for og:image is 1200x630");
|
|
943
|
+
}
|
|
661
944
|
}
|
|
662
945
|
}
|
|
663
946
|
}
|
|
664
947
|
}
|
|
665
|
-
}
|
|
666
948
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
949
|
+
// Structured data validation
|
|
950
|
+
if (parsed.jsonLd.length > 0) {
|
|
951
|
+
const { issues, imageUrls, validatedTypes } = validateJsonLd(parsed.jsonLd);
|
|
952
|
+
if (validatedTypes.size > 0 || imageUrls.length > 0) {
|
|
953
|
+
output.push("", "--- Structured Data Validation ---", "");
|
|
954
|
+
output.push(...formatValidation(issues, validatedTypes));
|
|
955
|
+
|
|
956
|
+
// HEAD-check image URLs from structured data
|
|
957
|
+
if (imageUrls.length > 0) {
|
|
958
|
+
const deduped = [...new Set(imageUrls)];
|
|
959
|
+
const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
|
|
960
|
+
for (const check of imgChecks) {
|
|
961
|
+
if (check.error) {
|
|
962
|
+
output.push(`BROKEN Image: ${check.url} — ${check.error}`);
|
|
963
|
+
} else if (check.status && check.status >= 400) {
|
|
964
|
+
output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
|
|
965
|
+
} else {
|
|
966
|
+
const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
|
|
967
|
+
output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
|
|
968
|
+
}
|
|
686
969
|
}
|
|
687
970
|
}
|
|
688
971
|
}
|
|
689
972
|
}
|
|
690
|
-
}
|
|
691
973
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
974
|
+
// --- Link check (appended after metatags output) ---
|
|
975
|
+
if (check_links) {
|
|
976
|
+
const origin = new URL(url).origin;
|
|
977
|
+
const links = extractInternalLinks(html, origin);
|
|
978
|
+
|
|
979
|
+
if (links.length === 0) {
|
|
980
|
+
output.push(
|
|
981
|
+
"",
|
|
982
|
+
"--- Internal Links ---",
|
|
983
|
+
"",
|
|
984
|
+
"No internal links found.",
|
|
985
|
+
"Note: this page may use client-side rendering (SPA). Only static <a href> links in the HTML are detected.",
|
|
986
|
+
);
|
|
987
|
+
} else {
|
|
988
|
+
// Check links with concurrency 5
|
|
989
|
+
const concurrency = 5;
|
|
990
|
+
const results: LinkResult[] = [];
|
|
991
|
+
let i = 0;
|
|
992
|
+
|
|
993
|
+
while (i < links.length) {
|
|
994
|
+
const batch = links.slice(i, i + concurrency);
|
|
995
|
+
const settled = await Promise.all(batch.map((href) => checkLink(href)));
|
|
996
|
+
results.push(...settled);
|
|
997
|
+
i += concurrency;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
output.push("", formatLinkResults(url, results));
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
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
|
+
|
|
1030
|
+
return {
|
|
1031
|
+
content: [{ type: "text", text: output.join("\n") }],
|
|
1032
|
+
};
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1035
|
+
return {
|
|
1036
|
+
content: [{ type: "text", text: `Error analyzing page: ${msg}` }],
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
700
1039
|
}
|
|
1040
|
+
|
|
1041
|
+
// --- No valid input ---
|
|
1042
|
+
return {
|
|
1043
|
+
content: [
|
|
1044
|
+
{
|
|
1045
|
+
type: "text",
|
|
1046
|
+
text: "Provide 'url' for page analysis or 'foreground' + 'background' for contrast check.",
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
};
|
|
701
1050
|
},
|
|
702
1051
|
);
|
|
703
1052
|
}
|