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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { formatJsonLd } from "./structured-data.js";
|
|
2
|
+
interface MetaTag {
|
|
3
|
+
name?: string;
|
|
4
|
+
property?: string;
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ParsedHead {
|
|
9
|
+
title: string | null;
|
|
10
|
+
charset: string | null;
|
|
11
|
+
canonical: string | null;
|
|
12
|
+
meta: MetaTag[];
|
|
13
|
+
hreflang: Array<{ lang: string; href: string }>;
|
|
14
|
+
jsonLd: unknown[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseHead(html: string): ParsedHead {
|
|
18
|
+
// Extract <head> content (case-insensitive, handles attributes on <head>)
|
|
19
|
+
const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
20
|
+
const head = headMatch?.[1] ?? html;
|
|
21
|
+
|
|
22
|
+
// Title
|
|
23
|
+
const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
24
|
+
const title = titleMatch ? titleMatch[1].trim() : null;
|
|
25
|
+
|
|
26
|
+
// Charset
|
|
27
|
+
const charsetMatch = head.match(/<meta\s+charset=["']([^"']+)["']/i) ?? head.match(/charset=["']?([^"'\s;>]+)/i);
|
|
28
|
+
const charset = charsetMatch ? charsetMatch[1] : null;
|
|
29
|
+
|
|
30
|
+
// Canonical
|
|
31
|
+
const canonicalMatch = head.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i);
|
|
32
|
+
const canonical = canonicalMatch ? canonicalMatch[1] : null;
|
|
33
|
+
|
|
34
|
+
// Meta tags (name=... content=... and property=... content=...)
|
|
35
|
+
const meta: MetaTag[] = [];
|
|
36
|
+
for (const m of head.matchAll(/<meta\s+([^>]*?)>/gi)) {
|
|
37
|
+
const attrs = m[1];
|
|
38
|
+
const nameMatch = attrs.match(/name=["']([^"']+)["']/i);
|
|
39
|
+
const propMatch = attrs.match(/property=["']([^"']+)["']/i);
|
|
40
|
+
const contentMatch = attrs.match(/content=["']([^"']*?)["']/i);
|
|
41
|
+
if (contentMatch && (nameMatch || propMatch)) {
|
|
42
|
+
meta.push({
|
|
43
|
+
name: nameMatch?.[1],
|
|
44
|
+
property: propMatch?.[1],
|
|
45
|
+
content: contentMatch[1],
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Hreflang
|
|
51
|
+
const hreflang: Array<{ lang: string; href: string }> = [];
|
|
52
|
+
const hreflangRegex =
|
|
53
|
+
/<link[^>]*rel=["']alternate["'][^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*>/gi;
|
|
54
|
+
const hreflangRegex2 =
|
|
55
|
+
/<link[^>]*hreflang=["']([^"']+)["'][^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*>/gi;
|
|
56
|
+
const hreflangRegex3 =
|
|
57
|
+
/<link[^>]*href=["']([^"']+)["'][^>]*hreflang=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*>/gi;
|
|
58
|
+
for (const rx of [hreflangRegex, hreflangRegex2]) {
|
|
59
|
+
for (const hm of head.matchAll(rx)) {
|
|
60
|
+
hreflang.push({ lang: hm[1], href: hm[2] });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const hm of head.matchAll(hreflangRegex3)) {
|
|
64
|
+
hreflang.push({ lang: hm[2], href: hm[1] });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Dedup hreflang entries
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const dedupedHreflang = hreflang.filter((h) => {
|
|
70
|
+
const key = `${h.lang}:${h.href}`;
|
|
71
|
+
if (seen.has(key)) return false;
|
|
72
|
+
seen.add(key);
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// JSON-LD
|
|
77
|
+
const jsonLd: unknown[] = [];
|
|
78
|
+
for (const ld of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
79
|
+
try {
|
|
80
|
+
jsonLd.push(JSON.parse(ld[1]));
|
|
81
|
+
} catch {
|
|
82
|
+
// malformed JSON-LD, skip
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { title, charset, canonical, meta, hreflang: dedupedHreflang, jsonLd };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getMeta(meta: MetaTag[], key: string): string | null {
|
|
90
|
+
const tag = meta.find(
|
|
91
|
+
(m) => m.name?.toLowerCase() === key.toLowerCase() || m.property?.toLowerCase() === key.toLowerCase(),
|
|
92
|
+
);
|
|
93
|
+
return tag?.content ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getAllMeta(meta: MetaTag[], prefix: string): Array<{ key: string; value: string }> {
|
|
97
|
+
const results: Array<{ key: string; value: string }> = [];
|
|
98
|
+
for (const tag of meta) {
|
|
99
|
+
const key = tag.property ?? tag.name ?? "";
|
|
100
|
+
if (key.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
101
|
+
results.push({ key, value: tag.content });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatMetatags(url: string, parsed: ParsedHead): string {
|
|
108
|
+
const lines: string[] = [`=== Meta Tags: ${url} ===`, ""];
|
|
109
|
+
|
|
110
|
+
// Document
|
|
111
|
+
lines.push("--- Document ---", "");
|
|
112
|
+
lines.push(`Title: ${parsed.title ? `"${parsed.title}"` : "(missing)"}`);
|
|
113
|
+
lines.push(
|
|
114
|
+
`Description: ${getMeta(parsed.meta, "description") ? `"${getMeta(parsed.meta, "description")}"` : "(missing)"}`,
|
|
115
|
+
);
|
|
116
|
+
lines.push(`Charset: ${parsed.charset ?? "(missing)"}`);
|
|
117
|
+
lines.push(`Viewport: ${getMeta(parsed.meta, "viewport") ?? "(missing)"}`);
|
|
118
|
+
lines.push(`Canonical: ${parsed.canonical ?? "(not set)"}`);
|
|
119
|
+
const robots = getMeta(parsed.meta, "robots");
|
|
120
|
+
if (robots) lines.push(`Robots: ${robots}`);
|
|
121
|
+
const author = getMeta(parsed.meta, "author");
|
|
122
|
+
if (author) lines.push(`Author: ${author}`);
|
|
123
|
+
|
|
124
|
+
// Length warnings
|
|
125
|
+
if (parsed.title && parsed.title.length > 60) {
|
|
126
|
+
lines.push(`WARN: title is ${parsed.title.length} chars — may truncate in search results (recommended: ≤60)`);
|
|
127
|
+
}
|
|
128
|
+
const desc = getMeta(parsed.meta, "description");
|
|
129
|
+
if (desc && desc.length > 155) {
|
|
130
|
+
lines.push(`WARN: description is ${desc.length} chars — may truncate in search results (recommended: ≤155)`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// HTML entity warnings
|
|
134
|
+
const entityCheck = [
|
|
135
|
+
{ label: "title", value: parsed.title },
|
|
136
|
+
{ label: "description", value: getMeta(parsed.meta, "description") },
|
|
137
|
+
{ label: "og:title", value: getMeta(parsed.meta, "og:title") },
|
|
138
|
+
{ label: "og:description", value: getMeta(parsed.meta, "og:description") },
|
|
139
|
+
];
|
|
140
|
+
for (const { label, value } of entityCheck) {
|
|
141
|
+
if (value && /&(?:amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/i.test(value)) {
|
|
142
|
+
lines.push(`WARN: ${label} contains HTML entities — may render incorrectly in social previews`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
lines.push("");
|
|
147
|
+
|
|
148
|
+
// Open Graph
|
|
149
|
+
const ogTags = getAllMeta(parsed.meta, "og:");
|
|
150
|
+
lines.push("--- Open Graph ---", "");
|
|
151
|
+
if (ogTags.length > 0) {
|
|
152
|
+
for (const tag of ogTags) {
|
|
153
|
+
lines.push(`${tag.key}: ${tag.value}`);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
lines.push("(none found)");
|
|
157
|
+
}
|
|
158
|
+
lines.push("");
|
|
159
|
+
|
|
160
|
+
// Twitter Card
|
|
161
|
+
const twitterTags = getAllMeta(parsed.meta, "twitter:");
|
|
162
|
+
lines.push("--- Twitter Card ---", "");
|
|
163
|
+
if (twitterTags.length > 0) {
|
|
164
|
+
for (const tag of twitterTags) {
|
|
165
|
+
lines.push(`${tag.key}: ${tag.value}`);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
lines.push("(none found)");
|
|
169
|
+
}
|
|
170
|
+
lines.push("");
|
|
171
|
+
|
|
172
|
+
// Structured Data
|
|
173
|
+
lines.push("--- Structured Data (JSON-LD) ---", "");
|
|
174
|
+
if (parsed.jsonLd.length > 0) {
|
|
175
|
+
for (let i = 0; i < parsed.jsonLd.length; i++) {
|
|
176
|
+
if (i > 0) lines.push("");
|
|
177
|
+
lines.push(`Block ${i + 1}:`);
|
|
178
|
+
lines.push(...formatJsonLd(parsed.jsonLd[i], 1));
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
lines.push("(none found)");
|
|
182
|
+
}
|
|
183
|
+
lines.push("");
|
|
184
|
+
|
|
185
|
+
// Hreflang
|
|
186
|
+
if (parsed.hreflang.length > 0) {
|
|
187
|
+
lines.push("--- Alternate Languages ---", "");
|
|
188
|
+
for (const h of parsed.hreflang) {
|
|
189
|
+
lines.push(`${h.lang}: ${h.href}`);
|
|
190
|
+
}
|
|
191
|
+
lines.push("");
|
|
192
|
+
} else {
|
|
193
|
+
// Warn if URL suggests locale-specific content but no hreflang
|
|
194
|
+
const localePattern =
|
|
195
|
+
/\/(?: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;
|
|
196
|
+
const checkUrl = parsed.canonical ?? url;
|
|
197
|
+
if (localePattern.test(checkUrl)) {
|
|
198
|
+
lines.push(
|
|
199
|
+
"WARN: URL appears locale-specific but no hreflang tags found — search engines may not discover alternate language versions",
|
|
200
|
+
);
|
|
201
|
+
lines.push("");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Summary of missing tags relevant to search and social
|
|
206
|
+
const missing: string[] = [];
|
|
207
|
+
if (!parsed.title) missing.push("title");
|
|
208
|
+
if (!getMeta(parsed.meta, "description")) missing.push("meta description");
|
|
209
|
+
if (!parsed.canonical) missing.push("canonical URL");
|
|
210
|
+
if (ogTags.length === 0) missing.push("Open Graph tags");
|
|
211
|
+
else {
|
|
212
|
+
if (!getMeta(parsed.meta, "og:image")) missing.push("og:image");
|
|
213
|
+
}
|
|
214
|
+
if (twitterTags.length === 0) missing.push("Twitter Card tags");
|
|
215
|
+
if (parsed.jsonLd.length === 0) missing.push("structured data (JSON-LD)");
|
|
216
|
+
|
|
217
|
+
if (missing.length > 0) {
|
|
218
|
+
lines.push("--- Not Found ---", "");
|
|
219
|
+
for (const m of missing) {
|
|
220
|
+
lines.push(`- ${m}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|