pagesight 0.16.0 → 0.18.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.
Files changed (70) hide show
  1. package/README.md +17 -83
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/snapshots.md +123 -0
  5. package/docs/usage.md +130 -0
  6. package/package.json +26 -29
  7. package/src/api/bing.ts +83 -0
  8. package/src/api/compare-snapshots.ts +377 -0
  9. package/src/api/discover.ts +39 -0
  10. package/src/api/doctor.ts +35 -0
  11. package/src/api/evidence-schema.ts +78 -0
  12. package/src/api/evidence.ts +99 -0
  13. package/src/api/execute.ts +147 -0
  14. package/src/api/index.ts +5 -0
  15. package/src/api/reports.ts +90 -0
  16. package/src/api/schema.ts +207 -0
  17. package/src/api/snapshot.ts +183 -0
  18. package/src/api/ui-findings.ts +68 -0
  19. package/src/cli.ts +182 -0
  20. package/src/http.ts +39 -0
  21. package/src/index.ts +8 -25
  22. package/src/mcp-server.ts +27 -0
  23. package/src/mcp.ts +5 -0
  24. package/src/providers/bing.ts +48 -0
  25. package/src/{lib → providers}/crux.ts +4 -9
  26. package/src/providers/ga.ts +114 -0
  27. package/src/providers/google-tokens.ts +86 -0
  28. package/src/providers/gsc-auth.ts +93 -0
  29. package/src/{lib → providers}/gsc.ts +27 -24
  30. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  31. package/src/shared/dates.ts +32 -0
  32. package/src/shared/http.ts +63 -0
  33. package/src/tools/ai.ts +19 -27
  34. package/src/tools/audit.ts +26 -97
  35. package/src/tools/observe.ts +28 -0
  36. package/src/tools/page/analyze.ts +194 -0
  37. package/src/tools/page/batch.ts +163 -0
  38. package/src/tools/page/contrast.ts +128 -0
  39. package/src/tools/page/links.ts +200 -0
  40. package/src/tools/page/metadata.ts +225 -0
  41. package/src/tools/page/structured-data.ts +288 -0
  42. package/src/tools/page/tool.ts +48 -0
  43. package/src/tools/search/actions.ts +56 -0
  44. package/src/tools/search/analytics.ts +266 -0
  45. package/src/tools/search/coverage.ts +247 -0
  46. package/src/tools/search/gaps.ts +129 -0
  47. package/src/tools/search/inspection.ts +160 -0
  48. package/src/tools/search/result.ts +3 -0
  49. package/src/tools/search/sample.ts +110 -0
  50. package/src/tools/search/schema.ts +62 -0
  51. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  52. package/src/tools/search/sites.ts +86 -0
  53. package/src/tools/search/tool.ts +11 -0
  54. package/src/tools/setup.ts +59 -20
  55. package/src/tools/speed/analyze.ts +191 -0
  56. package/src/tools/speed/batch.ts +265 -0
  57. package/src/tools/speed/crux.ts +176 -0
  58. package/src/tools/speed/pagespeed.ts +273 -0
  59. package/src/tools/speed/schema.ts +39 -0
  60. package/src/tools/speed/tool.ts +11 -0
  61. package/src/web/fetch.ts +31 -0
  62. package/src/web/images.ts +62 -0
  63. package/src/web/page-observation.ts +69 -0
  64. package/src/{lib → web}/robots.ts +20 -12
  65. package/src/web/sitemap-inventory.ts +64 -0
  66. package/src/web/sitemap-parser.ts +59 -0
  67. package/src/lib/auth.ts +0 -187
  68. package/src/tools/page.ts +0 -1241
  69. package/src/tools/search.ts +0 -852
  70. package/src/tools/speed.ts +0 -956
