pagesight 0.10.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,26 +8,10 @@ See your site the way search engines and AI see it.
8
8
  npm install pagesight
9
9
  ```
10
10
 
11
- Google Search Console + PageSpeed Insights + CrUX + 139 AI crawlers. One package.
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://fipe.chat ===
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
- ## Setup
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
- ```env
47
- GSC_CLIENT_ID=your-client-id.apps.googleusercontent.com
48
- GSC_CLIENT_SECRET=your-client-secret
49
- GSC_REFRESH_TOKEN=your-refresh-token
50
- GOOGLE_API_KEY=your-api-key
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
- `robots`, `metatags`, and `pagespeed` work without credentials.
40
+ ## Setup
54
41
 
55
- ### MCP config
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
- ## Why not other SEO tools?
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
- We checked every common SEO "rule" against official Google documentation:
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 only reports what the sources actually return.
84
+ Pagesight reports what the sources report. Nothing more.
83
85
 
84
86
  ## License
85
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.10.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";
@@ -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 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.",
281
503
  {
282
- 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."),
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
- try {
292
- const result = await runPagespeed(url, {
293
- strategy: strategy as "mobile" | "desktop" | undefined,
294
- categories: categories as PsiCategoryType[] | undefined,
295
- locale,
296
- });
297
- return { content: [{ type: "text", text: formatPagespeed(url, result) }] };
298
- } catch (err) {
299
- const msg = err instanceof Error ? err.message : String(err);
300
- 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
+ };
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
  }