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,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
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
interface SchemaRule {
|
|
2
|
+
source: string;
|
|
3
|
+
required: string[];
|
|
4
|
+
requiredAny?: string[];
|
|
5
|
+
recommended: string[];
|
|
6
|
+
imageFields: string[];
|
|
7
|
+
nestedRequired?: Record<string, string[]>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ValidationIssue {
|
|
11
|
+
type: string;
|
|
12
|
+
level: "required" | "recommended";
|
|
13
|
+
field: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatJsonLdType(value: unknown, separator = ", "): string {
|
|
17
|
+
if (typeof value === "string") return value;
|
|
18
|
+
if (Array.isArray(value) && value.every((item): item is string => typeof item === "string"))
|
|
19
|
+
return value.join(separator);
|
|
20
|
+
return JSON.stringify(value) ?? "undefined";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatJsonLd(data: unknown, indent = 0): string[] {
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
const pad = " ".repeat(indent);
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(data)) {
|
|
28
|
+
for (const item of data) {
|
|
29
|
+
lines.push(...formatJsonLd(item, indent));
|
|
30
|
+
}
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (data && typeof data === "object") {
|
|
35
|
+
const obj = data as Record<string, unknown>;
|
|
36
|
+
const type = obj["@type"];
|
|
37
|
+
if (type) {
|
|
38
|
+
const label = formatJsonLdType(type);
|
|
39
|
+
lines.push(`${pad}@type: ${label}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
43
|
+
if (key.startsWith("@") && key !== "@type") continue;
|
|
44
|
+
if (key === "@type") continue;
|
|
45
|
+
if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
|
|
46
|
+
const str = String(val);
|
|
47
|
+
lines.push(`${pad}${key}: ${str.length > 100 ? `${str.slice(0, 100)}...` : str}`);
|
|
48
|
+
} else if (Array.isArray(val)) {
|
|
49
|
+
lines.push(`${pad}${key}: [${val.length} items]`);
|
|
50
|
+
} else if (val && typeof val === "object") {
|
|
51
|
+
const nested = val as Record<string, unknown>;
|
|
52
|
+
if (nested["@type"]) {
|
|
53
|
+
lines.push(`${pad}${key}:`);
|
|
54
|
+
lines.push(...formatJsonLd(nested, indent + 1));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Presence checks for selected Google-documented fields, reviewed 2026-09-08.
|
|
64
|
+
// These do not cover every conditional requirement, value rule, or eligibility policy.
|
|
65
|
+
const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
66
|
+
WebSite: {
|
|
67
|
+
source: "https://developers.google.com/search/docs/appearance/site-names",
|
|
68
|
+
required: ["name", "url"],
|
|
69
|
+
recommended: ["alternateName"],
|
|
70
|
+
imageFields: [],
|
|
71
|
+
},
|
|
72
|
+
Organization: {
|
|
73
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/organization",
|
|
74
|
+
required: [],
|
|
75
|
+
recommended: ["name", "url", "logo", "sameAs", "contactPoint"],
|
|
76
|
+
imageFields: ["logo"],
|
|
77
|
+
},
|
|
78
|
+
Article: {
|
|
79
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/article",
|
|
80
|
+
required: [],
|
|
81
|
+
recommended: ["headline", "image", "datePublished", "author", "dateModified"],
|
|
82
|
+
imageFields: ["image"],
|
|
83
|
+
},
|
|
84
|
+
NewsArticle: {
|
|
85
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/article",
|
|
86
|
+
required: [],
|
|
87
|
+
recommended: ["headline", "image", "datePublished", "author", "dateModified"],
|
|
88
|
+
imageFields: ["image"],
|
|
89
|
+
},
|
|
90
|
+
Product: {
|
|
91
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/product-snippet",
|
|
92
|
+
required: ["name"],
|
|
93
|
+
requiredAny: ["offers", "review", "aggregateRating"],
|
|
94
|
+
recommended: ["offers", "review", "aggregateRating"],
|
|
95
|
+
imageFields: ["image"],
|
|
96
|
+
},
|
|
97
|
+
LocalBusiness: {
|
|
98
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/local-business",
|
|
99
|
+
required: ["name", "address"],
|
|
100
|
+
recommended: ["telephone", "openingHoursSpecification", "url"],
|
|
101
|
+
imageFields: ["image"],
|
|
102
|
+
},
|
|
103
|
+
BreadcrumbList: {
|
|
104
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/breadcrumb",
|
|
105
|
+
required: ["itemListElement"],
|
|
106
|
+
recommended: [],
|
|
107
|
+
imageFields: [],
|
|
108
|
+
},
|
|
109
|
+
Event: {
|
|
110
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/event",
|
|
111
|
+
required: ["name", "startDate", "location"],
|
|
112
|
+
recommended: ["endDate", "image", "description", "offers", "organizer"],
|
|
113
|
+
imageFields: ["image"],
|
|
114
|
+
},
|
|
115
|
+
Recipe: {
|
|
116
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/recipe",
|
|
117
|
+
required: ["name", "image"],
|
|
118
|
+
recommended: ["author", "datePublished", "description", "recipeIngredient", "recipeInstructions"],
|
|
119
|
+
imageFields: ["image"],
|
|
120
|
+
},
|
|
121
|
+
VideoObject: {
|
|
122
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/video",
|
|
123
|
+
required: ["name", "thumbnailUrl", "uploadDate"],
|
|
124
|
+
recommended: ["description", "duration", "contentUrl", "embedUrl"],
|
|
125
|
+
imageFields: ["thumbnailUrl"],
|
|
126
|
+
},
|
|
127
|
+
SoftwareApplication: {
|
|
128
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/software-app",
|
|
129
|
+
required: ["name", "offers"],
|
|
130
|
+
requiredAny: ["review", "aggregateRating"],
|
|
131
|
+
recommended: ["applicationCategory", "operatingSystem", "review", "aggregateRating"],
|
|
132
|
+
imageFields: ["image"],
|
|
133
|
+
nestedRequired: { offers: ["price"] },
|
|
134
|
+
},
|
|
135
|
+
Dataset: {
|
|
136
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/dataset",
|
|
137
|
+
required: ["name", "description"],
|
|
138
|
+
recommended: ["distribution", "creator", "license"],
|
|
139
|
+
imageFields: [],
|
|
140
|
+
nestedRequired: { distribution: ["contentUrl"] },
|
|
141
|
+
},
|
|
142
|
+
BlogPosting: {
|
|
143
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/article",
|
|
144
|
+
required: [],
|
|
145
|
+
recommended: ["headline", "image", "datePublished", "author", "dateModified"],
|
|
146
|
+
imageFields: ["image"],
|
|
147
|
+
},
|
|
148
|
+
Course: {
|
|
149
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/course",
|
|
150
|
+
required: ["name", "description"],
|
|
151
|
+
recommended: ["provider"],
|
|
152
|
+
imageFields: [],
|
|
153
|
+
},
|
|
154
|
+
JobPosting: {
|
|
155
|
+
source: "https://developers.google.com/search/docs/appearance/structured-data/job-posting",
|
|
156
|
+
required: ["title", "description", "datePosted", "hiringOrganization"],
|
|
157
|
+
recommended: ["employmentType", "baseSalary"],
|
|
158
|
+
imageFields: [],
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
|
|
163
|
+
const val = obj[field];
|
|
164
|
+
if (val !== undefined && val !== null && val !== "" && !(Array.isArray(val) && val.length === 0)) return val;
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function validateJsonLd(blocks: unknown[]): {
|
|
169
|
+
issues: ValidationIssue[];
|
|
170
|
+
imageUrls: string[];
|
|
171
|
+
validatedTypes: Set<string>;
|
|
172
|
+
} {
|
|
173
|
+
const issues: ValidationIssue[] = [];
|
|
174
|
+
const imageUrls: string[] = [];
|
|
175
|
+
const validatedTypes = new Set<string>();
|
|
176
|
+
|
|
177
|
+
function validateBlock(data: unknown) {
|
|
178
|
+
if (Array.isArray(data)) {
|
|
179
|
+
for (const item of data) validateBlock(item);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!data || typeof data !== "object") return;
|
|
183
|
+
|
|
184
|
+
const obj = data as Record<string, unknown>;
|
|
185
|
+
const rawType = obj["@type"];
|
|
186
|
+
const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
|
|
187
|
+
|
|
188
|
+
for (const type of types) {
|
|
189
|
+
const rule = SCHEMA_RULES[String(type)];
|
|
190
|
+
if (!rule) continue;
|
|
191
|
+
validatedTypes.add(String(type));
|
|
192
|
+
|
|
193
|
+
for (const field of rule.required) {
|
|
194
|
+
if (getNestedValue(obj, field) === undefined) {
|
|
195
|
+
issues.push({ type: String(type), level: "required", field });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (rule.requiredAny && !rule.requiredAny.some((field) => getNestedValue(obj, field) !== undefined)) {
|
|
200
|
+
issues.push({ type: String(type), level: "required", field: rule.requiredAny.join(" or ") });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const field of rule.recommended) {
|
|
204
|
+
if (getNestedValue(obj, field) === undefined) {
|
|
205
|
+
issues.push({ type: String(type), level: "recommended", field });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check each nested object; one complete entry cannot hide an incomplete sibling.
|
|
210
|
+
if (rule.nestedRequired) {
|
|
211
|
+
for (const [parent, fields] of Object.entries(rule.nestedRequired)) {
|
|
212
|
+
const parentVal = obj[parent];
|
|
213
|
+
if (parentVal && typeof parentVal === "object") {
|
|
214
|
+
const targets = Array.isArray(parentVal) ? parentVal : [parentVal];
|
|
215
|
+
for (const target of targets) {
|
|
216
|
+
if (target && typeof target === "object") {
|
|
217
|
+
const nested = target as Record<string, unknown>;
|
|
218
|
+
for (const field of fields) {
|
|
219
|
+
if (nested[field] === undefined || nested[field] === null || nested[field] === "") {
|
|
220
|
+
issues.push({ type: String(type), level: "required", field: `${parent}.${field}` });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Collect image URLs for validation
|
|
230
|
+
for (const field of rule.imageFields) {
|
|
231
|
+
const val = obj[field];
|
|
232
|
+
if (typeof val === "string" && val.startsWith("http")) {
|
|
233
|
+
imageUrls.push(val);
|
|
234
|
+
} else if (val && typeof val === "object") {
|
|
235
|
+
const nested = val as Record<string, unknown>;
|
|
236
|
+
const url = nested.url ?? nested.contentUrl;
|
|
237
|
+
if (typeof url === "string" && url.startsWith("http")) {
|
|
238
|
+
imageUrls.push(url);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Recurse into nested objects and arrays (e.g., @graph)
|
|
245
|
+
for (const val of Object.values(obj)) {
|
|
246
|
+
if (Array.isArray(val)) {
|
|
247
|
+
for (const item of val) validateBlock(item);
|
|
248
|
+
} else if (val && typeof val === "object") {
|
|
249
|
+
validateBlock(val);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const block of blocks) validateBlock(block);
|
|
255
|
+
return { issues, imageUrls, validatedTypes };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function formatValidation(issues: ValidationIssue[], validatedTypes: Set<string>): string[] {
|
|
259
|
+
const lines: string[] = [
|
|
260
|
+
"Selected field-presence checks only; values, conditional rules, and Google eligibility are not fully checked.",
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
// Report only the subset checked here, not complete rich-result eligibility.
|
|
264
|
+
const typesWithRequiredIssues = new Set(issues.filter((i) => i.level === "required").map((i) => i.type));
|
|
265
|
+
for (const type of validatedTypes) {
|
|
266
|
+
if (!typesWithRequiredIssues.has(type)) {
|
|
267
|
+
lines.push(`CHECKED ${type} — no missing required fields in the checked subset`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const type of validatedTypes) lines.push(`Source (${type}): ${SCHEMA_RULES[type].source}`);
|
|
272
|
+
|
|
273
|
+
const required = issues.filter((i) => i.level === "required");
|
|
274
|
+
const recommended = issues.filter((i) => i.level === "recommended");
|
|
275
|
+
|
|
276
|
+
if (required.length > 0) {
|
|
277
|
+
for (const i of required) {
|
|
278
|
+
lines.push(`MISSING ${i.type}.${i.field} (required in checked subset)`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (recommended.length > 0) {
|
|
282
|
+
for (const i of recommended) {
|
|
283
|
+
lines.push(`OPTIONAL ${i.type}.${i.field}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return lines;
|
|
288
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { analyzePage } from "./analyze.js";
|
|
4
|
+
import { analyzeBatch } from "./batch.js";
|
|
5
|
+
import { analyzeContrast } from "./contrast.js";
|
|
6
|
+
|
|
7
|
+
export function registerPageTool(server: McpServer): void {
|
|
8
|
+
server.tool(
|
|
9
|
+
"page",
|
|
10
|
+
"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.",
|
|
11
|
+
{
|
|
12
|
+
url: z.string().url().optional().describe("Single URL to analyze. Use this OR urls, not both."),
|
|
13
|
+
urls: z
|
|
14
|
+
.array(z.string().url())
|
|
15
|
+
.min(2)
|
|
16
|
+
.max(10)
|
|
17
|
+
.optional()
|
|
18
|
+
.describe(
|
|
19
|
+
"Multiple URLs (2-10) for batch analysis. Returns a summary table of meta tags, structured data, and link health per page.",
|
|
20
|
+
),
|
|
21
|
+
check_links: z
|
|
22
|
+
.boolean()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Check internal links for broken links and redirect chains. Default: true. Set false to skip."),
|
|
25
|
+
user_agent: z.string().optional().describe("Custom User-Agent for page fetch. Default: Googlebot-compatible."),
|
|
26
|
+
foreground: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Foreground (text) hex color for WCAG contrast check (e.g., '#D4594C'). Use with background."),
|
|
30
|
+
background: z.string().optional().describe("Background hex color for contrast check (e.g., '#FFFFFF')."),
|
|
31
|
+
large_text: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Whether text is large (≥18pt or ≥14pt bold). Lowers AA threshold to 3:1."),
|
|
35
|
+
},
|
|
36
|
+
async (options) => {
|
|
37
|
+
const { url, urls, foreground, background, large_text } = options;
|
|
38
|
+
if (urls) return analyzeBatch(urls, options);
|
|
39
|
+
if (foreground && background && !url) return analyzeContrast(foreground, background, large_text);
|
|
40
|
+
if (url) return analyzePage(url, options);
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{ type: "text", text: "Provide 'url' for page analysis or 'foreground' + 'background' for contrast check." },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type SearchOptions } from "./schema.js";
|
|
2
|
+
import { runInspect } from "./inspection.js";
|
|
3
|
+
import { runCoverage } from "./coverage.js";
|
|
4
|
+
import { runListSites, runGetSite, runGetSitemap, runSitemaps } from "./sites.js";
|
|
5
|
+
import { runAnalytics } from "./analytics.js";
|
|
6
|
+
import { runGaps } from "./gaps.js";
|
|
7
|
+
import { runSample } from "./sample.js";
|
|
8
|
+
import { textResult } from "./result.js";
|
|
9
|
+
export async function runSearch(params: SearchOptions) {
|
|
10
|
+
const { site_url, url, sitemap_url, sample_size, filter, start_date, end_date, dimensions, filters, compare } =
|
|
11
|
+
params;
|
|
12
|
+
// ── Route to action ──
|
|
13
|
+
const hasAnalyticsParams = !!(start_date || end_date || dimensions || filters || compare);
|
|
14
|
+
|
|
15
|
+
const resolvedAction =
|
|
16
|
+
params.action ??
|
|
17
|
+
(url && site_url && !hasAnalyticsParams
|
|
18
|
+
? "inspect"
|
|
19
|
+
: sample_size || filter
|
|
20
|
+
? "sample"
|
|
21
|
+
: hasAnalyticsParams
|
|
22
|
+
? "analytics"
|
|
23
|
+
: sitemap_url && site_url
|
|
24
|
+
? "get_sitemap"
|
|
25
|
+
: site_url
|
|
26
|
+
? "sitemaps"
|
|
27
|
+
: "list_sites");
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
switch (resolvedAction) {
|
|
31
|
+
case "inspect":
|
|
32
|
+
return await runInspect(params);
|
|
33
|
+
case "sample":
|
|
34
|
+
return await runSample(params);
|
|
35
|
+
case "coverage":
|
|
36
|
+
return await runCoverage(params);
|
|
37
|
+
case "list_sites":
|
|
38
|
+
return await runListSites();
|
|
39
|
+
case "get_site":
|
|
40
|
+
return await runGetSite(params);
|
|
41
|
+
case "get_sitemap":
|
|
42
|
+
return await runGetSitemap(params);
|
|
43
|
+
case "sitemaps":
|
|
44
|
+
return await runSitemaps(params);
|
|
45
|
+
case "analytics":
|
|
46
|
+
return await runAnalytics(params);
|
|
47
|
+
case "gaps":
|
|
48
|
+
return await runGaps(params);
|
|
49
|
+
}
|
|
50
|
+
const unhandled: never = resolvedAction;
|
|
51
|
+
return textResult(`Error: Unknown action "${String(unhandled)}".`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
return textResult(`Error: ${msg}`);
|
|
55
|
+
}
|
|
56
|
+
}
|