pagesight 0.11.0 → 0.12.1
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/lib/psi.ts +4 -0
- package/src/tools/audit.ts +73 -11
- package/src/tools/links.ts +183 -0
- package/src/tools/metatags.ts +112 -12
- package/src/tools/pagespeed.ts +8 -1
- package/src/tools/sample-inspect.ts +6 -6
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { registerAuditTool } from "./tools/audit.js";
|
|
5
5
|
import { registerCruxTool } from "./tools/crux.js";
|
|
6
6
|
import { registerInspectTool } from "./tools/inspect.js";
|
|
7
|
+
import { registerLinksTool } from "./tools/links.js";
|
|
7
8
|
import { registerMetatagsTool } from "./tools/metatags.js";
|
|
8
9
|
import { registerPagespeedTool } from "./tools/pagespeed.js";
|
|
9
10
|
import { registerPerformanceTool } from "./tools/performance.js";
|
|
@@ -22,6 +23,7 @@ const server = new McpServer({
|
|
|
22
23
|
registerAuditTool(server);
|
|
23
24
|
registerCruxTool(server);
|
|
24
25
|
registerInspectTool(server);
|
|
26
|
+
registerLinksTool(server);
|
|
25
27
|
registerMetatagsTool(server);
|
|
26
28
|
registerPagespeedTool(server);
|
|
27
29
|
registerPerformanceTool(server);
|
package/src/lib/psi.ts
CHANGED
|
@@ -79,6 +79,10 @@ export interface PsiResult {
|
|
|
79
79
|
export type PsiStrategy = "mobile" | "desktop";
|
|
80
80
|
export type PsiCategoryType = "performance" | "accessibility" | "best-practices" | "seo";
|
|
81
81
|
|
|
82
|
+
export function hasApiKey(): boolean {
|
|
83
|
+
return !!process.env.GOOGLE_API_KEY;
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
export async function runPagespeed(
|
|
83
87
|
url: string,
|
|
84
88
|
options?: {
|
package/src/tools/audit.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { inspectUrl, listSitemaps } from "../lib/gsc.js";
|
|
4
4
|
import { type PsiCategoryType, type PsiResult, runPagespeed } from "../lib/psi.js";
|
|
5
5
|
import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../lib/robots.js";
|
|
6
|
+
import { fetchSitemap, inspectSingle, sampleUrls } from "./sample-inspect.js";
|
|
6
7
|
|
|
7
8
|
type Severity = "HIGH" | "MEDIUM" | "LOW";
|
|
8
9
|
|
|
@@ -128,9 +129,11 @@ function addPagespeedFindings(result: PsiResult, findings: Finding[]) {
|
|
|
128
129
|
const fontItems = renderBlocking.details.items.filter((i) => i.url && String(i.url).includes("fonts"));
|
|
129
130
|
if (fontItems.length > 0) {
|
|
130
131
|
const wastedMs = fontItems.reduce((sum, i) => sum + (i.wastedMs ? Number(i.wastedMs) : 0), 0);
|
|
132
|
+
const isGoogleFonts = fontItems.some((i) => String(i.url).includes("fonts.googleapis.com"));
|
|
133
|
+
const label = isGoogleFonts ? "Render-blocking Google Fonts" : "Render-blocking font CSS";
|
|
131
134
|
findings.push({
|
|
132
135
|
severity: "MEDIUM",
|
|
133
|
-
message:
|
|
136
|
+
message: `${label} (${Math.round(wastedMs)}ms wasted)`,
|
|
134
137
|
source: "pagespeed",
|
|
135
138
|
});
|
|
136
139
|
}
|
|
@@ -208,6 +211,36 @@ function addSitemapFindings(sitemapCount: number, totalSubmitted: number, totalI
|
|
|
208
211
|
}
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
function formatDrillDown(
|
|
215
|
+
inspections: Array<{ url: string; verdict: string; coverageState: string; error: string | null }>,
|
|
216
|
+
): string {
|
|
217
|
+
const valid = inspections.filter((r) => !r.error);
|
|
218
|
+
if (valid.length === 0) return "";
|
|
219
|
+
|
|
220
|
+
const indexed = valid.filter((r) => r.verdict === "PASS").length;
|
|
221
|
+
const lines: string[] = [` Auto-inspected ${valid.length} URLs:`];
|
|
222
|
+
lines.push(` - ${indexed}/${valid.length} indexed`);
|
|
223
|
+
|
|
224
|
+
const stateCounts: Record<string, { count: number; urls: string[] }> = {};
|
|
225
|
+
for (const r of valid) {
|
|
226
|
+
if (r.verdict !== "PASS") {
|
|
227
|
+
const path = new URL(r.url).pathname;
|
|
228
|
+
const existing = stateCounts[r.coverageState];
|
|
229
|
+
if (existing) {
|
|
230
|
+
existing.count++;
|
|
231
|
+
existing.urls.push(path);
|
|
232
|
+
} else {
|
|
233
|
+
stateCounts[r.coverageState] = { count: 1, urls: [path] };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const [state, { count, urls }] of Object.entries(stateCounts)) {
|
|
238
|
+
lines.push(` - ${count}/${valid.length} ${state}: ${urls.join(", ")}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return lines.join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
211
244
|
function addInspectFindings(verdict: string, coverageState: string, findings: Finding[]) {
|
|
212
245
|
if (verdict === "FAIL") {
|
|
213
246
|
findings.push({ severity: "HIGH", message: `URL not indexed: ${coverageState}`, source: "inspect" });
|
|
@@ -219,26 +252,28 @@ function addInspectFindings(verdict: string, coverageState: string, findings: Fi
|
|
|
219
252
|
function formatAudit(url: string, findings: Finding[], errors: string[]): string {
|
|
220
253
|
const lines: string[] = [`=== Site Audit: ${url} ===`, ""];
|
|
221
254
|
|
|
255
|
+
// Lead with errors so partial results are obvious
|
|
256
|
+
if (errors.length > 0) {
|
|
257
|
+
lines.push(`${errors.length} check${errors.length > 1 ? "s" : ""} failed — results below are partial:`, "");
|
|
258
|
+
for (const e of errors) {
|
|
259
|
+
lines.push(` FAIL ${e}`);
|
|
260
|
+
}
|
|
261
|
+
lines.push("");
|
|
262
|
+
}
|
|
263
|
+
|
|
222
264
|
// Sort: HIGH first, then MEDIUM, then LOW
|
|
223
265
|
const order: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
224
266
|
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
225
267
|
|
|
226
|
-
if (findings.length === 0) {
|
|
268
|
+
if (findings.length === 0 && errors.length === 0) {
|
|
227
269
|
lines.push("No issues found.");
|
|
228
|
-
} else {
|
|
229
|
-
lines.push(`${findings.length} findings:`, "");
|
|
270
|
+
} else if (findings.length > 0) {
|
|
271
|
+
lines.push(`${findings.length} finding${findings.length > 1 ? "s" : ""}:`, "");
|
|
230
272
|
for (const f of findings) {
|
|
231
273
|
lines.push(`${f.severity.padEnd(6)} ${f.message}`);
|
|
232
274
|
}
|
|
233
275
|
}
|
|
234
276
|
|
|
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
277
|
return lines.join("\n");
|
|
243
278
|
}
|
|
244
279
|
|
|
@@ -310,6 +345,33 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
310
345
|
}
|
|
311
346
|
}
|
|
312
347
|
addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
|
|
348
|
+
|
|
349
|
+
// Auto-drill-down: when indexing is low, sample-inspect to explain why
|
|
350
|
+
const indexPct = totalSubmitted > 0 ? (totalIndexed / totalSubmitted) * 100 : 100;
|
|
351
|
+
if (site_url && indexPct < 50 && sitemaps.length > 0) {
|
|
352
|
+
try {
|
|
353
|
+
const sitemapPath = (sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0]).path;
|
|
354
|
+
let parsed = await fetchSitemap(sitemapPath);
|
|
355
|
+
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
356
|
+
parsed = await fetchSitemap(parsed.childSitemaps[0]);
|
|
357
|
+
}
|
|
358
|
+
if (parsed.urls.length > 0) {
|
|
359
|
+
const sampled = sampleUrls(parsed.urls, 5, "spread");
|
|
360
|
+
const inspections = [];
|
|
361
|
+
for (const u of sampled) {
|
|
362
|
+
inspections.push(await inspectSingle(u, site_url));
|
|
363
|
+
}
|
|
364
|
+
const drillDown = formatDrillDown(inspections);
|
|
365
|
+
// Append drill-down to the sitemap finding
|
|
366
|
+
const sitemapFinding = findings.find((f) => f.source === "sitemaps" && f.severity === "HIGH");
|
|
367
|
+
if (sitemapFinding) {
|
|
368
|
+
sitemapFinding.message += `\n${drillDown}`;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// Drill-down is best-effort — don't fail the audit
|
|
373
|
+
}
|
|
374
|
+
}
|
|
313
375
|
} else if (sitemapResult.status === "rejected") {
|
|
314
376
|
errors.push(`Sitemaps: ${sitemapResult.reason}`);
|
|
315
377
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
interface LinkResult {
|
|
5
|
+
href: string;
|
|
6
|
+
status: number | null;
|
|
7
|
+
redirectChain: Array<{ url: string; status: number }>;
|
|
8
|
+
finalUrl: string | null;
|
|
9
|
+
error: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function checkLink(href: string): Promise<LinkResult> {
|
|
13
|
+
const chain: Array<{ url: string; status: number }> = [];
|
|
14
|
+
let current = href;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
for (let i = 0; i < 10; i++) {
|
|
18
|
+
const res = await fetch(current, {
|
|
19
|
+
method: "GET",
|
|
20
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
21
|
+
redirect: "manual",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
chain.push({ url: current, status: res.status });
|
|
25
|
+
|
|
26
|
+
if (res.status >= 300 && res.status < 400) {
|
|
27
|
+
const location = res.headers.get("location");
|
|
28
|
+
if (!location) break;
|
|
29
|
+
current = new URL(location, current).href;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
href,
|
|
35
|
+
status: res.status,
|
|
36
|
+
redirectChain: chain,
|
|
37
|
+
finalUrl: chain.length > 1 ? current : null,
|
|
38
|
+
error: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
href,
|
|
44
|
+
status: chain[chain.length - 1]?.status ?? null,
|
|
45
|
+
redirectChain: chain,
|
|
46
|
+
finalUrl: current,
|
|
47
|
+
error: "Too many redirects",
|
|
48
|
+
};
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return {
|
|
51
|
+
href,
|
|
52
|
+
status: null,
|
|
53
|
+
redirectChain: chain,
|
|
54
|
+
finalUrl: null,
|
|
55
|
+
error: err instanceof Error ? err.message : String(err),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractInternalLinks(html: string, origin: string): string[] {
|
|
61
|
+
const links = new Set<string>();
|
|
62
|
+
|
|
63
|
+
for (const match of html.matchAll(/<a\s[^>]*href=["']([^"'#]+)/gi)) {
|
|
64
|
+
const raw = match[1].trim();
|
|
65
|
+
if (!raw || raw.startsWith("javascript:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) continue;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const resolved = new URL(raw, origin).href;
|
|
69
|
+
if (resolved.startsWith(origin) && !resolved.includes("/cdn-cgi/")) {
|
|
70
|
+
links.add(resolved);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Invalid URL, skip
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [...links];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatResults(url: string, results: LinkResult[]): string {
|
|
81
|
+
const lines: string[] = [`=== Internal Links: ${url} ===`, `Found ${results.length} internal links`, ""];
|
|
82
|
+
|
|
83
|
+
const broken = results.filter((r) => r.error || (r.status && r.status >= 400));
|
|
84
|
+
const redirected = results.filter((r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400);
|
|
85
|
+
const ok = results.filter((r) => !r.error && r.redirectChain.length === 1 && r.status && r.status < 400);
|
|
86
|
+
|
|
87
|
+
// Summary
|
|
88
|
+
lines.push("--- Summary ---", "");
|
|
89
|
+
lines.push(`OK: ${ok.length}`);
|
|
90
|
+
if (redirected.length > 0) lines.push(`Redirected: ${redirected.length}`);
|
|
91
|
+
if (broken.length > 0) lines.push(`Broken: ${broken.length}`);
|
|
92
|
+
lines.push("");
|
|
93
|
+
|
|
94
|
+
// Broken links
|
|
95
|
+
if (broken.length > 0) {
|
|
96
|
+
lines.push("--- Broken Links ---", "");
|
|
97
|
+
for (const r of broken) {
|
|
98
|
+
if (r.error) {
|
|
99
|
+
lines.push(`FAIL ${r.href}`);
|
|
100
|
+
lines.push(` Error: ${r.error}`);
|
|
101
|
+
} else {
|
|
102
|
+
lines.push(`FAIL ${r.href} → ${r.status}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
lines.push("");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Redirect chains
|
|
109
|
+
if (redirected.length > 0) {
|
|
110
|
+
lines.push("--- Redirect Chains ---", "");
|
|
111
|
+
for (const r of redirected) {
|
|
112
|
+
const hops = r.redirectChain.length - 1;
|
|
113
|
+
const chainStr = r.redirectChain.map((h) => `${h.status}`).join(" → ");
|
|
114
|
+
lines.push(`REDIRECT ${r.href}`);
|
|
115
|
+
lines.push(` ${chainStr} → ${r.finalUrl} (${hops} hop${hops > 1 ? "s" : ""})`);
|
|
116
|
+
}
|
|
117
|
+
lines.push("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function registerLinksTool(server: McpServer): void {
|
|
124
|
+
server.tool(
|
|
125
|
+
"links",
|
|
126
|
+
"Check internal links on a page. Fetches the page, extracts all internal <a href> links, and checks each for broken links (404/5xx), redirect chains, and errors.",
|
|
127
|
+
{
|
|
128
|
+
url: z.string().url().describe("The page URL to check internal links for."),
|
|
129
|
+
},
|
|
130
|
+
async ({ url }) => {
|
|
131
|
+
try {
|
|
132
|
+
const origin = new URL(url).origin;
|
|
133
|
+
|
|
134
|
+
// Fetch the page
|
|
135
|
+
const res = await fetch(url, {
|
|
136
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
137
|
+
redirect: "follow",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: `Error fetching ${url}: HTTP ${res.status}` }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const html = await res.text();
|
|
147
|
+
const links = extractInternalLinks(html, origin);
|
|
148
|
+
|
|
149
|
+
if (links.length === 0) {
|
|
150
|
+
return {
|
|
151
|
+
content: [
|
|
152
|
+
{
|
|
153
|
+
type: "text",
|
|
154
|
+
text: `No internal links found on ${url}\nNote: this page may use client-side rendering (SPA). Only static <a href> links in the HTML are detected.`,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check links with concurrency 5
|
|
161
|
+
const concurrency = 5;
|
|
162
|
+
const results: LinkResult[] = [];
|
|
163
|
+
let i = 0;
|
|
164
|
+
|
|
165
|
+
while (i < links.length) {
|
|
166
|
+
const batch = links.slice(i, i + concurrency);
|
|
167
|
+
const settled = await Promise.all(batch.map((href) => checkLink(href)));
|
|
168
|
+
results.push(...settled);
|
|
169
|
+
i += concurrency;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: "text", text: formatResults(url, results) }],
|
|
174
|
+
};
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
177
|
+
return {
|
|
178
|
+
content: [{ type: "text", text: `Error checking links: ${msg}` }],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
}
|
package/src/tools/metatags.ts
CHANGED
|
@@ -140,6 +140,7 @@ interface SchemaRule {
|
|
|
140
140
|
required: string[];
|
|
141
141
|
recommended: string[];
|
|
142
142
|
imageFields: string[];
|
|
143
|
+
nestedRequired?: Record<string, string[]>;
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
// Required/recommended fields sourced from Google's Rich Results documentation
|
|
@@ -169,6 +170,7 @@ const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
|
169
170
|
required: ["name", "image"],
|
|
170
171
|
recommended: ["description", "offers", "brand", "review", "aggregateRating"],
|
|
171
172
|
imageFields: ["image"],
|
|
173
|
+
nestedRequired: { offers: ["price", "priceCurrency"] },
|
|
172
174
|
},
|
|
173
175
|
LocalBusiness: {
|
|
174
176
|
required: ["name", "address"],
|
|
@@ -200,6 +202,43 @@ const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
|
200
202
|
recommended: ["duration", "contentUrl", "embedUrl"],
|
|
201
203
|
imageFields: ["thumbnailUrl"],
|
|
202
204
|
},
|
|
205
|
+
SoftwareApplication: {
|
|
206
|
+
required: ["name", "offers"],
|
|
207
|
+
recommended: ["applicationCategory", "operatingSystem", "review", "aggregateRating"],
|
|
208
|
+
imageFields: ["image"],
|
|
209
|
+
nestedRequired: { offers: ["price", "priceCurrency"] },
|
|
210
|
+
},
|
|
211
|
+
Dataset: {
|
|
212
|
+
required: ["name", "description"],
|
|
213
|
+
recommended: ["distribution", "creator", "license"],
|
|
214
|
+
imageFields: [],
|
|
215
|
+
nestedRequired: { distribution: ["contentUrl", "encodingFormat"] },
|
|
216
|
+
},
|
|
217
|
+
TechArticle: {
|
|
218
|
+
required: ["headline", "image", "datePublished", "author"],
|
|
219
|
+
recommended: ["dateModified", "publisher"],
|
|
220
|
+
imageFields: ["image"],
|
|
221
|
+
},
|
|
222
|
+
BlogPosting: {
|
|
223
|
+
required: ["headline", "image", "datePublished", "author"],
|
|
224
|
+
recommended: ["dateModified", "publisher"],
|
|
225
|
+
imageFields: ["image"],
|
|
226
|
+
},
|
|
227
|
+
HowTo: {
|
|
228
|
+
required: ["name", "step"],
|
|
229
|
+
recommended: ["image", "totalTime", "estimatedCost"],
|
|
230
|
+
imageFields: ["image"],
|
|
231
|
+
},
|
|
232
|
+
Course: {
|
|
233
|
+
required: ["name", "description"],
|
|
234
|
+
recommended: ["provider", "offers"],
|
|
235
|
+
imageFields: [],
|
|
236
|
+
},
|
|
237
|
+
JobPosting: {
|
|
238
|
+
required: ["title", "description", "datePosted", "hiringOrganization"],
|
|
239
|
+
recommended: ["employmentType", "jobLocation", "baseSalary", "validThrough"],
|
|
240
|
+
imageFields: [],
|
|
241
|
+
},
|
|
203
242
|
};
|
|
204
243
|
|
|
205
244
|
interface ValidationIssue {
|
|
@@ -216,9 +255,14 @@ function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
|
|
|
216
255
|
return undefined;
|
|
217
256
|
}
|
|
218
257
|
|
|
219
|
-
function validateJsonLd(blocks: unknown[]): {
|
|
258
|
+
function validateJsonLd(blocks: unknown[]): {
|
|
259
|
+
issues: ValidationIssue[];
|
|
260
|
+
imageUrls: string[];
|
|
261
|
+
validatedTypes: Set<string>;
|
|
262
|
+
} {
|
|
220
263
|
const issues: ValidationIssue[] = [];
|
|
221
264
|
const imageUrls: string[] = [];
|
|
265
|
+
const validatedTypes = new Set<string>();
|
|
222
266
|
|
|
223
267
|
function validateBlock(data: unknown) {
|
|
224
268
|
if (Array.isArray(data)) {
|
|
@@ -234,6 +278,7 @@ function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUr
|
|
|
234
278
|
for (const type of types) {
|
|
235
279
|
const rule = SCHEMA_RULES[String(type)];
|
|
236
280
|
if (!rule) continue;
|
|
281
|
+
validatedTypes.add(String(type));
|
|
237
282
|
|
|
238
283
|
for (const field of rule.required) {
|
|
239
284
|
if (getNestedValue(obj, field) === undefined) {
|
|
@@ -247,6 +292,27 @@ function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUr
|
|
|
247
292
|
}
|
|
248
293
|
}
|
|
249
294
|
|
|
295
|
+
// Check nested required fields (e.g., offers.price inside Product)
|
|
296
|
+
if (rule.nestedRequired) {
|
|
297
|
+
for (const [parent, fields] of Object.entries(rule.nestedRequired)) {
|
|
298
|
+
const parentVal = obj[parent];
|
|
299
|
+
if (parentVal && typeof parentVal === "object") {
|
|
300
|
+
const targets = Array.isArray(parentVal) ? parentVal : [parentVal];
|
|
301
|
+
for (const target of targets) {
|
|
302
|
+
if (target && typeof target === "object") {
|
|
303
|
+
const nested = target as Record<string, unknown>;
|
|
304
|
+
for (const field of fields) {
|
|
305
|
+
if (nested[field] === undefined || nested[field] === null || nested[field] === "") {
|
|
306
|
+
issues.push({ type: String(type), level: "required", field: `${parent}.${field}` });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
break; // Only check the first item in arrays
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
250
316
|
// Collect image URLs for validation
|
|
251
317
|
for (const field of rule.imageFields) {
|
|
252
318
|
const val = obj[field];
|
|
@@ -262,17 +328,18 @@ function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUr
|
|
|
262
328
|
}
|
|
263
329
|
}
|
|
264
330
|
|
|
265
|
-
// Recurse into nested objects
|
|
331
|
+
// Recurse into nested objects and arrays (e.g., @graph)
|
|
266
332
|
for (const val of Object.values(obj)) {
|
|
267
|
-
if (
|
|
268
|
-
const
|
|
269
|
-
|
|
333
|
+
if (Array.isArray(val)) {
|
|
334
|
+
for (const item of val) validateBlock(item);
|
|
335
|
+
} else if (val && typeof val === "object") {
|
|
336
|
+
validateBlock(val);
|
|
270
337
|
}
|
|
271
338
|
}
|
|
272
339
|
}
|
|
273
340
|
|
|
274
341
|
for (const block of blocks) validateBlock(block);
|
|
275
|
-
return { issues, imageUrls };
|
|
342
|
+
return { issues, imageUrls, validatedTypes };
|
|
276
343
|
}
|
|
277
344
|
|
|
278
345
|
// What each recommended field enables (sourced from Google Rich Results docs)
|
|
@@ -293,12 +360,31 @@ const FIELD_HINTS: Record<string, string> = {
|
|
|
293
360
|
"Event.offers": "shows ticket prices in search",
|
|
294
361
|
"Recipe.author": "shown in recipe rich results",
|
|
295
362
|
"VideoObject.duration": "shown in video rich results",
|
|
363
|
+
"SoftwareApplication.applicationCategory": "shown in software rich results",
|
|
364
|
+
"SoftwareApplication.operatingSystem": "shown in software rich results",
|
|
365
|
+
"SoftwareApplication.review": "enables star ratings",
|
|
366
|
+
"SoftwareApplication.aggregateRating": "enables aggregate star ratings",
|
|
367
|
+
"Dataset.distribution": "enables dataset download in search",
|
|
368
|
+
"Dataset.creator": "shown in dataset rich results",
|
|
369
|
+
"Dataset.license": "shown in dataset rich results",
|
|
370
|
+
"Course.provider": "shown in course rich results",
|
|
371
|
+
"Course.offers": "enables price display for courses",
|
|
372
|
+
"JobPosting.employmentType": "shown in job search results",
|
|
373
|
+
"JobPosting.jobLocation": "shown in job search results",
|
|
374
|
+
"JobPosting.baseSalary": "enables salary display in job search",
|
|
296
375
|
};
|
|
297
376
|
|
|
298
|
-
function formatValidation(issues: ValidationIssue[]): string[] {
|
|
299
|
-
if (issues.length === 0) return ["All validated types have their required fields."];
|
|
300
|
-
|
|
377
|
+
function formatValidation(issues: ValidationIssue[], validatedTypes: Set<string>): string[] {
|
|
301
378
|
const lines: string[] = [];
|
|
379
|
+
|
|
380
|
+
// Show PASS for types with no required issues
|
|
381
|
+
const typesWithRequiredIssues = new Set(issues.filter((i) => i.level === "required").map((i) => i.type));
|
|
382
|
+
for (const type of validatedTypes) {
|
|
383
|
+
if (!typesWithRequiredIssues.has(type)) {
|
|
384
|
+
lines.push(`PASS ${type} — all required fields present`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
302
388
|
const required = issues.filter((i) => i.level === "required");
|
|
303
389
|
const recommended = issues.filter((i) => i.level === "recommended");
|
|
304
390
|
|
|
@@ -404,6 +490,20 @@ function formatMetatags(url: string, parsed: ParsedHead): string {
|
|
|
404
490
|
if (robots) lines.push(`Robots: ${robots}`);
|
|
405
491
|
const author = getMeta(parsed.meta, "author");
|
|
406
492
|
if (author) lines.push(`Author: ${author}`);
|
|
493
|
+
|
|
494
|
+
// HTML entity warnings
|
|
495
|
+
const entityCheck = [
|
|
496
|
+
{ label: "title", value: parsed.title },
|
|
497
|
+
{ label: "description", value: getMeta(parsed.meta, "description") },
|
|
498
|
+
{ label: "og:title", value: getMeta(parsed.meta, "og:title") },
|
|
499
|
+
{ label: "og:description", value: getMeta(parsed.meta, "og:description") },
|
|
500
|
+
];
|
|
501
|
+
for (const { label, value } of entityCheck) {
|
|
502
|
+
if (value && /&(?:amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/i.test(value)) {
|
|
503
|
+
lines.push(`WARN: ${label} contains HTML entities — may render incorrectly in social previews`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
407
507
|
lines.push("");
|
|
408
508
|
|
|
409
509
|
// Open Graph
|
|
@@ -566,10 +666,10 @@ export function registerMetatagsTool(server: McpServer): void {
|
|
|
566
666
|
|
|
567
667
|
// Structured data validation
|
|
568
668
|
if (parsed.jsonLd.length > 0) {
|
|
569
|
-
const { issues, imageUrls } = validateJsonLd(parsed.jsonLd);
|
|
570
|
-
if (
|
|
669
|
+
const { issues, imageUrls, validatedTypes } = validateJsonLd(parsed.jsonLd);
|
|
670
|
+
if (validatedTypes.size > 0 || imageUrls.length > 0) {
|
|
571
671
|
output.push("", "--- Structured Data Validation ---", "");
|
|
572
|
-
output.push(...formatValidation(issues));
|
|
672
|
+
output.push(...formatValidation(issues, validatedTypes));
|
|
573
673
|
|
|
574
674
|
// HEAD-check image URLs from structured data
|
|
575
675
|
if (imageUrls.length > 0) {
|
package/src/tools/pagespeed.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import {
|
|
4
|
+
hasApiKey,
|
|
4
5
|
type PsiAudit,
|
|
5
6
|
type PsiAuditDetailItem,
|
|
6
7
|
type PsiCategoryType,
|
|
@@ -8,6 +9,9 @@ import {
|
|
|
8
9
|
runPagespeed,
|
|
9
10
|
} from "../lib/psi.js";
|
|
10
11
|
|
|
12
|
+
const QUOTA_NOTE =
|
|
13
|
+
"\n\nNote: No GOOGLE_API_KEY configured — using shared quota (400 req/day). Set your own key to avoid rate limits.";
|
|
14
|
+
|
|
11
15
|
function scoreLabel(score: number | null): string {
|
|
12
16
|
if (score === null) return "N/A";
|
|
13
17
|
const pct = Math.round(score * 100);
|
|
@@ -536,7 +540,8 @@ export function registerPagespeedTool(server: McpServer): void {
|
|
|
536
540
|
if (url) {
|
|
537
541
|
try {
|
|
538
542
|
const result = await runPagespeed(url, opts);
|
|
539
|
-
|
|
543
|
+
const text = formatPagespeed(url, result) + (hasApiKey() ? "" : QUOTA_NOTE);
|
|
544
|
+
return { content: [{ type: "text", text }] };
|
|
540
545
|
} catch (err) {
|
|
541
546
|
const msg = err instanceof Error ? err.message : String(err);
|
|
542
547
|
return { content: [{ type: "text", text: `Error running PageSpeed analysis: ${msg}` }] };
|
|
@@ -569,6 +574,8 @@ export function registerPagespeedTool(server: McpServer): void {
|
|
|
569
574
|
output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
|
|
570
575
|
}
|
|
571
576
|
|
|
577
|
+
if (!hasApiKey()) output += QUOTA_NOTE;
|
|
578
|
+
|
|
572
579
|
return { content: [{ type: "text", text: output }] };
|
|
573
580
|
},
|
|
574
581
|
);
|
|
@@ -2,13 +2,13 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { inspectUrl, listSitemaps } from "../lib/gsc.js";
|
|
4
4
|
|
|
5
|
-
interface SitemapParseResult {
|
|
5
|
+
export interface SitemapParseResult {
|
|
6
6
|
urls: string[];
|
|
7
7
|
isSitemapIndex: boolean;
|
|
8
8
|
childSitemaps: string[];
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function parseSitemapXml(xml: string): SitemapParseResult {
|
|
11
|
+
export function parseSitemapXml(xml: string): SitemapParseResult {
|
|
12
12
|
const urls: string[] = [];
|
|
13
13
|
const childSitemaps: string[] = [];
|
|
14
14
|
|
|
@@ -28,7 +28,7 @@ function parseSitemapXml(xml: string): SitemapParseResult {
|
|
|
28
28
|
return { urls, isSitemapIndex, childSitemaps };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async function fetchSitemap(sitemapUrl: string): Promise<SitemapParseResult> {
|
|
31
|
+
export async function fetchSitemap(sitemapUrl: string): Promise<SitemapParseResult> {
|
|
32
32
|
const res = await fetch(sitemapUrl, {
|
|
33
33
|
headers: { "User-Agent": "Pagesight/1.0" },
|
|
34
34
|
});
|
|
@@ -41,7 +41,7 @@ async function fetchSitemap(sitemapUrl: string): Promise<SitemapParseResult> {
|
|
|
41
41
|
return parseSitemapXml(xml);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function sampleUrls(urls: string[], count: number, strategy: string): string[] {
|
|
44
|
+
export function sampleUrls(urls: string[], count: number, strategy: string): string[] {
|
|
45
45
|
if (urls.length <= count) return [...urls];
|
|
46
46
|
|
|
47
47
|
if (strategy === "first") {
|
|
@@ -66,7 +66,7 @@ function sampleUrls(urls: string[], count: number, strategy: string): string[] {
|
|
|
66
66
|
return shuffled.slice(0, count);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
interface InspectionSummary {
|
|
69
|
+
export interface InspectionSummary {
|
|
70
70
|
url: string;
|
|
71
71
|
verdict: string;
|
|
72
72
|
coverageState: string;
|
|
@@ -78,7 +78,7 @@ interface InspectionSummary {
|
|
|
78
78
|
error: string | null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
async function inspectSingle(url: string, siteUrl: string): Promise<InspectionSummary> {
|
|
81
|
+
export async function inspectSingle(url: string, siteUrl: string): Promise<InspectionSummary> {
|
|
82
82
|
try {
|
|
83
83
|
const r = await inspectUrl(url, siteUrl);
|
|
84
84
|
const idx = r.indexStatusResult;
|