pagesight 0.10.0 → 0.12.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 +36 -34
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/tools/audit.ts +61 -1
- package/src/tools/inspect.ts +5 -0
- package/src/tools/links.ts +183 -0
- package/src/tools/metatags.ts +112 -12
- package/src/tools/pagespeed.ts +284 -13
- package/src/tools/sample-inspect.ts +6 -6
package/README.md
CHANGED
|
@@ -8,26 +8,10 @@ See your site the way search engines and AI see it.
|
|
|
8
8
|
npm install pagesight
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
## Tools
|
|
14
|
-
|
|
15
|
-
| Tool | What it does |
|
|
16
|
-
|------|-------------|
|
|
17
|
-
| `audit` | One-call site audit. Runs pagespeed + metatags + robots + sitemaps + inspect in parallel. Returns prioritized findings. |
|
|
18
|
-
| `pagespeed` | Lighthouse scores, Core Web Vitals, opportunities with resource URLs, failing audits with selectors and fix links. |
|
|
19
|
-
| `metatags` | OG, Twitter Card, canonical, JSON-LD with schema validation, redirect chain, image validation. |
|
|
20
|
-
| `inspect` | Google index status, canonical choice, crawl state, rich results. |
|
|
21
|
-
| `sample_inspect` | Sample URLs from a sitemap and batch-inspect via GSC. Diagnoses indexing patterns. |
|
|
22
|
-
| `performance` | Search analytics — clicks, impressions, CTR, position. `compare: true` for period-over-period deltas. |
|
|
23
|
-
| `crux` | Real-world Core Web Vitals from Chrome users (p75, histograms). |
|
|
24
|
-
| `crux_history` | CWV trends over time — up to 40 weekly data points. |
|
|
25
|
-
| `robots` | robots.txt validation (RFC 9309) + AI crawler audit (139+ bots). |
|
|
26
|
-
| `sitemaps` | GSC properties and sitemaps with submitted/indexed counts. |
|
|
27
|
-
| `setup` | Auth status and OAuth setup. |
|
|
11
|
+
Your AI assistant can write your code. Now it can see your site. Index status, performance, real-user metrics, search traffic, meta tags, structured data, AI crawler access — one package, one call.
|
|
28
12
|
|
|
29
13
|
```
|
|
30
|
-
=== Site Audit: https://
|
|
14
|
+
=== Site Audit: https://example.com ===
|
|
31
15
|
|
|
32
16
|
HIGH Missing canonical URL
|
|
33
17
|
HIGH 7,772 sitemap URLs submitted, 0 indexed
|
|
@@ -37,22 +21,25 @@ LOW Missing Twitter Card tags
|
|
|
37
21
|
LOW No structured data (JSON-LD) found
|
|
38
22
|
```
|
|
39
23
|
|
|
40
|
-
##
|
|
41
|
-
|
|
42
|
-
1. [Google Cloud Console](https://console.cloud.google.com/) — enable Search Console API, PageSpeed Insights API, Chrome UX Report API
|
|
43
|
-
2. Create OAuth client ID (Desktop app) + API key
|
|
44
|
-
3. Configure:
|
|
24
|
+
## Tools
|
|
45
25
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
26
|
+
| Tool | What it does |
|
|
27
|
+
|------|-------------|
|
|
28
|
+
| `audit` | One-call site audit. Runs all checks in parallel. Returns prioritized findings. |
|
|
29
|
+
| `pagespeed` | Lighthouse scores, Core Web Vitals, opportunities, failing audits with fix links. |
|
|
30
|
+
| `metatags` | OG, Twitter Card, canonical, JSON-LD with schema validation, redirect chain, image validation. |
|
|
31
|
+
| `inspect` | Google index status, canonical choice, crawl state, rich results. |
|
|
32
|
+
| `sample_inspect` | Sample URLs from a sitemap and batch-inspect. Diagnoses indexing patterns. |
|
|
33
|
+
| `performance` | Search analytics — clicks, impressions, CTR, position. `compare: true` for period-over-period. |
|
|
34
|
+
| `crux` | Real-user Core Web Vitals (p75, histograms). |
|
|
35
|
+
| `crux_history` | CWV trends over time — up to 40 weekly data points. |
|
|
36
|
+
| `robots` | robots.txt validation (RFC 9309) + AI crawler audit (139+ bots). |
|
|
37
|
+
| `sitemaps` | Search Console properties and sitemaps with submitted/indexed counts. |
|
|
38
|
+
| `setup` | Auth status and OAuth setup. |
|
|
52
39
|
|
|
53
|
-
|
|
40
|
+
## Setup
|
|
54
41
|
|
|
55
|
-
|
|
42
|
+
Add to your MCP config:
|
|
56
43
|
|
|
57
44
|
```json
|
|
58
45
|
{
|
|
@@ -71,15 +58,30 @@ GOOGLE_API_KEY=your-api-key
|
|
|
71
58
|
}
|
|
72
59
|
```
|
|
73
60
|
|
|
74
|
-
|
|
61
|
+
`robots`, `metatags`, and `pagespeed` work without credentials.
|
|
62
|
+
|
|
63
|
+
### Full setup
|
|
64
|
+
|
|
65
|
+
1. [Google Cloud Console](https://console.cloud.google.com/) — enable Search Console API, PageSpeed Insights API, Chrome UX Report API
|
|
66
|
+
2. Create OAuth client ID (Desktop app) + API key
|
|
67
|
+
3. Configure:
|
|
68
|
+
|
|
69
|
+
```env
|
|
70
|
+
GSC_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
|
71
|
+
GSC_CLIENT_SECRET=your-client-secret
|
|
72
|
+
GSC_REFRESH_TOKEN=your-refresh-token
|
|
73
|
+
GOOGLE_API_KEY=your-api-key
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Why Pagesight
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
Every data point comes from a verifiable source. Google's APIs, real Chrome users, RFC 9309, schema.org, a community-maintained bot registry. No invented scores. No rules we can't cite.
|
|
77
79
|
|
|
78
80
|
- **"Title must be under 60 characters"** — Gary Illyes: "an externally made-up metric."
|
|
79
81
|
- **"Only one H1 per page"** — John Mueller: "You can use H1 tags as often as you want."
|
|
80
82
|
- **"Minimum 300 words per page"** — Mueller: "not a quality factor."
|
|
81
83
|
|
|
82
|
-
Pagesight
|
|
84
|
+
Pagesight reports what the sources report. Nothing more.
|
|
83
85
|
|
|
84
86
|
## License
|
|
85
87
|
|
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/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" });
|
|
@@ -310,6 +343,33 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
310
343
|
}
|
|
311
344
|
}
|
|
312
345
|
addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
|
|
346
|
+
|
|
347
|
+
// Auto-drill-down: when indexing is low, sample-inspect to explain why
|
|
348
|
+
const indexPct = totalSubmitted > 0 ? (totalIndexed / totalSubmitted) * 100 : 100;
|
|
349
|
+
if (site_url && indexPct < 50 && sitemaps.length > 0) {
|
|
350
|
+
try {
|
|
351
|
+
const sitemapPath = (sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0]).path;
|
|
352
|
+
let parsed = await fetchSitemap(sitemapPath);
|
|
353
|
+
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
354
|
+
parsed = await fetchSitemap(parsed.childSitemaps[0]);
|
|
355
|
+
}
|
|
356
|
+
if (parsed.urls.length > 0) {
|
|
357
|
+
const sampled = sampleUrls(parsed.urls, 5, "spread");
|
|
358
|
+
const inspections = [];
|
|
359
|
+
for (const u of sampled) {
|
|
360
|
+
inspections.push(await inspectSingle(u, site_url));
|
|
361
|
+
}
|
|
362
|
+
const drillDown = formatDrillDown(inspections);
|
|
363
|
+
// Append drill-down to the sitemap finding
|
|
364
|
+
const sitemapFinding = findings.find((f) => f.source === "sitemaps" && f.severity === "HIGH");
|
|
365
|
+
if (sitemapFinding) {
|
|
366
|
+
sitemapFinding.message += `\n${drillDown}`;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
// Drill-down is best-effort — don't fail the audit
|
|
371
|
+
}
|
|
372
|
+
}
|
|
313
373
|
} else if (sitemapResult.status === "rejected") {
|
|
314
374
|
errors.push(`Sitemaps: ${sitemapResult.reason}`);
|
|
315
375
|
}
|
package/src/tools/inspect.ts
CHANGED
|
@@ -34,6 +34,11 @@ function formatInspection(url: string, siteUrl: string, r: InspectionResult): st
|
|
|
34
34
|
lines.push(`\nReferring URLs: ${idx.referringUrls.join(", ")}`);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
if (idx.verdict !== "PASS") {
|
|
38
|
+
const gscUrl = `https://search.google.com/search-console/inspect?resource_id=${encodeURIComponent(siteUrl)}&id=${encodeURIComponent(url)}`;
|
|
39
|
+
lines.push("", `→ This page is not indexed. Request indexing manually in Google Search Console:`, ` ${gscUrl}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
// Rich Results
|
|
38
43
|
if (r.richResultsResult) {
|
|
39
44
|
lines.push("", "--- Rich Results ---", "");
|
|
@@ -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
|
@@ -16,6 +16,10 @@ function scoreLabel(score: number | null): string {
|
|
|
16
16
|
return `${pct} (poor)`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function scorePct(score: number | null): number | null {
|
|
20
|
+
return score === null ? null : Math.round(score * 100);
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
function cwvRating(category: string): string {
|
|
20
24
|
if (category === "FAST") return "good";
|
|
21
25
|
if (category === "AVERAGE") return "needs improvement";
|
|
@@ -190,6 +194,8 @@ function formatFailingAudits(audits: Record<string, PsiAudit>, categoryRefs: str
|
|
|
190
194
|
return lines;
|
|
191
195
|
}
|
|
192
196
|
|
|
197
|
+
// --- Single URL formatting (existing) ---
|
|
198
|
+
|
|
193
199
|
function formatPagespeed(url: string, result: PsiResult): string {
|
|
194
200
|
const lhr = result.lighthouseResult;
|
|
195
201
|
const lines: string[] = [
|
|
@@ -274,12 +280,234 @@ function formatPagespeed(url: string, result: PsiResult): string {
|
|
|
274
280
|
return lines.join("\n");
|
|
275
281
|
}
|
|
276
282
|
|
|
283
|
+
// --- Batch formatting ---
|
|
284
|
+
|
|
285
|
+
function shortUrl(url: string, allUrls: string[]): string {
|
|
286
|
+
try {
|
|
287
|
+
const u = new URL(url);
|
|
288
|
+
const path = u.pathname + u.search;
|
|
289
|
+
const hasDuplicate = allUrls.some(
|
|
290
|
+
(other) => other !== url && new URL(other).pathname + new URL(other).search === path,
|
|
291
|
+
);
|
|
292
|
+
return hasDuplicate ? u.hostname + path : path;
|
|
293
|
+
} catch {
|
|
294
|
+
return url;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatDelta(a: number | null, b: number | null): string {
|
|
299
|
+
if (a === null || b === null) return "";
|
|
300
|
+
const diff = b - a;
|
|
301
|
+
if (diff === 0) return " (=)";
|
|
302
|
+
return diff > 0 ? ` (+${diff})` : ` (${diff})`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatBatchCompare(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
|
|
306
|
+
const [a, b] = results;
|
|
307
|
+
const lhrA = a.result.lighthouseResult;
|
|
308
|
+
const lhrB = b.result.lighthouseResult;
|
|
309
|
+
|
|
310
|
+
const lines: string[] = [
|
|
311
|
+
`=== PageSpeed Compare (${strategy}) ===`,
|
|
312
|
+
``,
|
|
313
|
+
`A: ${a.url}`,
|
|
314
|
+
`B: ${b.url}`,
|
|
315
|
+
`Lighthouse: ${lhrA.lighthouseVersion}`,
|
|
316
|
+
"",
|
|
317
|
+
"--- Scores ---",
|
|
318
|
+
"",
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
// Score comparison
|
|
322
|
+
const catIds = Object.keys(lhrA.categories);
|
|
323
|
+
for (const id of catIds) {
|
|
324
|
+
const catA = lhrA.categories[id];
|
|
325
|
+
const catB = lhrB.categories[id];
|
|
326
|
+
if (!catA || !catB) continue;
|
|
327
|
+
const pA = scorePct(catA.score);
|
|
328
|
+
const pB = scorePct(catB.score);
|
|
329
|
+
const delta = formatDelta(pA, pB);
|
|
330
|
+
lines.push(`${catA.title}: ${pA ?? "N/A"} → ${pB ?? "N/A"}${delta}`);
|
|
331
|
+
}
|
|
332
|
+
lines.push("");
|
|
333
|
+
|
|
334
|
+
// CWV comparison
|
|
335
|
+
const cwvIds = [
|
|
336
|
+
"first-contentful-paint",
|
|
337
|
+
"largest-contentful-paint",
|
|
338
|
+
"total-blocking-time",
|
|
339
|
+
"cumulative-layout-shift",
|
|
340
|
+
"speed-index",
|
|
341
|
+
"interactive",
|
|
342
|
+
];
|
|
343
|
+
const cwvLines: string[] = [];
|
|
344
|
+
for (const id of cwvIds) {
|
|
345
|
+
const auditA = lhrA.audits[id];
|
|
346
|
+
const auditB = lhrB.audits[id];
|
|
347
|
+
if (!auditA?.displayValue || !auditB?.displayValue) continue;
|
|
348
|
+
cwvLines.push(`${auditA.title}: ${auditA.displayValue} → ${auditB.displayValue}`);
|
|
349
|
+
}
|
|
350
|
+
if (cwvLines.length > 0) {
|
|
351
|
+
lines.push("--- Core Web Vitals (Lab) ---", "", ...cwvLines, "");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Opportunities unique to each / shared
|
|
355
|
+
const oppsA = collectOpportunityIds(lhrA.audits);
|
|
356
|
+
const oppsB = collectOpportunityIds(lhrB.audits);
|
|
357
|
+
const onlyA = [...oppsA].filter((id) => !oppsB.has(id));
|
|
358
|
+
const onlyB = [...oppsB].filter((id) => !oppsA.has(id));
|
|
359
|
+
const shared = [...oppsA].filter((id) => oppsB.has(id));
|
|
360
|
+
|
|
361
|
+
if (onlyA.length > 0) {
|
|
362
|
+
lines.push(`--- Opportunities (A only) ---`, "");
|
|
363
|
+
for (const id of onlyA) lines.push(` ${lhrA.audits[id].title}: ${lhrA.audits[id].displayValue ?? ""}`);
|
|
364
|
+
lines.push("");
|
|
365
|
+
}
|
|
366
|
+
if (onlyB.length > 0) {
|
|
367
|
+
lines.push(`--- Opportunities (B only) ---`, "");
|
|
368
|
+
for (const id of onlyB) lines.push(` ${lhrB.audits[id].title}: ${lhrB.audits[id].displayValue ?? ""}`);
|
|
369
|
+
lines.push("");
|
|
370
|
+
}
|
|
371
|
+
if (shared.length > 0) {
|
|
372
|
+
lines.push(`--- Shared Opportunities ---`, "");
|
|
373
|
+
for (const id of shared) {
|
|
374
|
+
lines.push(
|
|
375
|
+
` ${lhrA.audits[id].title}: ${lhrA.audits[id].displayValue ?? ""} → ${lhrB.audits[id].displayValue ?? ""}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
lines.push("");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const timeA = (lhrA.timing.total / 1000).toFixed(1);
|
|
382
|
+
const timeB = (lhrB.timing.total / 1000).toFixed(1);
|
|
383
|
+
lines.push(`Analysis took ${timeA}s + ${timeB}s`);
|
|
384
|
+
|
|
385
|
+
return lines.join("\n");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function formatBatchTable(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
|
|
389
|
+
const lines: string[] = [`=== Batch PageSpeed (${results.length} URLs, ${strategy}) ===`, ""];
|
|
390
|
+
|
|
391
|
+
// Collect all category IDs from first result
|
|
392
|
+
const catIds = Object.keys(results[0].result.lighthouseResult.categories);
|
|
393
|
+
const catNames = catIds.map((id) => results[0].result.lighthouseResult.categories[id].title);
|
|
394
|
+
|
|
395
|
+
// Score table
|
|
396
|
+
lines.push("--- Scores ---", "");
|
|
397
|
+
|
|
398
|
+
// Header
|
|
399
|
+
const urlCol = "URL";
|
|
400
|
+
const allUrls = results.map((r) => r.url);
|
|
401
|
+
const urlWidth = Math.max(urlCol.length, ...results.map((r) => shortUrl(r.url, allUrls).length));
|
|
402
|
+
const colWidth = Math.max(...catNames.map((n) => n.length), 4);
|
|
403
|
+
lines.push(`${urlCol.padEnd(urlWidth)} ${catNames.map((n) => n.padEnd(colWidth)).join(" ")}`);
|
|
404
|
+
|
|
405
|
+
// Rows
|
|
406
|
+
let bestPerf: { url: string; score: number } | null = null;
|
|
407
|
+
let worstPerf: { url: string; score: number } | null = null;
|
|
408
|
+
|
|
409
|
+
for (const { url, result } of results) {
|
|
410
|
+
const lhr = result.lighthouseResult;
|
|
411
|
+
const scores = catIds.map((id) => {
|
|
412
|
+
const s = scorePct(lhr.categories[id]?.score);
|
|
413
|
+
return s !== null ? String(s) : "N/A";
|
|
414
|
+
});
|
|
415
|
+
lines.push(`${shortUrl(url, allUrls).padEnd(urlWidth)} ${scores.map((s) => s.padEnd(colWidth)).join(" ")}`);
|
|
416
|
+
|
|
417
|
+
const perf = scorePct(lhr.categories.performance?.score);
|
|
418
|
+
if (perf !== null) {
|
|
419
|
+
if (!bestPerf || perf > bestPerf.score) bestPerf = { url: shortUrl(url, allUrls), score: perf };
|
|
420
|
+
if (!worstPerf || perf < worstPerf.score) worstPerf = { url: shortUrl(url, allUrls), score: perf };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
lines.push("");
|
|
425
|
+
if (bestPerf) lines.push(`Best: ${bestPerf.url} (${bestPerf.score})`);
|
|
426
|
+
if (worstPerf && worstPerf.url !== bestPerf?.url) lines.push(`Worst: ${worstPerf.url} (${worstPerf.score})`);
|
|
427
|
+
lines.push("");
|
|
428
|
+
|
|
429
|
+
// Shared opportunities across pages
|
|
430
|
+
const oppCounts = new Map<string, { title: string; count: number }>();
|
|
431
|
+
for (const { result } of results) {
|
|
432
|
+
for (const id of collectOpportunityIds(result.lighthouseResult.audits)) {
|
|
433
|
+
const existing = oppCounts.get(id);
|
|
434
|
+
if (existing) {
|
|
435
|
+
existing.count++;
|
|
436
|
+
} else {
|
|
437
|
+
oppCounts.set(id, { title: result.lighthouseResult.audits[id].title, count: 1 });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const sharedOpps = [...oppCounts.entries()].filter(([, v]) => v.count >= 2).sort((a, b) => b[1].count - a[1].count);
|
|
443
|
+
if (sharedOpps.length > 0) {
|
|
444
|
+
lines.push("--- Shared Opportunities ---", "");
|
|
445
|
+
for (const [, { title, count }] of sharedOpps.slice(0, 10)) {
|
|
446
|
+
lines.push(` ${title} (${count}/${results.length} pages)`);
|
|
447
|
+
}
|
|
448
|
+
lines.push("");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const totalTime = results.reduce((sum, r) => sum + r.result.lighthouseResult.timing.total, 0);
|
|
452
|
+
lines.push(`Total analysis time: ${(totalTime / 1000).toFixed(1)}s`);
|
|
453
|
+
|
|
454
|
+
return lines.join("\n");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function collectOpportunityIds(audits: Record<string, PsiAudit>): Set<string> {
|
|
458
|
+
const ids = new Set<string>();
|
|
459
|
+
for (const [id, audit] of Object.entries(audits)) {
|
|
460
|
+
if (audit.score === null || audit.score >= 1) continue;
|
|
461
|
+
const mode = audit.scoreDisplayMode;
|
|
462
|
+
const hasNumeric = audit.numericValue && audit.numericValue > 0;
|
|
463
|
+
if (mode === "metricSavings" || ((mode === "numeric" || mode === "binary") && hasNumeric)) {
|
|
464
|
+
if (hasNumeric || (audit.details?.items?.length ?? 0) > 0) {
|
|
465
|
+
ids.add(id);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return ids;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function runBatch(
|
|
473
|
+
urls: string[],
|
|
474
|
+
options: { strategy?: "mobile" | "desktop"; categories?: PsiCategoryType[]; locale?: string },
|
|
475
|
+
): Promise<Array<{ url: string; result?: PsiResult; error?: string }>> {
|
|
476
|
+
const concurrency = 2;
|
|
477
|
+
const results: Array<{ url: string; result?: PsiResult; error?: string }> = [];
|
|
478
|
+
let i = 0;
|
|
479
|
+
|
|
480
|
+
while (i < urls.length) {
|
|
481
|
+
const batch = urls.slice(i, i + concurrency);
|
|
482
|
+
const settled = await Promise.all(
|
|
483
|
+
batch.map(async (url) => {
|
|
484
|
+
try {
|
|
485
|
+
const result = await runPagespeed(url, options);
|
|
486
|
+
return { url, result };
|
|
487
|
+
} catch (err) {
|
|
488
|
+
return { url, error: err instanceof Error ? err.message : String(err) };
|
|
489
|
+
}
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
results.push(...settled);
|
|
493
|
+
i += concurrency;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return results;
|
|
497
|
+
}
|
|
498
|
+
|
|
277
499
|
export function registerPagespeedTool(server: McpServer): void {
|
|
278
500
|
server.tool(
|
|
279
501
|
"pagespeed",
|
|
280
|
-
"Analyze
|
|
502
|
+
"Analyze page performance using Google PageSpeed Insights. Accepts a single URL or multiple URLs (batch mode). With 2 URLs, returns a side-by-side comparison with deltas. With 3-10 URLs, returns a summary table with shared opportunities.",
|
|
281
503
|
{
|
|
282
|
-
url: z.string().url().describe("
|
|
504
|
+
url: z.string().url().optional().describe("Single URL to analyze. Use this OR urls, not both."),
|
|
505
|
+
urls: z
|
|
506
|
+
.array(z.string().url())
|
|
507
|
+
.min(2)
|
|
508
|
+
.max(10)
|
|
509
|
+
.optional()
|
|
510
|
+
.describe("Multiple URLs (2-10) for batch analysis. 2 URLs = compare mode, 3+ = summary table."),
|
|
283
511
|
strategy: z.enum(["mobile", "desktop"]).optional().describe("Device strategy. Default: 'mobile'."),
|
|
284
512
|
categories: z
|
|
285
513
|
.array(z.enum(["performance", "accessibility", "best-practices", "seo"]))
|
|
@@ -287,18 +515,61 @@ export function registerPagespeedTool(server: McpServer): void {
|
|
|
287
515
|
.describe("Lighthouse categories to run. Default: all four."),
|
|
288
516
|
locale: z.string().optional().describe("Locale for localized results (e.g., 'pt-BR', 'en')."),
|
|
289
517
|
},
|
|
290
|
-
async ({ url, strategy, categories, locale }) => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
518
|
+
async ({ url, urls, strategy, categories, locale }) => {
|
|
519
|
+
const strat = (strategy as "mobile" | "desktop") ?? "mobile";
|
|
520
|
+
const cats = categories as PsiCategoryType[] | undefined;
|
|
521
|
+
const opts = { strategy: strat, categories: cats, locale };
|
|
522
|
+
|
|
523
|
+
// Validate: must provide url or urls, not both
|
|
524
|
+
if (url && urls) {
|
|
525
|
+
return {
|
|
526
|
+
content: [{ type: "text", text: "Error: provide either 'url' (single) or 'urls' (batch), not both." }],
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (!url && !urls) {
|
|
530
|
+
return {
|
|
531
|
+
content: [{ type: "text", text: "Error: provide 'url' for single analysis or 'urls' for batch analysis." }],
|
|
532
|
+
};
|
|
301
533
|
}
|
|
534
|
+
|
|
535
|
+
// Single URL — existing behavior
|
|
536
|
+
if (url) {
|
|
537
|
+
try {
|
|
538
|
+
const result = await runPagespeed(url, opts);
|
|
539
|
+
return { content: [{ type: "text", text: formatPagespeed(url, result) }] };
|
|
540
|
+
} catch (err) {
|
|
541
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
542
|
+
return { content: [{ type: "text", text: `Error running PageSpeed analysis: ${msg}` }] };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Batch mode
|
|
547
|
+
const batchUrls = urls as string[];
|
|
548
|
+
const results = await runBatch(batchUrls, opts);
|
|
549
|
+
|
|
550
|
+
// Separate successes and failures
|
|
551
|
+
const successes = results.filter((r): r is { url: string; result: PsiResult } => !!r.result);
|
|
552
|
+
const failures = results.filter((r): r is { url: string; error: string } => !!r.error);
|
|
553
|
+
|
|
554
|
+
if (successes.length === 0) {
|
|
555
|
+
const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
|
|
556
|
+
return { content: [{ type: "text", text: `All URLs failed:\n${errorLines.join("\n")}` }] };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let output: string;
|
|
560
|
+
if (successes.length === 2) {
|
|
561
|
+
output = formatBatchCompare(successes, strat);
|
|
562
|
+
} else {
|
|
563
|
+
output = formatBatchTable(successes, strat);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Append any failures
|
|
567
|
+
if (failures.length > 0) {
|
|
568
|
+
const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
|
|
569
|
+
output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { content: [{ type: "text", text: output }] };
|
|
302
573
|
},
|
|
303
574
|
);
|
|
304
575
|
}
|
|
@@ -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;
|