pagesight 0.16.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.
Files changed (70) hide show
  1. package/README.md +17 -83
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/snapshots.md +123 -0
  5. package/docs/usage.md +130 -0
  6. package/package.json +26 -29
  7. package/src/api/bing.ts +83 -0
  8. package/src/api/compare-snapshots.ts +377 -0
  9. package/src/api/discover.ts +39 -0
  10. package/src/api/doctor.ts +35 -0
  11. package/src/api/evidence-schema.ts +78 -0
  12. package/src/api/evidence.ts +99 -0
  13. package/src/api/execute.ts +147 -0
  14. package/src/api/index.ts +5 -0
  15. package/src/api/reports.ts +90 -0
  16. package/src/api/schema.ts +207 -0
  17. package/src/api/snapshot.ts +183 -0
  18. package/src/api/ui-findings.ts +68 -0
  19. package/src/cli.ts +182 -0
  20. package/src/http.ts +39 -0
  21. package/src/index.ts +8 -25
  22. package/src/mcp-server.ts +27 -0
  23. package/src/mcp.ts +5 -0
  24. package/src/providers/bing.ts +48 -0
  25. package/src/{lib → providers}/crux.ts +4 -9
  26. package/src/providers/ga.ts +114 -0
  27. package/src/providers/google-tokens.ts +86 -0
  28. package/src/providers/gsc-auth.ts +93 -0
  29. package/src/{lib → providers}/gsc.ts +27 -24
  30. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  31. package/src/shared/dates.ts +32 -0
  32. package/src/shared/http.ts +63 -0
  33. package/src/tools/ai.ts +19 -27
  34. package/src/tools/audit.ts +26 -97
  35. package/src/tools/observe.ts +28 -0
  36. package/src/tools/page/analyze.ts +194 -0
  37. package/src/tools/page/batch.ts +163 -0
  38. package/src/tools/page/contrast.ts +128 -0
  39. package/src/tools/page/links.ts +200 -0
  40. package/src/tools/page/metadata.ts +225 -0
  41. package/src/tools/page/structured-data.ts +288 -0
  42. package/src/tools/page/tool.ts +48 -0
  43. package/src/tools/search/actions.ts +56 -0
  44. package/src/tools/search/analytics.ts +266 -0
  45. package/src/tools/search/coverage.ts +247 -0
  46. package/src/tools/search/gaps.ts +129 -0
  47. package/src/tools/search/inspection.ts +160 -0
  48. package/src/tools/search/result.ts +3 -0
  49. package/src/tools/search/sample.ts +110 -0
  50. package/src/tools/search/schema.ts +62 -0
  51. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  52. package/src/tools/search/sites.ts +86 -0
  53. package/src/tools/search/tool.ts +11 -0
  54. package/src/tools/setup.ts +59 -20
  55. package/src/tools/speed/analyze.ts +191 -0
  56. package/src/tools/speed/batch.ts +265 -0
  57. package/src/tools/speed/crux.ts +176 -0
  58. package/src/tools/speed/pagespeed.ts +273 -0
  59. package/src/tools/speed/schema.ts +39 -0
  60. package/src/tools/speed/tool.ts +11 -0
  61. package/src/web/fetch.ts +31 -0
  62. package/src/web/images.ts +62 -0
  63. package/src/web/page-observation.ts +69 -0
  64. package/src/{lib → web}/robots.ts +20 -12
  65. package/src/web/sitemap-inventory.ts +64 -0
  66. package/src/web/sitemap-parser.ts +59 -0
  67. package/src/lib/auth.ts +0 -187
  68. package/src/tools/page.ts +0 -1241
  69. package/src/tools/search.ts +0 -852
  70. 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
+ }