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 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
- Most SEO tools flag "title over 60 characters" and "only one H1 allowed." [Google's own engineers say those rules don't exist.](#why-not-other-seo-tools) Pagesight skips the myths and goes to the sources.
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
- === robots.txt: https://www.nytimes.com ===
67
- AI Crawlers: 35 blocked, 104 allowed (of 139 known)
68
-
69
- BLOCKED GPTBot (OpenAI) — GPT model training
70
- BLOCKED ClaudeBot (Anthropic) — Claude model training
71
- ALLOWED Claude-User (Anthropic) — User-initiated fetching
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
- ### `sitemaps`
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
- Check auth status or walk through OAuth interactively.
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
- ### 1. Google Cloud project
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": "bun",
115
- "args": ["run", "/path/to/pagesight/src/index.ts"],
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
- Then just ask:
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
- ## Why not other SEO tools?
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
- We checked every common SEO "rule" against official Google documentation:
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
- - **"Title must be under 60 characters"** — Google: "there's no limit." Gary Illyes: "an externally made-up metric."
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
- Tools that flag these are reporting their opinions. Pagesight only reports what the sources actually return.
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
- ## Development
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
- ```bash
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
@@ -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 ---", "");
@@ -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: Array<{ title: string; displayValue: string }> = [];
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({ title: audit.title, displayValue: audit.displayValue });
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 item of failing.slice(0, 10)) {
111
- lines.push(`${item.title}: ${item.displayValue}`);
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 a page's performance using Google PageSpeed Insights API. Returns Lighthouse scores, Core Web Vitals (lab + field), opportunities, and diagnostics.",
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("The URL to analyze."),
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
- try {
276
- const result = await runPagespeed(url, {
277
- strategy: strategy as "mobile" | "desktop" | undefined,
278
- categories: categories as PsiCategoryType[] | undefined,
279
- locale,
280
- });
281
- return { content: [{ type: "text", text: formatPagespeed(url, result) }] };
282
- } catch (err) {
283
- const msg = err instanceof Error ? err.message : String(err);
284
- return { content: [{ type: "text", text: `Error running PageSpeed analysis: ${msg}` }] };
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
  }