pagesight 0.6.0 → 0.8.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/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/tools/audit.ts +330 -0
- package/src/tools/metatags.ts +187 -0
- package/src/tools/performance.ts +1 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerAuditTool } from "./tools/audit.js";
|
|
4
5
|
import { registerCruxTool } from "./tools/crux.js";
|
|
5
6
|
import { registerInspectTool } from "./tools/inspect.js";
|
|
6
7
|
import { registerMetatagsTool } from "./tools/metatags.js";
|
|
@@ -18,6 +19,7 @@ const server = new McpServer({
|
|
|
18
19
|
version: pkg.version,
|
|
19
20
|
});
|
|
20
21
|
|
|
22
|
+
registerAuditTool(server);
|
|
21
23
|
registerCruxTool(server);
|
|
22
24
|
registerInspectTool(server);
|
|
23
25
|
registerMetatagsTool(server);
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { inspectUrl, listSitemaps } from "../lib/gsc.js";
|
|
4
|
+
import { type PsiCategoryType, type PsiResult, runPagespeed } from "../lib/psi.js";
|
|
5
|
+
import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../lib/robots.js";
|
|
6
|
+
|
|
7
|
+
type Severity = "HIGH" | "MEDIUM" | "LOW";
|
|
8
|
+
|
|
9
|
+
interface Finding {
|
|
10
|
+
severity: Severity;
|
|
11
|
+
message: string;
|
|
12
|
+
source: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// --- Metatags (inline, no external dep needed) ---
|
|
16
|
+
|
|
17
|
+
interface MetaCheckResult {
|
|
18
|
+
title: string | null;
|
|
19
|
+
description: string | null;
|
|
20
|
+
canonical: string | null;
|
|
21
|
+
ogImage: string | null;
|
|
22
|
+
ogTitle: string | null;
|
|
23
|
+
twitterCard: string | null;
|
|
24
|
+
hasJsonLd: boolean;
|
|
25
|
+
redirectCount: number;
|
|
26
|
+
finalUrl: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function checkMeta(url: string): Promise<MetaCheckResult> {
|
|
30
|
+
let current = url;
|
|
31
|
+
let redirectCount = 0;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < 10; i++) {
|
|
34
|
+
const res = await fetch(current, {
|
|
35
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
36
|
+
redirect: "manual",
|
|
37
|
+
});
|
|
38
|
+
if (res.status >= 300 && res.status < 400) {
|
|
39
|
+
const location = res.headers.get("location");
|
|
40
|
+
if (!location) break;
|
|
41
|
+
current = new URL(location, current).href;
|
|
42
|
+
redirectCount++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const html = await res.text();
|
|
47
|
+
const head = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i)?.[1] ?? html;
|
|
48
|
+
|
|
49
|
+
const getMeta = (key: string) => {
|
|
50
|
+
const nameMatch = head.match(new RegExp(`<meta[^>]*name=["']${key}["'][^>]*content=["']([^"']*?)["']`, "i"));
|
|
51
|
+
const propMatch = head.match(new RegExp(`<meta[^>]*property=["']${key}["'][^>]*content=["']([^"']*?)["']`, "i"));
|
|
52
|
+
return nameMatch?.[1] ?? propMatch?.[1] ?? null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
title: head.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? null,
|
|
57
|
+
description: getMeta("description"),
|
|
58
|
+
canonical: head.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i)?.[1] ?? null,
|
|
59
|
+
ogImage: getMeta("og:image"),
|
|
60
|
+
ogTitle: getMeta("og:title"),
|
|
61
|
+
twitterCard: getMeta("twitter:card"),
|
|
62
|
+
hasJsonLd: /<script[^>]*type=["']application\/ld\+json["']/i.test(html),
|
|
63
|
+
redirectCount,
|
|
64
|
+
finalUrl: current,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Exhausted redirects
|
|
69
|
+
return {
|
|
70
|
+
title: null,
|
|
71
|
+
description: null,
|
|
72
|
+
canonical: null,
|
|
73
|
+
ogImage: null,
|
|
74
|
+
ogTitle: null,
|
|
75
|
+
twitterCard: null,
|
|
76
|
+
hasJsonLd: false,
|
|
77
|
+
redirectCount,
|
|
78
|
+
finalUrl: current,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Audit logic ---
|
|
83
|
+
|
|
84
|
+
function addMetaFindings(meta: MetaCheckResult, findings: Finding[]) {
|
|
85
|
+
if (!meta.title) findings.push({ severity: "HIGH", message: "Missing <title> tag", source: "metatags" });
|
|
86
|
+
if (!meta.description) findings.push({ severity: "HIGH", message: "Missing meta description", source: "metatags" });
|
|
87
|
+
if (!meta.canonical) findings.push({ severity: "HIGH", message: "Missing canonical URL", source: "metatags" });
|
|
88
|
+
if (!meta.ogImage)
|
|
89
|
+
findings.push({ severity: "MEDIUM", message: "Missing og:image — no social preview image", source: "metatags" });
|
|
90
|
+
if (!meta.ogTitle) findings.push({ severity: "MEDIUM", message: "Missing Open Graph tags", source: "metatags" });
|
|
91
|
+
if (!meta.twitterCard) findings.push({ severity: "LOW", message: "Missing Twitter Card tags", source: "metatags" });
|
|
92
|
+
if (!meta.hasJsonLd)
|
|
93
|
+
findings.push({ severity: "LOW", message: "No structured data (JSON-LD) found", source: "metatags" });
|
|
94
|
+
if (meta.redirectCount > 2)
|
|
95
|
+
findings.push({
|
|
96
|
+
severity: "MEDIUM",
|
|
97
|
+
message: `Redirect chain has ${meta.redirectCount} hops — consider reducing`,
|
|
98
|
+
source: "metatags",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function addPagespeedFindings(result: PsiResult, findings: Finding[]) {
|
|
103
|
+
const lhr = result.lighthouseResult;
|
|
104
|
+
|
|
105
|
+
for (const cat of Object.values(lhr.categories)) {
|
|
106
|
+
const score = cat.score !== null ? Math.round(cat.score * 100) : null;
|
|
107
|
+
if (score === null) continue;
|
|
108
|
+
|
|
109
|
+
if (cat.id === "performance" && score < 50) {
|
|
110
|
+
findings.push({ severity: "HIGH", message: `Performance score: ${score}/100`, source: "pagespeed" });
|
|
111
|
+
} else if (cat.id === "performance" && score < 90) {
|
|
112
|
+
findings.push({ severity: "MEDIUM", message: `Performance score: ${score}/100`, source: "pagespeed" });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (cat.id === "accessibility" && score < 90) {
|
|
116
|
+
const sev = score < 70 ? "HIGH" : "MEDIUM";
|
|
117
|
+
findings.push({ severity: sev, message: `Accessibility score: ${score}/100`, source: "pagespeed" });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (cat.id === "seo" && score < 90) {
|
|
121
|
+
findings.push({ severity: "MEDIUM", message: `SEO score: ${score}/100`, source: "pagespeed" });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Flag specific high-impact audits
|
|
126
|
+
const renderBlocking = lhr.audits["render-blocking-insight"] ?? lhr.audits["render-blocking-resources"];
|
|
127
|
+
if (renderBlocking?.score === 0 && renderBlocking.details?.items) {
|
|
128
|
+
const fontItems = renderBlocking.details.items.filter((i) => i.url && String(i.url).includes("fonts"));
|
|
129
|
+
if (fontItems.length > 0) {
|
|
130
|
+
const wastedMs = fontItems.reduce((sum, i) => sum + (i.wastedMs ? Number(i.wastedMs) : 0), 0);
|
|
131
|
+
findings.push({
|
|
132
|
+
severity: "MEDIUM",
|
|
133
|
+
message: `Render-blocking Google Fonts (${Math.round(wastedMs)}ms wasted)`,
|
|
134
|
+
source: "pagespeed",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// FCP / LCP
|
|
140
|
+
const fcp = lhr.audits["first-contentful-paint"];
|
|
141
|
+
if (fcp?.numericValue && fcp.numericValue > 2500) {
|
|
142
|
+
findings.push({
|
|
143
|
+
severity: "MEDIUM",
|
|
144
|
+
message: `FCP ${(fcp.numericValue / 1000).toFixed(1)}s — above 2.5s threshold`,
|
|
145
|
+
source: "pagespeed",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const lcp = lhr.audits["largest-contentful-paint"];
|
|
150
|
+
if (lcp?.numericValue && lcp.numericValue > 2500) {
|
|
151
|
+
findings.push({
|
|
152
|
+
severity: lcp.numericValue > 4000 ? "HIGH" : "MEDIUM",
|
|
153
|
+
message: `LCP ${(lcp.numericValue / 1000).toFixed(1)}s — above 2.5s threshold`,
|
|
154
|
+
source: "pagespeed",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function addRobotsFindings(
|
|
160
|
+
robotsTxt: string | null,
|
|
161
|
+
blockedAiCrawlers: number,
|
|
162
|
+
totalAiCrawlers: number,
|
|
163
|
+
googlebotAllowed: boolean,
|
|
164
|
+
findings: Finding[],
|
|
165
|
+
) {
|
|
166
|
+
if (robotsTxt === null) {
|
|
167
|
+
findings.push({ severity: "LOW", message: "No robots.txt found", source: "robots" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!googlebotAllowed) {
|
|
171
|
+
findings.push({ severity: "HIGH", message: "Googlebot is blocked by robots.txt", source: "robots" });
|
|
172
|
+
}
|
|
173
|
+
if (blockedAiCrawlers > 0) {
|
|
174
|
+
findings.push({
|
|
175
|
+
severity: "LOW",
|
|
176
|
+
message: `${blockedAiCrawlers}/${totalAiCrawlers} AI crawlers blocked`,
|
|
177
|
+
source: "robots",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function addSitemapFindings(sitemapCount: number, totalSubmitted: number, totalIndexed: number, findings: Finding[]) {
|
|
183
|
+
if (sitemapCount === 0) {
|
|
184
|
+
findings.push({ severity: "MEDIUM", message: "No sitemaps submitted to GSC", source: "sitemaps" });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (totalSubmitted > 0 && totalIndexed === 0) {
|
|
188
|
+
findings.push({
|
|
189
|
+
severity: "HIGH",
|
|
190
|
+
message: `${totalSubmitted.toLocaleString()} sitemap URLs submitted, 0 indexed`,
|
|
191
|
+
source: "sitemaps",
|
|
192
|
+
});
|
|
193
|
+
} else if (totalSubmitted > 0) {
|
|
194
|
+
const pct = Math.round((totalIndexed / totalSubmitted) * 100);
|
|
195
|
+
if (pct < 50) {
|
|
196
|
+
findings.push({
|
|
197
|
+
severity: "HIGH",
|
|
198
|
+
message: `${totalSubmitted.toLocaleString()} sitemap URLs, only ${pct}% indexed (${totalIndexed.toLocaleString()})`,
|
|
199
|
+
source: "sitemaps",
|
|
200
|
+
});
|
|
201
|
+
} else if (pct < 80) {
|
|
202
|
+
findings.push({
|
|
203
|
+
severity: "MEDIUM",
|
|
204
|
+
message: `${totalSubmitted.toLocaleString()} sitemap URLs, ${pct}% indexed (${totalIndexed.toLocaleString()})`,
|
|
205
|
+
source: "sitemaps",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function addInspectFindings(verdict: string, coverageState: string, findings: Finding[]) {
|
|
212
|
+
if (verdict === "FAIL") {
|
|
213
|
+
findings.push({ severity: "HIGH", message: `URL not indexed: ${coverageState}`, source: "inspect" });
|
|
214
|
+
} else if (verdict !== "PASS") {
|
|
215
|
+
findings.push({ severity: "MEDIUM", message: `Index status: ${coverageState}`, source: "inspect" });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatAudit(url: string, findings: Finding[], errors: string[]): string {
|
|
220
|
+
const lines: string[] = [`=== Site Audit: ${url} ===`, ""];
|
|
221
|
+
|
|
222
|
+
// Sort: HIGH first, then MEDIUM, then LOW
|
|
223
|
+
const order: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
224
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
225
|
+
|
|
226
|
+
if (findings.length === 0) {
|
|
227
|
+
lines.push("No issues found.");
|
|
228
|
+
} else {
|
|
229
|
+
lines.push(`${findings.length} findings:`, "");
|
|
230
|
+
for (const f of findings) {
|
|
231
|
+
lines.push(`${f.severity.padEnd(6)} ${f.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (errors.length > 0) {
|
|
236
|
+
lines.push("", "--- Errors (partial audit) ---", "");
|
|
237
|
+
for (const e of errors) {
|
|
238
|
+
lines.push(`- ${e}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function registerAuditTool(server: McpServer): void {
|
|
246
|
+
server.tool(
|
|
247
|
+
"audit",
|
|
248
|
+
"Run a comprehensive site audit. Checks PageSpeed scores, meta tags, robots.txt, sitemaps, and index status in one call. Returns a prioritized list of findings.",
|
|
249
|
+
{
|
|
250
|
+
url: z.string().url().describe("The URL to audit (e.g., 'https://example.com')."),
|
|
251
|
+
site_url: z
|
|
252
|
+
.string()
|
|
253
|
+
.optional()
|
|
254
|
+
.describe("GSC property for index/sitemap checks (e.g., 'sc-domain:example.com'). Omit to skip GSC checks."),
|
|
255
|
+
strategy: z.enum(["mobile", "desktop"]).optional().describe("PageSpeed device strategy. Default: 'mobile'."),
|
|
256
|
+
},
|
|
257
|
+
async ({ url, site_url, strategy }) => {
|
|
258
|
+
const findings: Finding[] = [];
|
|
259
|
+
const errors: string[] = [];
|
|
260
|
+
const origin = new URL(url).origin;
|
|
261
|
+
|
|
262
|
+
// Run independent checks in parallel
|
|
263
|
+
const [metaResult, pagespeedResult, robotsResult, sitemapResult, inspectResult] = await Promise.allSettled([
|
|
264
|
+
checkMeta(url),
|
|
265
|
+
runPagespeed(url, {
|
|
266
|
+
strategy: (strategy as "mobile" | "desktop") ?? "mobile",
|
|
267
|
+
categories: ["performance", "accessibility", "seo", "best-practices"] as PsiCategoryType[],
|
|
268
|
+
}),
|
|
269
|
+
fetchRobotsTxt(origin).then(async ({ robotsTxt }) => {
|
|
270
|
+
const crawlers = await auditAiCrawlers(robotsTxt);
|
|
271
|
+
const { allowed: googlebotAllowed } = isAllowed(robotsTxt, "Googlebot", "/");
|
|
272
|
+
return { robotsTxt, crawlers, googlebotAllowed };
|
|
273
|
+
}),
|
|
274
|
+
site_url ? listSitemaps(site_url) : Promise.resolve(null),
|
|
275
|
+
site_url ? inspectUrl(url, site_url).catch(() => null) : Promise.resolve(null),
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
// Process meta
|
|
279
|
+
if (metaResult.status === "fulfilled") {
|
|
280
|
+
addMetaFindings(metaResult.value, findings);
|
|
281
|
+
} else {
|
|
282
|
+
errors.push(`Meta tags: ${metaResult.reason}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Process pagespeed
|
|
286
|
+
if (pagespeedResult.status === "fulfilled") {
|
|
287
|
+
addPagespeedFindings(pagespeedResult.value, findings);
|
|
288
|
+
} else {
|
|
289
|
+
errors.push(`PageSpeed: ${pagespeedResult.reason}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Process robots
|
|
293
|
+
if (robotsResult.status === "fulfilled") {
|
|
294
|
+
const { robotsTxt, crawlers, googlebotAllowed } = robotsResult.value;
|
|
295
|
+
const blocked = crawlers.filter((c) => !c.allowed).length;
|
|
296
|
+
addRobotsFindings(robotsTxt.raw || null, blocked, crawlers.length, googlebotAllowed, findings);
|
|
297
|
+
} else {
|
|
298
|
+
errors.push(`Robots.txt: ${robotsResult.reason}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Process sitemaps
|
|
302
|
+
if (sitemapResult.status === "fulfilled" && sitemapResult.value) {
|
|
303
|
+
const sitemaps = sitemapResult.value;
|
|
304
|
+
let totalSubmitted = 0;
|
|
305
|
+
let totalIndexed = 0;
|
|
306
|
+
for (const sm of sitemaps) {
|
|
307
|
+
for (const c of sm.contents ?? []) {
|
|
308
|
+
totalSubmitted += Number(c.submitted ?? 0);
|
|
309
|
+
totalIndexed += Number(c.indexed ?? 0);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
|
|
313
|
+
} else if (sitemapResult.status === "rejected") {
|
|
314
|
+
errors.push(`Sitemaps: ${sitemapResult.reason}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Process inspect
|
|
318
|
+
if (inspectResult.status === "fulfilled" && inspectResult.value) {
|
|
319
|
+
const idx = inspectResult.value.indexStatusResult;
|
|
320
|
+
addInspectFindings(idx.verdict, idx.coverageState, findings);
|
|
321
|
+
} else if (inspectResult.status === "rejected") {
|
|
322
|
+
errors.push(`Inspect: ${inspectResult.reason}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
content: [{ type: "text", text: formatAudit(url, findings, errors) }],
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
}
|
package/src/tools/metatags.ts
CHANGED
|
@@ -134,6 +134,168 @@ function formatJsonLd(data: unknown, indent = 0): string[] {
|
|
|
134
134
|
return lines;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// --- Structured data validation (per Google Rich Results docs) ---
|
|
138
|
+
|
|
139
|
+
interface SchemaRule {
|
|
140
|
+
required: string[];
|
|
141
|
+
recommended: string[];
|
|
142
|
+
imageFields: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Required/recommended fields sourced from Google's Rich Results documentation
|
|
146
|
+
// https://developers.google.com/search/docs/appearance/structured-data
|
|
147
|
+
const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
148
|
+
WebSite: {
|
|
149
|
+
required: ["name", "url"],
|
|
150
|
+
recommended: ["potentialAction"],
|
|
151
|
+
imageFields: [],
|
|
152
|
+
},
|
|
153
|
+
Organization: {
|
|
154
|
+
required: ["name", "url"],
|
|
155
|
+
recommended: ["logo", "sameAs", "contactPoint"],
|
|
156
|
+
imageFields: ["logo"],
|
|
157
|
+
},
|
|
158
|
+
Article: {
|
|
159
|
+
required: ["headline", "image", "datePublished", "author"],
|
|
160
|
+
recommended: ["dateModified", "publisher"],
|
|
161
|
+
imageFields: ["image"],
|
|
162
|
+
},
|
|
163
|
+
NewsArticle: {
|
|
164
|
+
required: ["headline", "image", "datePublished", "author"],
|
|
165
|
+
recommended: ["dateModified", "publisher"],
|
|
166
|
+
imageFields: ["image"],
|
|
167
|
+
},
|
|
168
|
+
Product: {
|
|
169
|
+
required: ["name", "image"],
|
|
170
|
+
recommended: ["description", "offers", "brand", "review", "aggregateRating"],
|
|
171
|
+
imageFields: ["image"],
|
|
172
|
+
},
|
|
173
|
+
LocalBusiness: {
|
|
174
|
+
required: ["name", "address"],
|
|
175
|
+
recommended: ["telephone", "openingHoursSpecification", "image", "url"],
|
|
176
|
+
imageFields: ["image"],
|
|
177
|
+
},
|
|
178
|
+
FAQPage: {
|
|
179
|
+
required: ["mainEntity"],
|
|
180
|
+
recommended: [],
|
|
181
|
+
imageFields: [],
|
|
182
|
+
},
|
|
183
|
+
BreadcrumbList: {
|
|
184
|
+
required: ["itemListElement"],
|
|
185
|
+
recommended: [],
|
|
186
|
+
imageFields: [],
|
|
187
|
+
},
|
|
188
|
+
Event: {
|
|
189
|
+
required: ["name", "startDate", "location"],
|
|
190
|
+
recommended: ["endDate", "image", "description", "offers", "organizer"],
|
|
191
|
+
imageFields: ["image"],
|
|
192
|
+
},
|
|
193
|
+
Recipe: {
|
|
194
|
+
required: ["name", "image"],
|
|
195
|
+
recommended: ["author", "datePublished", "description", "recipeIngredient", "recipeInstructions"],
|
|
196
|
+
imageFields: ["image"],
|
|
197
|
+
},
|
|
198
|
+
VideoObject: {
|
|
199
|
+
required: ["name", "description", "thumbnailUrl", "uploadDate"],
|
|
200
|
+
recommended: ["duration", "contentUrl", "embedUrl"],
|
|
201
|
+
imageFields: ["thumbnailUrl"],
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
interface ValidationIssue {
|
|
206
|
+
type: string;
|
|
207
|
+
level: "required" | "recommended";
|
|
208
|
+
field: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
|
|
212
|
+
const val = obj[field];
|
|
213
|
+
if (val !== undefined && val !== null && val !== "") return val;
|
|
214
|
+
// Check if it's a nested object with a value (e.g., logo might be {url: "..."} or a string)
|
|
215
|
+
if (typeof val === "object" && val !== null) return val;
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUrls: string[] } {
|
|
220
|
+
const issues: ValidationIssue[] = [];
|
|
221
|
+
const imageUrls: string[] = [];
|
|
222
|
+
|
|
223
|
+
function validateBlock(data: unknown) {
|
|
224
|
+
if (Array.isArray(data)) {
|
|
225
|
+
for (const item of data) validateBlock(item);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!data || typeof data !== "object") return;
|
|
229
|
+
|
|
230
|
+
const obj = data as Record<string, unknown>;
|
|
231
|
+
const rawType = obj["@type"];
|
|
232
|
+
const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
|
|
233
|
+
|
|
234
|
+
for (const type of types) {
|
|
235
|
+
const rule = SCHEMA_RULES[String(type)];
|
|
236
|
+
if (!rule) continue;
|
|
237
|
+
|
|
238
|
+
for (const field of rule.required) {
|
|
239
|
+
if (getNestedValue(obj, field) === undefined) {
|
|
240
|
+
issues.push({ type: String(type), level: "required", field });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const field of rule.recommended) {
|
|
245
|
+
if (getNestedValue(obj, field) === undefined) {
|
|
246
|
+
issues.push({ type: String(type), level: "recommended", field });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Collect image URLs for validation
|
|
251
|
+
for (const field of rule.imageFields) {
|
|
252
|
+
const val = obj[field];
|
|
253
|
+
if (typeof val === "string" && val.startsWith("http")) {
|
|
254
|
+
imageUrls.push(val);
|
|
255
|
+
} else if (val && typeof val === "object") {
|
|
256
|
+
const nested = val as Record<string, unknown>;
|
|
257
|
+
const url = nested.url ?? nested.contentUrl;
|
|
258
|
+
if (typeof url === "string" && url.startsWith("http")) {
|
|
259
|
+
imageUrls.push(url);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Recurse into nested objects
|
|
266
|
+
for (const val of Object.values(obj)) {
|
|
267
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
268
|
+
const nested = val as Record<string, unknown>;
|
|
269
|
+
if (nested["@type"]) validateBlock(nested);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const block of blocks) validateBlock(block);
|
|
275
|
+
return { issues, imageUrls };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function formatValidation(issues: ValidationIssue[]): string[] {
|
|
279
|
+
if (issues.length === 0) return ["All validated types have their required fields."];
|
|
280
|
+
|
|
281
|
+
const lines: string[] = [];
|
|
282
|
+
const required = issues.filter((i) => i.level === "required");
|
|
283
|
+
const recommended = issues.filter((i) => i.level === "recommended");
|
|
284
|
+
|
|
285
|
+
if (required.length > 0) {
|
|
286
|
+
for (const i of required) {
|
|
287
|
+
lines.push(`MISSING ${i.type}.${i.field} (required for Rich Results)`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (recommended.length > 0) {
|
|
291
|
+
for (const i of recommended) {
|
|
292
|
+
lines.push(`OPTIONAL ${i.type}.${i.field} (recommended)`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return lines;
|
|
297
|
+
}
|
|
298
|
+
|
|
137
299
|
interface RedirectHop {
|
|
138
300
|
url: string;
|
|
139
301
|
status: number;
|
|
@@ -381,6 +543,31 @@ export function registerMetatagsTool(server: McpServer): void {
|
|
|
381
543
|
}
|
|
382
544
|
}
|
|
383
545
|
|
|
546
|
+
// Structured data validation
|
|
547
|
+
if (parsed.jsonLd.length > 0) {
|
|
548
|
+
const { issues, imageUrls } = validateJsonLd(parsed.jsonLd);
|
|
549
|
+
if (issues.length > 0 || imageUrls.length > 0) {
|
|
550
|
+
output.push("", "--- Structured Data Validation ---", "");
|
|
551
|
+
output.push(...formatValidation(issues));
|
|
552
|
+
|
|
553
|
+
// HEAD-check image URLs from structured data
|
|
554
|
+
if (imageUrls.length > 0) {
|
|
555
|
+
const deduped = [...new Set(imageUrls)];
|
|
556
|
+
const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
|
|
557
|
+
for (const check of imgChecks) {
|
|
558
|
+
if (check.error) {
|
|
559
|
+
output.push(`BROKEN Image: ${check.url} — ${check.error}`);
|
|
560
|
+
} else if (check.status && check.status >= 400) {
|
|
561
|
+
output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
|
|
562
|
+
} else {
|
|
563
|
+
const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
|
|
564
|
+
output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
384
571
|
return {
|
|
385
572
|
content: [{ type: "text", text: output.join("\n") }],
|
|
386
573
|
};
|
package/src/tools/performance.ts
CHANGED
|
@@ -111,7 +111,7 @@ function formatComparison(
|
|
|
111
111
|
`Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
|
|
112
112
|
`Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
|
|
113
113
|
`Avg CTR: ${`${(cur.ctr * 100).toFixed(1)}%`.padEnd(12)} ${`${(prev.ctr * 100).toFixed(1)}%`.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`,
|
|
114
|
-
`Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${cur.position < prev.position ? "improved" : cur.position > prev.position ? "regressed" : "stable"} (${(cur.position - prev.position).toFixed(1)})`,
|
|
114
|
+
`Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${prev.position === 0 && cur.position > 0 ? "new" : cur.position < prev.position ? "improved" : cur.position > prev.position ? "regressed" : "stable"} (${(cur.position - prev.position).toFixed(1)})`,
|
|
115
115
|
"",
|
|
116
116
|
];
|
|
117
117
|
|