@@ -1,956 +0,0 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { z } from "zod";
3
- import {
4
- type CruxFormFactor,
5
- type CruxHistoryResponse,
6
- type CruxResponse,
7
- queryCrux,
8
- queryCruxHistory,
9
- } from "../lib/crux.js";
10
- import {
11
- hasApiKey,
12
- type PsiAudit,
13
- type PsiAuditDetailItem,
14
- type PsiCategoryType,
15
- type PsiResult,
16
- runPagespeed,
17
- } from "../lib/psi.js";
18
-
19
- // --- PageSpeed helpers ---
20
-
21
- const QUOTA_NOTE =
22
- "\n\nNote: No GOOGLE_API_KEY configured — using shared quota (400 req/day). Set your own key to avoid rate limits.";
23
-
24
- function scoreLabel(score: number | null): string {
25
- if (score === null) return "N/A";
26
- const pct = Math.round(score * 100);
27
- if (pct >= 90) return `${pct} (good)`;
28
- if (pct >= 50) return `${pct} (needs improvement)`;
29
- return `${pct} (poor)`;
30
- }
31
-
32
- function scorePct(score: number | null): number | null {
33
- return score === null ? null : Math.round(score * 100);
34
- }
35
-
36
- function cwvRating(category: string): string {
37
- if (category === "FAST") return "good";
38
- if (category === "AVERAGE") return "needs improvement";
39
- if (category === "SLOW") return "poor";
40
- return category;
41
- }
42
-
43
- function formatLoadingExperience(label: string, exp: PsiResult["loadingExperience"]): string[] {
44
- if (!exp?.metrics || Object.keys(exp.metrics).length === 0) return [];
45
-
46
- const lines: string[] = [`--- ${label} (CrUX Field Data) ---`, ""];
47
- lines.push(`Overall: ${cwvRating(exp.overall_category)}`, "");
48
-
49
- const metricNames: Record<string, string> = {
50
- CUMULATIVE_LAYOUT_SHIFT_SCORE: "CLS",
51
- EXPERIMENTAL_TIME_TO_FIRST_BYTE: "TTFB",
52
- FIRST_CONTENTFUL_PAINT_MS: "FCP",
53
- FIRST_INPUT_DELAY_MS: "FID",
54
- INTERACTION_TO_NEXT_PAINT: "INP",
55
- LARGEST_CONTENTFUL_PAINT_MS: "LCP",
56
- };
57
-
58
- for (const [key, metric] of Object.entries(exp.metrics)) {
59
- const name = metricNames[key] ?? key;
60
- const unit = key.includes("LAYOUT_SHIFT") ? "" : "ms";
61
- const value = key.includes("LAYOUT_SHIFT") ? (metric.percentile / 100).toFixed(2) : `${metric.percentile}${unit}`;
62
- lines.push(`${name}: ${value} (${cwvRating(metric.category)})`);
63
- }
64
-
65
- return lines;
66
- }
67
-
68
- function formatOpportunities(audits: Record<string, PsiAudit>): string[] {
69
- const opportunities: PsiAudit[] = [];
70
-
71
- for (const audit of Object.values(audits)) {
72
- if (audit.score === null || audit.score >= 1) continue;
73
- const mode = audit.scoreDisplayMode;
74
- const hasItems = (audit.details?.items?.length ?? 0) > 0;
75
- const hasNumeric = audit.numericValue && audit.numericValue > 0;
76
- if (mode === "metricSavings" || ((mode === "numeric" || mode === "binary") && hasNumeric)) {
77
- if (hasNumeric || hasItems) {
78
- opportunities.push(audit);
79
- }
80
- }
81
- }
82
-
83
- if (opportunities.length === 0) return [];
84
-
85
- opportunities.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
86
-
87
- const lines: string[] = ["--- Opportunities ---", ""];
88
- for (const audit of opportunities.slice(0, 10)) {
89
- const severity = (audit.score ?? 0) < 0.5 ? "HIGH" : (audit.score ?? 0) < 0.9 ? "MEDIUM" : "LOW";
90
- const unit = audit.numericUnit === "millisecond" ? "ms" : audit.numericUnit === "byte" ? " bytes" : "";
91
- const savings = audit.displayValue ?? `${Math.round(audit.numericValue ?? 0)}${unit}`;
92
- lines.push(`${severity} ${audit.title}`);
93
- lines.push(` Potential savings: ${savings}`);
94
-
95
- const items = audit.details?.items;
96
- if (items && items.length > 0) {
97
- for (const item of items.slice(0, 3)) {
98
- lines.push(...formatDetailItem(item));
99
- }
100
- if (items.length > 3) {
101
- lines.push(` ... and ${items.length - 3} more resources`);
102
- }
103
- }
104
- lines.push("");
105
- }
106
-
107
- return lines;
108
- }
109
-
110
- function formatDiagnostics(audits: Record<string, PsiAudit>): string[] {
111
- const failing: PsiAudit[] = [];
112
-
113
- for (const audit of Object.values(audits)) {
114
- if (
115
- audit.score !== null &&
116
- audit.score < 0.5 &&
117
- (audit.scoreDisplayMode === "numeric" || audit.scoreDisplayMode === "metricSavings") &&
118
- audit.displayValue
119
- ) {
120
- failing.push(audit);
121
- }
122
- }
123
-
124
- if (failing.length === 0) return [];
125
-
126
- failing.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
127
-
128
- const lines: string[] = ["--- Diagnostics ---", ""];
129
- for (const audit of failing.slice(0, 10)) {
130
- lines.push(`${audit.title}: ${audit.displayValue}`);
131
-
132
- const linkMatch = audit.description?.match(/\[.*?\]\((https?:\/\/[^)]+)\)/);
133
- if (linkMatch) lines.push(` Learn more: ${linkMatch[1]}`);
134
-
135
- const items = audit.details?.items;
136
- if (items && items.length > 0) {
137
- for (const item of items.slice(0, 3)) {
138
- lines.push(...formatDetailItem(item));
139
- }
140
- if (items.length > 3) {
141
- lines.push(` ... and ${items.length - 3} more`);
142
- }
143
- }
144
- lines.push("");
145
- }
146
-
147
- return lines;
148
- }
149
-
150
- function formatDetailItem(item: PsiAuditDetailItem): string[] {
151
- const lines: string[] = [];
152
-
153
- if (item.node) {
154
- const n = item.node;
155
- if (n.nodeLabel) lines.push(` Element: "${n.nodeLabel}"`);
156
- if (n.selector) lines.push(` Selector: ${n.selector}`);
157
- if (n.snippet) lines.push(` HTML: ${n.snippet}`);
158
- if (n.explanation) lines.push(` Issue: ${n.explanation}`);
159
- } else if (item.url) {
160
- const parts = [` ${item.url}`];
161
- if (item.wastedMs) parts.push(`wastedMs=${Math.round(item.wastedMs)}`);
162
- if (item.wastedBytes) parts.push(`wastedBytes=${Math.round(item.wastedBytes)}`);
163
- if (item.totalBytes) parts.push(`totalBytes=${Math.round(item.totalBytes)}`);
164
- lines.push(parts.join(" "));
165
- }
166
-
167
- return lines;
168
- }
169
-
170
- function formatFailingAudits(audits: Record<string, PsiAudit>, categoryRefs: string[]): string[] {
171
- const failing: PsiAudit[] = [];
172
-
173
- for (const ref of categoryRefs) {
174
- const audit = audits[ref];
175
- if (audit && audit.score !== null && audit.score < 1) {
176
- failing.push(audit);
177
- }
178
- }
179
-
180
- if (failing.length === 0) return [];
181
-
182
- failing.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
183
-
184
- const lines: string[] = [];
185
- for (const audit of failing) {
186
- const severity = audit.score === 0 ? "FAIL" : (audit.score ?? 0) < 0.5 ? "WARN" : "INFO";
187
- lines.push(`[${severity}] ${audit.title}`);
188
- if (audit.displayValue) lines.push(` Value: ${audit.displayValue}`);
189
-
190
- // Extract learn-more URL from description markdown
191
- const linkMatch = audit.description?.match(/\[.*?\]\((https?:\/\/[^)]+)\)/);
192
- if (linkMatch) lines.push(` Learn more: ${linkMatch[1]}`);
193
-
194
- const items = audit.details?.items;
195
- if (items && items.length > 0) {
196
- const maxItems = 5;
197
- for (const item of items.slice(0, maxItems)) {
198
- lines.push(...formatDetailItem(item));
199
- }
200
- if (items.length > maxItems) {
201
- lines.push(` ... and ${items.length - maxItems} more`);
202
- }
203
- }
204
- lines.push("");
205
- }
206
-
207
- return lines;
208
- }
209
-
210
- function formatPagespeed(url: string, result: PsiResult): string {
211
- const lhr = result.lighthouseResult;
212
- const lines: string[] = [
213
- `=== PageSpeed: ${url} ===`,
214
- `Strategy: ${lhr.configSettings.emulatedFormFactor}`,
215
- `Lighthouse: ${lhr.lighthouseVersion}`,
216
- `Analyzed: ${result.analysisUTCTimestamp}`,
217
- "",
218
- ];
219
-
220
- // Runtime errors
221
- if (lhr.runtimeError) {
222
- lines.push(`ERROR: ${lhr.runtimeError.code} — ${lhr.runtimeError.message}`, "");
223
- }
224
-
225
- // Warnings
226
- if (lhr.runWarnings && lhr.runWarnings.length > 0) {
227
- for (const w of lhr.runWarnings) {
228
- lines.push(`WARNING: ${w}`);
229
- }
230
- lines.push("");
231
- }
232
-
233
- // Category scores
234
- lines.push("--- Scores ---", "");
235
- for (const cat of Object.values(lhr.categories)) {
236
- lines.push(`${cat.title}: ${scoreLabel(cat.score)}`);
237
- }
238
- lines.push("");
239
-
240
- // Core Web Vitals from audits
241
- const cwvIds = [
242
- "first-contentful-paint",
243
- "largest-contentful-paint",
244
- "total-blocking-time",
245
- "cumulative-layout-shift",
246
- "speed-index",
247
- "interactive",
248
- ];
249
- const cwvLines: string[] = [];
250
- for (const id of cwvIds) {
251
- const audit = lhr.audits[id];
252
- if (audit?.displayValue) {
253
- cwvLines.push(`${audit.title}: ${audit.displayValue} ${scoreLabel(audit.score)}`);
254
- }
255
- }
256
- if (cwvLines.length > 0) {
257
- lines.push("--- Core Web Vitals (Lab) ---", "", ...cwvLines, "");
258
- }
259
-
260
- // CrUX field data
261
- const pageExp = formatLoadingExperience("Page", result.loadingExperience);
262
- if (pageExp.length > 0) lines.push(...pageExp, "");
263
-
264
- const originExp = formatLoadingExperience("Origin", result.originLoadingExperience);
265
- if (originExp.length > 0) lines.push(...originExp, "");
266
-
267
- // Opportunities
268
- const opps = formatOpportunities(lhr.audits);
269
- if (opps.length > 0) lines.push(...opps, "");
270
-
271
- // Diagnostics
272
- const diags = formatDiagnostics(lhr.audits);
273
- if (diags.length > 0) lines.push(...diags, "");
274
-
275
- // Failing audits per non-performance category (a11y, SEO, best practices)
276
- for (const cat of Object.values(lhr.categories)) {
277
- if (cat.id === "performance" || !cat.auditRefs) continue;
278
- const score = cat.score !== null ? Math.round(cat.score * 100) : null;
279
- if (score === null || score >= 100) continue;
280
-
281
- const refs = cat.auditRefs.map((r) => r.id);
282
- const details = formatFailingAudits(lhr.audits, refs);
283
- if (details.length > 0) {
284
- lines.push(`--- ${cat.title} Issues ---`, "", ...details);
285
- }
286
- }
287
-
288
- // Timing
289
- lines.push(`Analysis took ${(lhr.timing.total / 1000).toFixed(1)}s`);
290
-
291
- return lines.join("\n");
292
- }
293
-
294
- function shortUrl(url: string, allUrls: string[]): string {
295
- try {
296
- const u = new URL(url);
297
- const path = u.pathname + u.search;
298
- const hasDuplicate = allUrls.some(
299
- (other) => other !== url && new URL(other).pathname + new URL(other).search === path,
300
- );
301
- return hasDuplicate ? u.hostname + path : path;
302
- } catch {
303
- return url;
304
- }
305
- }
306
-
307
- function formatDelta(a: number | null, b: number | null): string {
308
- if (a === null || b === null) return "";
309
- const diff = b - a;
310
- if (diff === 0) return " (=)";
311
- return diff > 0 ? ` (+${diff})` : ` (${diff})`;
312
- }
313
-
314
- function formatBatchCompare(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
315
- if (results.length < 2) return "Error: compare requires at least 2 results.";
316
- const [a, b] = results;
317
- const lhrA = a.result.lighthouseResult;
318
- const lhrB = b.result.lighthouseResult;
319
-
320
- const lines: string[] = [
321
- `=== PageSpeed Compare (${strategy}) ===`,
322
- ``,
323
- `A: ${a.url}`,
324
- `B: ${b.url}`,
325
- `Lighthouse: ${lhrA.lighthouseVersion}`,
326
- "",
327
- "--- Scores ---",
328
- "",
329
- ];
330
-
331
- // Score comparison
332
- const catIds = Object.keys(lhrA.categories);
333
- for (const id of catIds) {
334
- const catA = lhrA.categories[id];
335
- const catB = lhrB.categories[id];
336
- if (!catA || !catB) continue;
337
- const pA = scorePct(catA.score);
338
- const pB = scorePct(catB.score);
339
- const delta = formatDelta(pA, pB);
340
- lines.push(`${catA.title}: ${pA ?? "N/A"} → ${pB ?? "N/A"}${delta}`);
341
- }
342
- lines.push("");
343
-
344
- // CWV comparison
345
- const cwvIds = [
346
- "first-contentful-paint",
347
- "largest-contentful-paint",
348
- "total-blocking-time",
349
- "cumulative-layout-shift",
350
- "speed-index",
351
- "interactive",
352
- ];
353
- const cwvLines: string[] = [];
354
- for (const id of cwvIds) {
355
- const auditA = lhrA.audits[id];
356
- const auditB = lhrB.audits[id];
357
- if (!auditA?.displayValue || !auditB?.displayValue) continue;
358
- cwvLines.push(`${auditA.title}: ${auditA.displayValue} → ${auditB.displayValue}`);
359
- }
360
- if (cwvLines.length > 0) {
361
- lines.push("--- Core Web Vitals (Lab) ---", "", ...cwvLines, "");
362
- }
363
-
364
- // Opportunities unique to each / shared
365
- const oppsA = collectOpportunityIds(lhrA.audits);
366
- const oppsB = collectOpportunityIds(lhrB.audits);
367
- const onlyA = [...oppsA].filter((id) => !oppsB.has(id));
368
- const onlyB = [...oppsB].filter((id) => !oppsA.has(id));
369
- const shared = [...oppsA].filter((id) => oppsB.has(id));
370
-
371
- if (onlyA.length > 0) {
372
- lines.push(`--- Opportunities (A only) ---`, "");
373
- for (const id of onlyA) lines.push(` ${lhrA.audits[id].title}: ${lhrA.audits[id].displayValue ?? ""}`);
374
- lines.push("");
375
- }
376
- if (onlyB.length > 0) {
377
- lines.push(`--- Opportunities (B only) ---`, "");
378
- for (const id of onlyB) lines.push(` ${lhrB.audits[id].title}: ${lhrB.audits[id].displayValue ?? ""}`);
379
- lines.push("");
380
- }
381
- if (shared.length > 0) {
382
- lines.push(`--- Shared Opportunities ---`, "");
383
- for (const id of shared) {
384
- lines.push(
385
- ` ${lhrA.audits[id].title}: ${lhrA.audits[id].displayValue ?? ""} → ${lhrB.audits[id].displayValue ?? ""}`,
386
- );
387
- }
388
- lines.push("");
389
- }
390
-
391
- const timeA = (lhrA.timing.total / 1000).toFixed(1);
392
- const timeB = (lhrB.timing.total / 1000).toFixed(1);
393
- lines.push(`Analysis took ${timeA}s + ${timeB}s`);
394
-
395
- return lines.join("\n");
396
- }
397
-
398
- function formatBatchTable(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
399
- if (results.length === 0) return "Error: no results to display.";
400
- const lines: string[] = [`=== Batch PageSpeed (${results.length} URLs, ${strategy}) ===`, ""];
401
-
402
- // Collect all category IDs from first result
403
- const catIds = Object.keys(results[0].result.lighthouseResult.categories);
404
- const catNames = catIds.map((id) => results[0].result.lighthouseResult.categories[id].title);
405
-
406
- // Score table
407
- lines.push("--- Scores ---", "");
408
-
409
- // Header
410
- const urlCol = "URL";
411
- const allUrls = results.map((r) => r.url);
412
- const urlWidth = Math.max(urlCol.length, ...results.map((r) => shortUrl(r.url, allUrls).length));
413
- const colWidth = Math.max(...catNames.map((n) => n.length), 4);
414
- lines.push(`${urlCol.padEnd(urlWidth)} ${catNames.map((n) => n.padEnd(colWidth)).join(" ")}`);
415
-
416
- // Rows
417
- let bestPerf: { url: string; score: number } | null = null;
418
- let worstPerf: { url: string; score: number } | null = null;
419
-
420
- for (const { url, result } of results) {
421
- const lhr = result.lighthouseResult;
422
- const scores = catIds.map((id) => {
423
- const s = scorePct(lhr.categories[id]?.score);
424
- return s !== null ? String(s) : "N/A";
425
- });
426
- lines.push(`${shortUrl(url, allUrls).padEnd(urlWidth)} ${scores.map((s) => s.padEnd(colWidth)).join(" ")}`);
427
-
428
- const perf = scorePct(lhr.categories.performance?.score);
429
- if (perf !== null) {
430
- if (!bestPerf || perf > bestPerf.score) bestPerf = { url: shortUrl(url, allUrls), score: perf };
431
- if (!worstPerf || perf < worstPerf.score) worstPerf = { url: shortUrl(url, allUrls), score: perf };
432
- }
433
- }
434
-
435
- lines.push("");
436
- if (bestPerf) lines.push(`Best: ${bestPerf.url} (${bestPerf.score})`);
437
- if (worstPerf && worstPerf.url !== bestPerf?.url) lines.push(`Worst: ${worstPerf.url} (${worstPerf.score})`);
438
- lines.push("");
439
-
440
- // Shared opportunities across pages
441
- const oppCounts = new Map<string, { title: string; count: number; maxSavings: string }>();
442
- for (const { result } of results) {
443
- for (const id of collectOpportunityIds(result.lighthouseResult.audits)) {
444
- const audit = result.lighthouseResult.audits[id];
445
- const savings = audit.displayValue ?? "";
446
- const existing = oppCounts.get(id);
447
- if (existing) {
448
- existing.count++;
449
- if (savings && (!existing.maxSavings || savings > existing.maxSavings)) existing.maxSavings = savings;
450
- } else {
451
- oppCounts.set(id, { title: audit.title, count: 1, maxSavings: savings });
452
- }
453
- }
454
- }
455
-
456
- const sharedOpps = [...oppCounts.entries()].filter(([, v]) => v.count >= 2).sort((a, b) => b[1].count - a[1].count);
457
- if (sharedOpps.length > 0) {
458
- lines.push("--- Shared Opportunities ---", "");
459
- for (const [, { title, count, maxSavings }] of sharedOpps.slice(0, 10)) {
460
- const cleaned = maxSavings.replace(/^Est savings of /i, "");
461
- const savingsStr = cleaned ? `, up to ${cleaned}` : "";
462
- lines.push(` ${title} (${count}/${results.length} pages${savingsStr})`);
463
- }
464
- lines.push("");
465
- }
466
-
467
- // A11y failures deduplicated across pages
468
- const a11yFailures = new Map<string, { title: string; count: number; pages: string[]; selectors: string[] }>();
469
- for (const { url, result } of results) {
470
- const lhr = result.lighthouseResult;
471
- const a11yCat = lhr.categories.accessibility;
472
- if (!a11yCat?.auditRefs) continue;
473
-
474
- for (const ref of a11yCat.auditRefs) {
475
- const audit = lhr.audits[ref.id];
476
- if (audit && audit.score !== null && audit.score < 1) {
477
- const page = shortUrl(url, allUrls);
478
- const selector = audit.details?.items?.[0]?.node?.selector ?? null;
479
- const existing = a11yFailures.get(ref.id);
480
- if (existing) {
481
- existing.count++;
482
- existing.pages.push(page);
483
- if (selector && !existing.selectors.includes(selector)) existing.selectors.push(selector);
484
- } else {
485
- a11yFailures.set(ref.id, {
486
- title: audit.title,
487
- count: 1,
488
- pages: [page],
489
- selectors: selector ? [selector] : [],
490
- });
491
- }
492
- }
493
- }
494
- }
495
-
496
- const sharedA11y = [...a11yFailures.entries()]
497
- .filter(([, v]) => v.count >= 2)
498
- .sort((a, b) => b[1].count - a[1].count);
499
- if (sharedA11y.length > 0) {
500
- lines.push("--- Accessibility Issues (shared) ---", "");
501
- for (const [, { title, count, pages, selectors }] of sharedA11y.slice(0, 10)) {
502
- lines.push(` ${title} (${count}/${results.length} pages: ${pages.join(", ")})`);
503
- if (selectors.length > 0) {
504
- lines.push(` Elements: ${selectors.slice(0, 3).join(", ")}`);
505
- }
506
- }
507
- lines.push("");
508
- }
509
-
510
- const totalTime = results.reduce((sum, r) => sum + r.result.lighthouseResult.timing.total, 0);
511
- lines.push(`Total analysis time: ${(totalTime / 1000).toFixed(1)}s`);
512
-
513
- return lines.join("\n");
514
- }
515
-
516
- function collectOpportunityIds(audits: Record<string, PsiAudit>): Set<string> {
517
- const ids = new Set<string>();
518
- for (const [id, audit] of Object.entries(audits)) {
519
- if (audit.score === null || audit.score >= 1) continue;
520
- const mode = audit.scoreDisplayMode;
521
- const hasNumeric = audit.numericValue && audit.numericValue > 0;
522
- if (mode === "metricSavings" || ((mode === "numeric" || mode === "binary") && hasNumeric)) {
523
- if (hasNumeric || (audit.details?.items?.length ?? 0) > 0) {
524
- ids.add(id);
525
- }
526
- }
527
- }
528
- return ids;
529
- }
530
-
531
- async function runBatch(
532
- urls: string[],
533
- options: { strategy?: "mobile" | "desktop"; categories?: PsiCategoryType[]; locale?: string },
534
- ): Promise<Array<{ url: string; result?: PsiResult; error?: string }>> {
535
- const concurrency = 2;
536
- const results: Array<{ url: string; result?: PsiResult; error?: string }> = [];
537
- let i = 0;
538
-
539
- while (i < urls.length) {
540
- const batch = urls.slice(i, i + concurrency);
541
- const settled = await Promise.all(
542
- batch.map(async (url) => {
543
- try {
544
- const result = await runPagespeed(url, options);
545
- return { url, result };
546
- } catch (err) {
547
- return { url, error: err instanceof Error ? err.message : String(err) };
548
- }
549
- }),
550
- );
551
- results.push(...settled);
552
- i += concurrency;
553
- }
554
-
555
- return results;
556
- }
557
-
558
- // --- CrUX helpers ---
559
-
560
- const METRIC_LABELS: Record<string, string> = {
561
- cumulative_layout_shift: "CLS",
562
- first_contentful_paint: "FCP",
563
- interaction_to_next_paint: "INP",
564
- largest_contentful_paint: "LCP",
565
- experimental_time_to_first_byte: "TTFB",
566
- round_trip_time: "RTT",
567
- navigation_types: "Navigation Types",
568
- form_factors: "Form Factors",
569
- largest_contentful_paint_image_element_render_delay: "LCP Image Render Delay",
570
- largest_contentful_paint_image_resource_load_delay: "LCP Image Load Delay",
571
- largest_contentful_paint_image_resource_load_duration: "LCP Image Load Duration",
572
- largest_contentful_paint_image_time_to_first_byte: "LCP Image TTFB",
573
- largest_contentful_paint_resource_type: "LCP Resource Type",
574
- };
575
-
576
- function formatDate(d: { year: number; month: number; day: number }): string {
577
- return `${d.year}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`;
578
- }
579
-
580
- function formatCrux(target: string, result: CruxResponse): string {
581
- const r = result.record;
582
- const period = r.collectionPeriod;
583
- const lines: string[] = [
584
- `=== CrUX: ${target} ===`,
585
- `Form factor: ${r.key.formFactor ?? "all"}`,
586
- `Period: ${formatDate(period.firstDate)} to ${formatDate(period.lastDate)}`,
587
- "",
588
- ];
589
-
590
- if (result.urlNormalizationDetails) {
591
- const norm = result.urlNormalizationDetails;
592
- if (norm.originalUrl !== norm.normalizedUrl) {
593
- lines.push(`Normalized: ${norm.originalUrl} → ${norm.normalizedUrl}`, "");
594
- }
595
- }
596
-
597
- lines.push("--- Metrics (p75) ---", "");
598
-
599
- for (const [key, metric] of Object.entries(r.metrics)) {
600
- const label = METRIC_LABELS[key] ?? key;
601
-
602
- if (metric.percentiles) {
603
- const val = metric.percentiles.p75;
604
- const unit = key === "cumulative_layout_shift" ? "" : "ms";
605
- lines.push(`${label}: ${val}${unit}`);
606
-
607
- if (metric.histogram) {
608
- const buckets = metric.histogram.map((b) => `${Math.round(b.density * 100)}%`).join(" / ");
609
- lines.push(` Distribution (good/needs improvement/poor): ${buckets}`);
610
- }
611
- } else if (metric.fractions) {
612
- lines.push(`${label}:`);
613
- for (const [fKey, fVal] of Object.entries(metric.fractions)) {
614
- lines.push(` ${fKey}: ${(fVal * 100).toFixed(1)}%`);
615
- }
616
- }
617
- }
618
-
619
- // Core Web Vitals assessment (Google ranking signal)
620
- const cwvMetrics = r.metrics;
621
- const lcp = cwvMetrics.largest_contentful_paint?.percentiles?.p75 as number | undefined;
622
- const inp = cwvMetrics.interaction_to_next_paint?.percentiles?.p75 as number | undefined;
623
- const cls = cwvMetrics.cumulative_layout_shift?.percentiles?.p75 as number | undefined;
624
-
625
- if (lcp !== undefined || inp !== undefined || cls !== undefined) {
626
- lines.push("", "--- Core Web Vitals Assessment ---", "");
627
- const lcpPass = lcp !== undefined && lcp <= 2500;
628
- const inpPass = inp !== undefined && inp <= 200;
629
- const clsPass = cls !== undefined && cls <= 0.1;
630
-
631
- if (lcp !== undefined)
632
- lines.push(`LCP: ${lcp}ms ${lcpPass ? "GOOD" : lcp <= 4000 ? "NEEDS IMPROVEMENT" : "POOR"} (threshold: 2500ms)`);
633
- if (inp !== undefined)
634
- lines.push(`INP: ${inp}ms ${inpPass ? "GOOD" : inp <= 500 ? "NEEDS IMPROVEMENT" : "POOR"} (threshold: 200ms)`);
635
- if (cls !== undefined)
636
- lines.push(`CLS: ${cls} ${clsPass ? "GOOD" : cls <= 0.25 ? "NEEDS IMPROVEMENT" : "POOR"} (threshold: 0.1)`);
637
-
638
- const allPresent = lcp !== undefined && inp !== undefined && cls !== undefined;
639
- if (allPresent) {
640
- const allPass = lcpPass && inpPass && clsPass;
641
- lines.push(
642
- "",
643
- allPass
644
- ? "Overall: PASS — all Core Web Vitals are good (positive ranking signal)"
645
- : "Overall: FAIL — not all Core Web Vitals pass (may affect rankings)",
646
- );
647
- }
648
- }
649
-
650
- return lines.join("\n");
651
- }
652
-
653
- function formatCruxHistory(target: string, result: CruxHistoryResponse): string {
654
- const r = result.record;
655
- const periods = r.collectionPeriods;
656
- const lines: string[] = [
657
- `=== CrUX History: ${target} ===`,
658
- `Form factor: ${r.key.formFactor ?? "all"}`,
659
- `Periods: ${periods.length} (${formatDate(periods[0].firstDate)} to ${formatDate(periods[periods.length - 1].lastDate)})`,
660
- "",
661
- ];
662
-
663
- if (result.urlNormalizationDetails) {
664
- const norm = result.urlNormalizationDetails;
665
- if (norm.originalUrl !== norm.normalizedUrl) {
666
- lines.push(`Normalized: ${norm.originalUrl} → ${norm.normalizedUrl}`, "");
667
- }
668
- }
669
-
670
- lines.push("--- p75 Trend ---", "");
671
-
672
- for (const [key, metric] of Object.entries(r.metrics)) {
673
- const label = METRIC_LABELS[key] ?? key;
674
-
675
- if (metric.percentilesTimeseries) {
676
- const values = metric.percentilesTimeseries.p75s;
677
- const first = values[0];
678
- const last = values[values.length - 1];
679
- const unit = key === "cumulative_layout_shift" ? "" : "ms";
680
-
681
- if (first === null && last === null) {
682
- lines.push(`${label}: insufficient data`);
683
- continue;
684
- }
685
-
686
- lines.push(
687
- `${label}: ${first ?? "N/A"}${first !== null ? unit : ""} → ${last ?? "N/A"}${last !== null ? unit : ""} (${values.length} points)`,
688
- );
689
-
690
- // Show trend direction
691
- if (first !== null && last !== null) {
692
- const f = Number(first);
693
- const l = Number(last);
694
- if (!Number.isNaN(f) && !Number.isNaN(l)) {
695
- const change = ((l - f) / f) * 100;
696
- const dir = change > 5 ? "worse" : change < -5 ? "improved" : "stable";
697
- lines.push(` Trend: ${change > 0 ? "+" : ""}${change.toFixed(1)}% (${dir})`);
698
- }
699
- }
700
- } else if (metric.fractionTimeseries) {
701
- lines.push(`${label}: (fraction timeseries, ${periods.length} points)`);
702
- for (const [fKey, fData] of Object.entries(metric.fractionTimeseries)) {
703
- const fracs = fData.fractions;
704
- const first = fracs[0];
705
- const last = fracs[fracs.length - 1];
706
- if (first !== null && last !== null && !Number.isNaN(first) && !Number.isNaN(last)) {
707
- lines.push(` ${fKey}: ${(first * 100).toFixed(1)}% → ${(last * 100).toFixed(1)}%`);
708
- }
709
- }
710
- }
711
- }
712
-
713
- // Show last 5 data points as table for core metrics
714
- const coreMetrics = ["largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift"];
715
- const available = coreMetrics.filter((m) => r.metrics[m]?.percentilesTimeseries);
716
-
717
- if (available.length > 0 && periods.length >= 5) {
718
- lines.push("", "--- Recent Data Points ---", "");
719
- const lastN = 5;
720
- const startIdx = periods.length - lastN;
721
-
722
- lines.push(`${"Date".padEnd(12)} ${available.map((m) => (METRIC_LABELS[m] ?? m).padEnd(10)).join(" ")}`);
723
- for (let i = startIdx; i < periods.length; i++) {
724
- const date = formatDate(periods[i].lastDate);
725
- const vals = available.map((m) => {
726
- const v = r.metrics[m].percentilesTimeseries?.p75s[i];
727
- return String(v ?? "N/A").padEnd(10);
728
- });
729
- lines.push(`${date.padEnd(12)} ${vals.join(" ")}`);
730
- }
731
- }
732
-
733
- return lines.join("\n");
734
- }
735
-
736
- // --- Unified speed tool ---
737
-
738
- export function registerSpeedTool(server: McpServer): void {
739
- server.tool(
740
- "speed",
741
- "Analyze site performance. Run PageSpeed Insights (lab metrics, Lighthouse scores, opportunities) for single or multiple URLs, or query Chrome UX Report for real-world field data and historical trends.",
742
- {
743
- action: z
744
- .enum(["pagespeed", "crux", "crux_history"])
745
- .optional()
746
- .describe(
747
- "Which analysis to run. Auto-detected: 'pagespeed' when url/urls provided, 'crux' when origin provided.",
748
- ),
749
- url: z.string().url().optional().describe("URL to analyze (PageSpeed or CrUX)."),
750
- urls: z
751
- .array(z.string().url())
752
- .min(2)
753
- .max(10)
754
- .optional()
755
- .describe("Multiple URLs (2-10) for batch PageSpeed. 2 = compare, 3+ = summary table."),
756
- strategy: z.enum(["mobile", "desktop"]).optional().describe("Device strategy for PageSpeed. Default: 'mobile'."),
757
- categories: z
758
- .array(z.enum(["performance", "accessibility", "best-practices", "seo"]))
759
- .optional()
760
- .describe("Lighthouse categories. Default: all four."),
761
- locale: z.string().optional().describe("Locale for PageSpeed results."),
762
- origin: z.string().optional().describe("Origin for CrUX data (e.g., 'https://example.com'). Triggers CrUX mode."),
763
- form_factor: z.enum(["DESKTOP", "PHONE", "TABLET"]).optional().describe("CrUX device filter."),
764
- metrics: z
765
- .array(
766
- z.enum([
767
- "cumulative_layout_shift",
768
- "first_contentful_paint",
769
- "interaction_to_next_paint",
770
- "largest_contentful_paint",
771
- "experimental_time_to_first_byte",
772
- "round_trip_time",
773
- "navigation_types",
774
- "form_factors",
775
- ]),
776
- )
777
- .optional()
778
- .describe("CrUX metrics to query."),
779
- periods: z.number().min(1).max(40).optional().describe("CrUX history periods (1-40). Default: 25."),
780
- },
781
- async ({ action, url, urls, strategy, categories, locale, origin, form_factor, metrics, periods }) => {
782
- // Determine which action to run
783
- let resolvedAction = action;
784
- if (!resolvedAction) {
785
- if (urls) {
786
- resolvedAction = "pagespeed";
787
- } else if (origin && !url) {
788
- resolvedAction = "crux";
789
- } else if (periods) {
790
- resolvedAction = "crux_history";
791
- } else {
792
- resolvedAction = "pagespeed";
793
- }
794
- }
795
-
796
- // --- PageSpeed action ---
797
- if (resolvedAction === "pagespeed") {
798
- const strat = (strategy as "mobile" | "desktop") ?? "mobile";
799
- const cats = categories as PsiCategoryType[] | undefined;
800
- const opts = { strategy: strat, categories: cats, locale };
801
-
802
- if (url && urls) {
803
- return {
804
- content: [
805
- { type: "text" as const, text: "Error: provide either 'url' (single) or 'urls' (batch), not both." },
806
- ],
807
- };
808
- }
809
- if (!url && !urls) {
810
- return {
811
- content: [
812
- { type: "text" as const, text: "Error: provide 'url' for single analysis or 'urls' for batch analysis." },
813
- ],
814
- };
815
- }
816
-
817
- // Single URL
818
- if (url) {
819
- try {
820
- const result = await runPagespeed(url, opts);
821
- const text = formatPagespeed(url, result) + (hasApiKey() ? "" : QUOTA_NOTE);
822
- return { content: [{ type: "text" as const, text }] };
823
- } catch (err) {
824
- const msg = err instanceof Error ? err.message : String(err);
825
- return { content: [{ type: "text" as const, text: `Error running PageSpeed analysis: ${msg}` }] };
826
- }
827
- }
828
-
829
- // Batch mode
830
- const batchUrls = urls as string[];
831
- const results = await runBatch(batchUrls, opts);
832
-
833
- const successes = results.filter((r): r is { url: string; result: PsiResult } => !!r.result);
834
- const failures = results.filter((r): r is { url: string; error: string } => !!r.error);
835
-
836
- if (successes.length === 0) {
837
- const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
838
- return { content: [{ type: "text" as const, text: `All URLs failed:\n${errorLines.join("\n")}` }] };
839
- }
840
-
841
- let output: string;
842
- if (successes.length === 2) {
843
- output = formatBatchCompare(successes, strat);
844
- } else {
845
- output = formatBatchTable(successes, strat);
846
- }
847
-
848
- if (failures.length > 0) {
849
- const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
850
- output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
851
- }
852
-
853
- if (!hasApiKey()) output += QUOTA_NOTE;
854
-
855
- return { content: [{ type: "text" as const, text: output }] };
856
- }
857
-
858
- // --- CrUX action ---
859
- if (resolvedAction === "crux") {
860
- const cruxUrl = url;
861
- const cruxOrigin = origin;
862
-
863
- if (!cruxUrl && !cruxOrigin) {
864
- return { content: [{ type: "text" as const, text: "Error: provide url or origin for CrUX data." }] };
865
- }
866
- if (cruxUrl && cruxOrigin) {
867
- return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
868
- }
869
-
870
- try {
871
- const result = await queryCrux({
872
- url: cruxUrl,
873
- origin: cruxOrigin,
874
- formFactor: form_factor as CruxFormFactor | undefined,
875
- metrics,
876
- });
877
- return { content: [{ type: "text" as const, text: formatCrux(cruxUrl ?? cruxOrigin ?? "", result) }] };
878
- } catch (err) {
879
- const msg = err instanceof Error ? err.message : String(err);
880
- if (msg.includes("404")) {
881
- const target = cruxUrl ?? cruxOrigin ?? "";
882
- const lines = [`No CrUX data for ${target}.`, ""];
883
- lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
884
- if (cruxUrl) {
885
- const originUrl = new URL(cruxUrl).origin;
886
- lines.push(`Try origin-level data instead: origin "${originUrl}"`);
887
- }
888
- lines.push("For lab metrics without traffic requirements, use speed with a url instead.");
889
- return { content: [{ type: "text" as const, text: lines.join("\n") }] };
890
- }
891
- if (msg.includes("SERVICE_DISABLED") || msg.includes("API_KEY_SERVICE_BLOCKED")) {
892
- return {
893
- content: [
894
- {
895
- type: "text" as const,
896
- text: "Chrome UX Report API is not enabled or the API key doesn't have access. Enable the API at: https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com — and ensure your API key allows it (Credentials > API key > API restrictions).",
897
- },
898
- ],
899
- };
900
- }
901
- return { content: [{ type: "text" as const, text: `Error querying CrUX: ${msg}` }] };
902
- }
903
- }
904
-
905
- // --- CrUX History action ---
906
- if (resolvedAction === "crux_history") {
907
- const histUrl = url;
908
- const histOrigin = origin;
909
-
910
- if (!histUrl && !histOrigin) {
911
- return { content: [{ type: "text" as const, text: "Error: provide url or origin for CrUX history." }] };
912
- }
913
- if (histUrl && histOrigin) {
914
- return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
915
- }
916
-
917
- try {
918
- const result = await queryCruxHistory({
919
- url: histUrl,
920
- origin: histOrigin,
921
- formFactor: form_factor as CruxFormFactor | undefined,
922
- metrics,
923
- collectionPeriodCount: periods,
924
- });
925
- return { content: [{ type: "text" as const, text: formatCruxHistory(histUrl ?? histOrigin ?? "", result) }] };
926
- } catch (err) {
927
- const msg = err instanceof Error ? err.message : String(err);
928
- if (msg.includes("404")) {
929
- const target = histUrl ?? histOrigin ?? "";
930
- const lines = [`No CrUX history data for ${target}.`, ""];
931
- lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
932
- if (histUrl) {
933
- const originUrl = new URL(histUrl).origin;
934
- lines.push(`Try origin-level data instead: origin "${originUrl}"`);
935
- }
936
- lines.push("For lab metrics without traffic requirements, use speed with a url instead.");
937
- return { content: [{ type: "text" as const, text: lines.join("\n") }] };
938
- }
939
- if (msg.includes("SERVICE_DISABLED")) {
940
- return {
941
- content: [
942
- {
943
- type: "text" as const,
944
- text: "Chrome UX Report API is not enabled. Enable it at: https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com",
945
- },
946
- ],
947
- };
948
- }
949
- return { content: [{ type: "text" as const, text: `Error querying CrUX History: ${msg}` }] };
950
- }
951
- }
952
-
953
- return { content: [{ type: "text" as const, text: `Unknown action: ${resolvedAction}` }] };
954
- },
955
- );
956
- }