pagesight 0.9.0 → 0.11.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 +43 -115
- package/package.json +1 -1
- package/src/tools/inspect.ts +5 -0
- package/src/tools/pagespeed.ts +304 -17
package/README.md
CHANGED
|
@@ -8,111 +8,45 @@ 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
|
-
### `inspect`
|
|
16
|
-
|
|
17
|
-
Ask Google: is this page indexed? What canonical did you choose? Any crawl errors? Structured data issues?
|
|
18
|
-
|
|
19
|
-
Returns index status, canonical (yours vs Google's), crawl status, rich results validation, sitemaps, and referring URLs.
|
|
20
|
-
|
|
21
|
-
### `pagespeed`
|
|
22
|
-
|
|
23
|
-
Run Google Lighthouse on any URL:
|
|
24
|
-
|
|
25
|
-
- **Scores**: performance, accessibility, best-practices, seo
|
|
26
|
-
- **Core Web Vitals (lab)**: FCP, LCP, TBT, CLS, Speed Index, TTI
|
|
27
|
-
- **CrUX field data**: real Chrome user metrics (page + origin)
|
|
28
|
-
- **Opportunities**: ranked by severity with potential savings
|
|
29
|
-
- **Strategy**: `mobile` or `desktop`
|
|
30
|
-
|
|
31
|
-
### `crux`
|
|
32
|
-
|
|
33
|
-
Real-world Core Web Vitals from Chrome users (28-day rolling window):
|
|
34
|
-
|
|
35
|
-
- **Metrics**: LCP, FCP, INP, CLS, TTFB, RTT, navigation types, form factors
|
|
36
|
-
- **Granularity**: by URL or origin, by device (DESKTOP, PHONE, TABLET)
|
|
37
|
-
- **Data**: p75 values + histogram distributions
|
|
38
|
-
|
|
39
|
-
### `crux_history`
|
|
40
|
-
|
|
41
|
-
Core Web Vitals trends over time — up to 40 weekly data points (~10 months):
|
|
42
|
-
|
|
43
|
-
- Trend detection (improved/stable/worse) with percentage change
|
|
44
|
-
- Recent data points table for core metrics
|
|
45
|
-
- Custom period count (1-40)
|
|
46
|
-
|
|
47
|
-
### `performance`
|
|
48
|
-
|
|
49
|
-
Google Search Console search analytics:
|
|
50
|
-
|
|
51
|
-
- **Dimensions**: `query`, `page`, `country`, `device`, `date`, `searchAppearance`, `hour`
|
|
52
|
-
- **Search types**: `web`, `image`, `video`, `news`, `discover`, `googleNews`
|
|
53
|
-
- **Filters**: `equals`, `contains`, `notEquals`, `notContains`, `includingRegex`, `excludingRegex`
|
|
54
|
-
- **Pagination**: up to 25,000 rows
|
|
55
|
-
|
|
56
|
-
### `robots`
|
|
57
|
-
|
|
58
|
-
Analyze any site's robots.txt:
|
|
59
|
-
|
|
60
|
-
- **Syntax validation** per [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309)
|
|
61
|
-
- **AI crawler audit** — 139+ bots from the [ai-robots-txt](https://github.com/ai-robots-txt/ai.robots.txt) community registry
|
|
62
|
-
- **Bot categories**: training scrapers, AI search crawlers, AI assistants, AI agents
|
|
63
|
-
- **Per-bot status**: blocked or allowed, with the matched rule
|
|
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.
|
|
64
12
|
|
|
65
13
|
```
|
|
66
|
-
===
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
14
|
+
=== Site Audit: https://example.com ===
|
|
15
|
+
|
|
16
|
+
HIGH Missing canonical URL
|
|
17
|
+
HIGH 7,772 sitemap URLs submitted, 0 indexed
|
|
18
|
+
MEDIUM Missing og:image — no social preview image
|
|
19
|
+
MEDIUM Accessibility score: 89/100
|
|
20
|
+
LOW Missing Twitter Card tags
|
|
21
|
+
LOW No structured data (JSON-LD) found
|
|
72
22
|
```
|
|
73
23
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
Search Console properties and sitemaps (read-only):
|
|
77
|
-
|
|
78
|
-
- `list_sites` — all properties with permission level
|
|
79
|
-
- `get_site` — details for a specific property
|
|
80
|
-
- `list_sitemaps` — sitemaps with error/warning counts
|
|
81
|
-
- `get_sitemap` — full details for a specific sitemap
|
|
82
|
-
|
|
83
|
-
### `setup`
|
|
24
|
+
## Tools
|
|
84
25
|
|
|
85
|
-
|
|
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. |
|
|
86
39
|
|
|
87
40
|
## Setup
|
|
88
41
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
92
|
-
2. Create a project (or use existing)
|
|
93
|
-
3. Enable: **Search Console API**, **PageSpeed Insights API**, **Chrome UX Report API**
|
|
94
|
-
4. Create **OAuth client ID** (Desktop app) — for Search Console
|
|
95
|
-
5. Create **API key** — for PageSpeed and CrUX
|
|
96
|
-
|
|
97
|
-
### 2. Configure
|
|
98
|
-
|
|
99
|
-
```env
|
|
100
|
-
GSC_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
|
101
|
-
GSC_CLIENT_SECRET=your-client-secret
|
|
102
|
-
GSC_REFRESH_TOKEN=your-refresh-token
|
|
103
|
-
GOOGLE_API_KEY=your-api-key
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
The `robots` tool works without any credentials.
|
|
107
|
-
|
|
108
|
-
### 3. Use with your AI assistant
|
|
42
|
+
Add to your MCP config:
|
|
109
43
|
|
|
110
44
|
```json
|
|
111
45
|
{
|
|
112
46
|
"mcpServers": {
|
|
113
47
|
"pagesight": {
|
|
114
|
-
"command": "
|
|
115
|
-
"args": ["
|
|
48
|
+
"command": "npx",
|
|
49
|
+
"args": ["pagesight"],
|
|
116
50
|
"env": {
|
|
117
51
|
"GSC_CLIENT_ID": "your-client-id",
|
|
118
52
|
"GSC_CLIENT_SECRET": "your-secret",
|
|
@@ -124,36 +58,30 @@ The `robots` tool works without any credentials.
|
|
|
124
58
|
}
|
|
125
59
|
```
|
|
126
60
|
|
|
127
|
-
|
|
61
|
+
`robots`, `metatags`, and `pagespeed` work without credentials.
|
|
128
62
|
|
|
129
|
-
|
|
130
|
-
"Is https://mysite.com indexed?"
|
|
131
|
-
"Run pagespeed on my homepage"
|
|
132
|
-
"Which AI crawlers can access my site?"
|
|
133
|
-
"How have my Core Web Vitals changed?"
|
|
134
|
-
"Which queries bring traffic to this page?"
|
|
135
|
-
```
|
|
63
|
+
### Full setup
|
|
136
64
|
|
|
137
|
-
|
|
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:
|
|
138
68
|
|
|
139
|
-
|
|
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
|
+
```
|
|
140
75
|
|
|
141
|
-
|
|
142
|
-
- **"Meta description must be 155 characters"** — Google: "there's no limit on how long a meta description can be."
|
|
143
|
-
- **"Only one H1 per page"** — John Mueller: "You can use H1 tags as often as you want. There's no limit."
|
|
144
|
-
- **"Minimum 300 words per page"** — Mueller: "the number of words on a page is not a quality factor."
|
|
145
|
-
- **"Text-to-HTML ratio matters"** — Mueller: "it makes absolutely no sense at all for SEO."
|
|
76
|
+
## Why Pagesight
|
|
146
77
|
|
|
147
|
-
|
|
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.
|
|
148
79
|
|
|
149
|
-
|
|
80
|
+
- **"Title must be under 60 characters"** — Gary Illyes: "an externally made-up metric."
|
|
81
|
+
- **"Only one H1 per page"** — John Mueller: "You can use H1 tags as often as you want."
|
|
82
|
+
- **"Minimum 300 words per page"** — Mueller: "not a quality factor."
|
|
150
83
|
|
|
151
|
-
|
|
152
|
-
bun install
|
|
153
|
-
bun run start # start server
|
|
154
|
-
bun run lint # biome check
|
|
155
|
-
bun run format # biome format
|
|
156
|
-
```
|
|
84
|
+
Pagesight reports what the sources report. Nothing more.
|
|
157
85
|
|
|
158
86
|
## License
|
|
159
87
|
|
package/package.json
CHANGED
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 ---", "");
|
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";
|
|
@@ -91,7 +95,7 @@ function formatOpportunities(audits: Record<string, PsiAudit>): string[] {
|
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
function formatDiagnostics(audits: Record<string, PsiAudit>): string[] {
|
|
94
|
-
const failing:
|
|
98
|
+
const failing: PsiAudit[] = [];
|
|
95
99
|
|
|
96
100
|
for (const audit of Object.values(audits)) {
|
|
97
101
|
if (
|
|
@@ -100,15 +104,31 @@ function formatDiagnostics(audits: Record<string, PsiAudit>): string[] {
|
|
|
100
104
|
(audit.scoreDisplayMode === "numeric" || audit.scoreDisplayMode === "metricSavings") &&
|
|
101
105
|
audit.displayValue
|
|
102
106
|
) {
|
|
103
|
-
failing.push(
|
|
107
|
+
failing.push(audit);
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
if (failing.length === 0) return [];
|
|
108
112
|
|
|
113
|
+
failing.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
114
|
+
|
|
109
115
|
const lines: string[] = ["--- Diagnostics ---", ""];
|
|
110
|
-
for (const
|
|
111
|
-
lines.push(`${
|
|
116
|
+
for (const audit of failing.slice(0, 10)) {
|
|
117
|
+
lines.push(`${audit.title}: ${audit.displayValue}`);
|
|
118
|
+
|
|
119
|
+
const linkMatch = audit.description?.match(/\[.*?\]\((https?:\/\/[^)]+)\)/);
|
|
120
|
+
if (linkMatch) lines.push(` Learn more: ${linkMatch[1]}`);
|
|
121
|
+
|
|
122
|
+
const items = audit.details?.items;
|
|
123
|
+
if (items && items.length > 0) {
|
|
124
|
+
for (const item of items.slice(0, 3)) {
|
|
125
|
+
lines.push(...formatDetailItem(item));
|
|
126
|
+
}
|
|
127
|
+
if (items.length > 3) {
|
|
128
|
+
lines.push(` ... and ${items.length - 3} more`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
lines.push("");
|
|
112
132
|
}
|
|
113
133
|
|
|
114
134
|
return lines;
|
|
@@ -174,6 +194,8 @@ function formatFailingAudits(audits: Record<string, PsiAudit>, categoryRefs: str
|
|
|
174
194
|
return lines;
|
|
175
195
|
}
|
|
176
196
|
|
|
197
|
+
// --- Single URL formatting (existing) ---
|
|
198
|
+
|
|
177
199
|
function formatPagespeed(url: string, result: PsiResult): string {
|
|
178
200
|
const lhr = result.lighthouseResult;
|
|
179
201
|
const lines: string[] = [
|
|
@@ -258,12 +280,234 @@ function formatPagespeed(url: string, result: PsiResult): string {
|
|
|
258
280
|
return lines.join("\n");
|
|
259
281
|
}
|
|
260
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
|
+
|
|
261
499
|
export function registerPagespeedTool(server: McpServer): void {
|
|
262
500
|
server.tool(
|
|
263
501
|
"pagespeed",
|
|
264
|
-
"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.",
|
|
265
503
|
{
|
|
266
|
-
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."),
|
|
267
511
|
strategy: z.enum(["mobile", "desktop"]).optional().describe("Device strategy. Default: 'mobile'."),
|
|
268
512
|
categories: z
|
|
269
513
|
.array(z.enum(["performance", "accessibility", "best-practices", "seo"]))
|
|
@@ -271,18 +515,61 @@ export function registerPagespeedTool(server: McpServer): void {
|
|
|
271
515
|
.describe("Lighthouse categories to run. Default: all four."),
|
|
272
516
|
locale: z.string().optional().describe("Locale for localized results (e.g., 'pt-BR', 'en')."),
|
|
273
517
|
},
|
|
274
|
-
async ({ url, strategy, categories, locale }) => {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
};
|
|
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
|
+
}
|
|
285
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 }] };
|
|
286
573
|
},
|
|
287
574
|
);
|
|
288
575
|
}
|