pagesight 0.12.1 → 0.13.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.
@@ -1,5 +1,12 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
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";
3
10
  import {
4
11
  hasApiKey,
5
12
  type PsiAudit,
@@ -9,6 +16,8 @@ import {
9
16
  runPagespeed,
10
17
  } from "../lib/psi.js";
11
18
 
19
+ // --- PageSpeed helpers ---
20
+
12
21
  const QUOTA_NOTE =
13
22
  "\n\nNote: No GOOGLE_API_KEY configured — using shared quota (400 req/day). Set your own key to avoid rate limits.";
14
23
 
@@ -198,8 +207,6 @@ function formatFailingAudits(audits: Record<string, PsiAudit>, categoryRefs: str
198
207
  return lines;
199
208
  }
200
209
 
201
- // --- Single URL formatting (existing) ---
202
-
203
210
  function formatPagespeed(url: string, result: PsiResult): string {
204
211
  const lhr = result.lighthouseResult;
205
212
  const lines: string[] = [
@@ -284,8 +291,6 @@ function formatPagespeed(url: string, result: PsiResult): string {
284
291
  return lines.join("\n");
285
292
  }
286
293
 
287
- // --- Batch formatting ---
288
-
289
294
  function shortUrl(url: string, allUrls: string[]): string {
290
295
  try {
291
296
  const u = new URL(url);
@@ -452,6 +457,39 @@ function formatBatchTable(results: Array<{ url: string; result: PsiResult }>, st
452
457
  lines.push("");
453
458
  }
454
459
 
460
+ // A11y failures deduplicated across pages
461
+ const a11yFailures = new Map<string, { title: string; count: number; pages: string[] }>();
462
+ for (const { url, result } of results) {
463
+ const lhr = result.lighthouseResult;
464
+ const a11yCat = lhr.categories.accessibility;
465
+ if (!a11yCat?.auditRefs) continue;
466
+
467
+ for (const ref of a11yCat.auditRefs) {
468
+ const audit = lhr.audits[ref.id];
469
+ if (audit && audit.score !== null && audit.score < 1) {
470
+ const existing = a11yFailures.get(ref.id);
471
+ const page = shortUrl(url, allUrls);
472
+ if (existing) {
473
+ existing.count++;
474
+ existing.pages.push(page);
475
+ } else {
476
+ a11yFailures.set(ref.id, { title: audit.title, count: 1, pages: [page] });
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ const sharedA11y = [...a11yFailures.entries()]
483
+ .filter(([, v]) => v.count >= 2)
484
+ .sort((a, b) => b[1].count - a[1].count);
485
+ if (sharedA11y.length > 0) {
486
+ lines.push("--- Accessibility Issues (shared) ---", "");
487
+ for (const [, { title, count, pages }] of sharedA11y.slice(0, 10)) {
488
+ lines.push(` ${title} (${count}/${results.length} pages: ${pages.join(", ")})`);
489
+ }
490
+ lines.push("");
491
+ }
492
+
455
493
  const totalTime = results.reduce((sum, r) => sum + r.result.lighthouseResult.timing.total, 0);
456
494
  lines.push(`Total analysis time: ${(totalTime / 1000).toFixed(1)}s`);
457
495
 
@@ -500,83 +538,366 @@ async function runBatch(
500
538
  return results;
501
539
  }
502
540
 
503
- export function registerPagespeedTool(server: McpServer): void {
541
+ // --- CrUX helpers ---
542
+
543
+ const METRIC_LABELS: Record<string, string> = {
544
+ cumulative_layout_shift: "CLS",
545
+ first_contentful_paint: "FCP",
546
+ interaction_to_next_paint: "INP",
547
+ largest_contentful_paint: "LCP",
548
+ experimental_time_to_first_byte: "TTFB",
549
+ round_trip_time: "RTT",
550
+ navigation_types: "Navigation Types",
551
+ form_factors: "Form Factors",
552
+ };
553
+
554
+ function formatDate(d: { year: number; month: number; day: number }): string {
555
+ return `${d.year}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`;
556
+ }
557
+
558
+ function formatCrux(target: string, result: CruxResponse): string {
559
+ const r = result.record;
560
+ const period = r.collectionPeriod;
561
+ const lines: string[] = [
562
+ `=== CrUX: ${target} ===`,
563
+ `Form factor: ${r.key.formFactor ?? "all"}`,
564
+ `Period: ${formatDate(period.firstDate)} to ${formatDate(period.lastDate)}`,
565
+ "",
566
+ ];
567
+
568
+ if (result.urlNormalizationDetails) {
569
+ const norm = result.urlNormalizationDetails;
570
+ if (norm.originalUrl !== norm.normalizedUrl) {
571
+ lines.push(`Normalized: ${norm.originalUrl} → ${norm.normalizedUrl}`, "");
572
+ }
573
+ }
574
+
575
+ lines.push("--- Metrics (p75) ---", "");
576
+
577
+ for (const [key, metric] of Object.entries(r.metrics)) {
578
+ const label = METRIC_LABELS[key] ?? key;
579
+
580
+ if (metric.percentiles) {
581
+ const val = metric.percentiles.p75;
582
+ const unit = key === "cumulative_layout_shift" ? "" : "ms";
583
+ lines.push(`${label}: ${val}${unit}`);
584
+
585
+ if (metric.histogram) {
586
+ const buckets = metric.histogram.map((b) => `${Math.round(b.density * 100)}%`).join(" / ");
587
+ lines.push(` Distribution (good/needs improvement/poor): ${buckets}`);
588
+ }
589
+ } else if (metric.fractions) {
590
+ lines.push(`${label}:`);
591
+ for (const [fKey, fVal] of Object.entries(metric.fractions)) {
592
+ lines.push(` ${fKey}: ${(fVal * 100).toFixed(1)}%`);
593
+ }
594
+ }
595
+ }
596
+
597
+ return lines.join("\n");
598
+ }
599
+
600
+ function formatCruxHistory(target: string, result: CruxHistoryResponse): string {
601
+ const r = result.record;
602
+ const periods = r.collectionPeriods;
603
+ const lines: string[] = [
604
+ `=== CrUX History: ${target} ===`,
605
+ `Form factor: ${r.key.formFactor ?? "all"}`,
606
+ `Periods: ${periods.length} (${formatDate(periods[0].firstDate)} to ${formatDate(periods[periods.length - 1].lastDate)})`,
607
+ "",
608
+ ];
609
+
610
+ if (result.urlNormalizationDetails) {
611
+ const norm = result.urlNormalizationDetails;
612
+ if (norm.originalUrl !== norm.normalizedUrl) {
613
+ lines.push(`Normalized: ${norm.originalUrl} → ${norm.normalizedUrl}`, "");
614
+ }
615
+ }
616
+
617
+ lines.push("--- p75 Trend ---", "");
618
+
619
+ for (const [key, metric] of Object.entries(r.metrics)) {
620
+ const label = METRIC_LABELS[key] ?? key;
621
+
622
+ if (metric.percentilesTimeseries) {
623
+ const values = metric.percentilesTimeseries.p75s;
624
+ const first = values[0];
625
+ const last = values[values.length - 1];
626
+ const unit = key === "cumulative_layout_shift" ? "" : "ms";
627
+
628
+ if (first === null && last === null) {
629
+ lines.push(`${label}: insufficient data`);
630
+ continue;
631
+ }
632
+
633
+ lines.push(
634
+ `${label}: ${first ?? "N/A"}${first !== null ? unit : ""} → ${last ?? "N/A"}${last !== null ? unit : ""} (${values.length} points)`,
635
+ );
636
+
637
+ // Show trend direction
638
+ if (first !== null && last !== null) {
639
+ const f = Number(first);
640
+ const l = Number(last);
641
+ if (!Number.isNaN(f) && !Number.isNaN(l)) {
642
+ const change = ((l - f) / f) * 100;
643
+ const dir = change > 5 ? "worse" : change < -5 ? "improved" : "stable";
644
+ lines.push(` Trend: ${change > 0 ? "+" : ""}${change.toFixed(1)}% (${dir})`);
645
+ }
646
+ }
647
+ } else if (metric.fractionTimeseries) {
648
+ lines.push(`${label}: (fraction timeseries, ${periods.length} points)`);
649
+ for (const [fKey, fData] of Object.entries(metric.fractionTimeseries)) {
650
+ const fracs = fData.fractions;
651
+ const first = fracs[0];
652
+ const last = fracs[fracs.length - 1];
653
+ if (first !== null && last !== null && !Number.isNaN(first) && !Number.isNaN(last)) {
654
+ lines.push(` ${fKey}: ${(first * 100).toFixed(1)}% → ${(last * 100).toFixed(1)}%`);
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ // Show last 5 data points as table for core metrics
661
+ const coreMetrics = ["largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift"];
662
+ const available = coreMetrics.filter((m) => r.metrics[m]?.percentilesTimeseries);
663
+
664
+ if (available.length > 0 && periods.length >= 5) {
665
+ lines.push("", "--- Recent Data Points ---", "");
666
+ const lastN = 5;
667
+ const startIdx = periods.length - lastN;
668
+
669
+ lines.push(`${"Date".padEnd(12)} ${available.map((m) => (METRIC_LABELS[m] ?? m).padEnd(10)).join(" ")}`);
670
+ for (let i = startIdx; i < periods.length; i++) {
671
+ const date = formatDate(periods[i].lastDate);
672
+ const vals = available.map((m) => {
673
+ const v = r.metrics[m].percentilesTimeseries?.p75s[i];
674
+ return String(v ?? "N/A").padEnd(10);
675
+ });
676
+ lines.push(`${date.padEnd(12)} ${vals.join(" ")}`);
677
+ }
678
+ }
679
+
680
+ return lines.join("\n");
681
+ }
682
+
683
+ // --- Unified speed tool ---
684
+
685
+ export function registerSpeedTool(server: McpServer): void {
504
686
  server.tool(
505
- "pagespeed",
506
- "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.",
687
+ "speed",
688
+ "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.",
507
689
  {
508
- url: z.string().url().optional().describe("Single URL to analyze. Use this OR urls, not both."),
690
+ action: z
691
+ .enum(["pagespeed", "crux", "crux_history"])
692
+ .optional()
693
+ .describe(
694
+ "Which analysis to run. Auto-detected: 'pagespeed' when url/urls provided, 'crux' when origin provided.",
695
+ ),
696
+ url: z.string().url().optional().describe("URL to analyze (PageSpeed or CrUX)."),
509
697
  urls: z
510
698
  .array(z.string().url())
511
699
  .min(2)
512
700
  .max(10)
513
701
  .optional()
514
- .describe("Multiple URLs (2-10) for batch analysis. 2 URLs = compare mode, 3+ = summary table."),
515
- strategy: z.enum(["mobile", "desktop"]).optional().describe("Device strategy. Default: 'mobile'."),
702
+ .describe("Multiple URLs (2-10) for batch PageSpeed. 2 = compare, 3+ = summary table."),
703
+ strategy: z.enum(["mobile", "desktop"]).optional().describe("Device strategy for PageSpeed. Default: 'mobile'."),
516
704
  categories: z
517
705
  .array(z.enum(["performance", "accessibility", "best-practices", "seo"]))
518
706
  .optional()
519
- .describe("Lighthouse categories to run. Default: all four."),
520
- locale: z.string().optional().describe("Locale for localized results (e.g., 'pt-BR', 'en')."),
707
+ .describe("Lighthouse categories. Default: all four."),
708
+ locale: z.string().optional().describe("Locale for PageSpeed results."),
709
+ origin: z.string().optional().describe("Origin for CrUX data (e.g., 'https://example.com'). Triggers CrUX mode."),
710
+ form_factor: z.enum(["DESKTOP", "PHONE", "TABLET"]).optional().describe("CrUX device filter."),
711
+ metrics: z
712
+ .array(
713
+ z.enum([
714
+ "cumulative_layout_shift",
715
+ "first_contentful_paint",
716
+ "interaction_to_next_paint",
717
+ "largest_contentful_paint",
718
+ "experimental_time_to_first_byte",
719
+ "round_trip_time",
720
+ "navigation_types",
721
+ "form_factors",
722
+ ]),
723
+ )
724
+ .optional()
725
+ .describe("CrUX metrics to query."),
726
+ periods: z.number().min(1).max(40).optional().describe("CrUX history periods (1-40). Default: 25."),
521
727
  },
522
- async ({ url, urls, strategy, categories, locale }) => {
523
- const strat = (strategy as "mobile" | "desktop") ?? "mobile";
524
- const cats = categories as PsiCategoryType[] | undefined;
525
- const opts = { strategy: strat, categories: cats, locale };
526
-
527
- // Validate: must provide url or urls, not both
528
- if (url && urls) {
529
- return {
530
- content: [{ type: "text", text: "Error: provide either 'url' (single) or 'urls' (batch), not both." }],
531
- };
728
+ async ({ action, url, urls, strategy, categories, locale, origin, form_factor, metrics, periods }) => {
729
+ // Determine which action to run
730
+ let resolvedAction = action;
731
+ if (!resolvedAction) {
732
+ if (urls) {
733
+ resolvedAction = "pagespeed";
734
+ } else if (origin && !url) {
735
+ resolvedAction = "crux";
736
+ } else if (periods) {
737
+ resolvedAction = "crux_history";
738
+ } else {
739
+ resolvedAction = "pagespeed";
740
+ }
532
741
  }
533
- if (!url && !urls) {
534
- return {
535
- content: [{ type: "text", text: "Error: provide 'url' for single analysis or 'urls' for batch analysis." }],
536
- };
742
+
743
+ // --- PageSpeed action ---
744
+ if (resolvedAction === "pagespeed") {
745
+ const strat = (strategy as "mobile" | "desktop") ?? "mobile";
746
+ const cats = categories as PsiCategoryType[] | undefined;
747
+ const opts = { strategy: strat, categories: cats, locale };
748
+
749
+ if (url && urls) {
750
+ return {
751
+ content: [
752
+ { type: "text" as const, text: "Error: provide either 'url' (single) or 'urls' (batch), not both." },
753
+ ],
754
+ };
755
+ }
756
+ if (!url && !urls) {
757
+ return {
758
+ content: [
759
+ { type: "text" as const, text: "Error: provide 'url' for single analysis or 'urls' for batch analysis." },
760
+ ],
761
+ };
762
+ }
763
+
764
+ // Single URL
765
+ if (url) {
766
+ try {
767
+ const result = await runPagespeed(url, opts);
768
+ const text = formatPagespeed(url, result) + (hasApiKey() ? "" : QUOTA_NOTE);
769
+ return { content: [{ type: "text" as const, text }] };
770
+ } catch (err) {
771
+ const msg = err instanceof Error ? err.message : String(err);
772
+ return { content: [{ type: "text" as const, text: `Error running PageSpeed analysis: ${msg}` }] };
773
+ }
774
+ }
775
+
776
+ // Batch mode
777
+ const batchUrls = urls as string[];
778
+ const results = await runBatch(batchUrls, opts);
779
+
780
+ const successes = results.filter((r): r is { url: string; result: PsiResult } => !!r.result);
781
+ const failures = results.filter((r): r is { url: string; error: string } => !!r.error);
782
+
783
+ if (successes.length === 0) {
784
+ const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
785
+ return { content: [{ type: "text" as const, text: `All URLs failed:\n${errorLines.join("\n")}` }] };
786
+ }
787
+
788
+ let output: string;
789
+ if (successes.length === 2) {
790
+ output = formatBatchCompare(successes, strat);
791
+ } else {
792
+ output = formatBatchTable(successes, strat);
793
+ }
794
+
795
+ if (failures.length > 0) {
796
+ const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
797
+ output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
798
+ }
799
+
800
+ if (!hasApiKey()) output += QUOTA_NOTE;
801
+
802
+ return { content: [{ type: "text" as const, text: output }] };
537
803
  }
538
804
 
539
- // Single URL — existing behavior
540
- if (url) {
805
+ // --- CrUX action ---
806
+ if (resolvedAction === "crux") {
807
+ const cruxUrl = url;
808
+ const cruxOrigin = origin;
809
+
810
+ if (!cruxUrl && !cruxOrigin) {
811
+ return { content: [{ type: "text" as const, text: "Error: provide either url or origin, not both." }] };
812
+ }
813
+ if (cruxUrl && cruxOrigin) {
814
+ return { content: [{ type: "text" as const, text: "Error: provide either url or origin, not both." }] };
815
+ }
816
+
541
817
  try {
542
- const result = await runPagespeed(url, opts);
543
- const text = formatPagespeed(url, result) + (hasApiKey() ? "" : QUOTA_NOTE);
544
- return { content: [{ type: "text", text }] };
818
+ const result = await queryCrux({
819
+ url: cruxUrl,
820
+ origin: cruxOrigin,
821
+ formFactor: form_factor as CruxFormFactor | undefined,
822
+ metrics,
823
+ });
824
+ return { content: [{ type: "text" as const, text: formatCrux(cruxUrl ?? cruxOrigin ?? "", result) }] };
545
825
  } catch (err) {
546
826
  const msg = err instanceof Error ? err.message : String(err);
547
- return { content: [{ type: "text", text: `Error running PageSpeed analysis: ${msg}` }] };
827
+ if (msg.includes("404")) {
828
+ const target = cruxUrl ?? cruxOrigin ?? "";
829
+ const lines = [`No CrUX data for ${target}.`, ""];
830
+ lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
831
+ if (cruxUrl) {
832
+ const originUrl = new URL(cruxUrl).origin;
833
+ lines.push(`Try origin-level data instead: origin "${originUrl}"`);
834
+ }
835
+ lines.push("For lab metrics without traffic requirements, use speed with a url instead.");
836
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
837
+ }
838
+ if (msg.includes("SERVICE_DISABLED") || msg.includes("API_KEY_SERVICE_BLOCKED")) {
839
+ return {
840
+ content: [
841
+ {
842
+ type: "text" as const,
843
+ 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).",
844
+ },
845
+ ],
846
+ };
847
+ }
848
+ return { content: [{ type: "text" as const, text: `Error querying CrUX: ${msg}` }] };
548
849
  }
549
850
  }
550
851
 
551
- // Batch mode
552
- const batchUrls = urls as string[];
553
- const results = await runBatch(batchUrls, opts);
852
+ // --- CrUX History action ---
853
+ if (resolvedAction === "crux_history") {
854
+ const histUrl = url;
855
+ const histOrigin = origin;
554
856
 
555
- // Separate successes and failures
556
- const successes = results.filter((r): r is { url: string; result: PsiResult } => !!r.result);
557
- const failures = results.filter((r): r is { url: string; error: string } => !!r.error);
558
-
559
- if (successes.length === 0) {
560
- const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
561
- return { content: [{ type: "text", text: `All URLs failed:\n${errorLines.join("\n")}` }] };
562
- }
563
-
564
- let output: string;
565
- if (successes.length === 2) {
566
- output = formatBatchCompare(successes, strat);
567
- } else {
568
- output = formatBatchTable(successes, strat);
569
- }
857
+ if (!histUrl && !histOrigin) {
858
+ return { content: [{ type: "text" as const, text: "Error: provide either url or origin, not both." }] };
859
+ }
860
+ if (histUrl && histOrigin) {
861
+ return { content: [{ type: "text" as const, text: "Error: provide either url or origin, not both." }] };
862
+ }
570
863
 
571
- // Append any failures
572
- if (failures.length > 0) {
573
- const errorLines = failures.map((f) => `${f.url}: ${f.error}`);
574
- output += `\n\n--- Errors ---\n${errorLines.join("\n")}`;
864
+ try {
865
+ const result = await queryCruxHistory({
866
+ url: histUrl,
867
+ origin: histOrigin,
868
+ formFactor: form_factor as CruxFormFactor | undefined,
869
+ metrics,
870
+ collectionPeriodCount: periods,
871
+ });
872
+ return { content: [{ type: "text" as const, text: formatCruxHistory(histUrl ?? histOrigin ?? "", result) }] };
873
+ } catch (err) {
874
+ const msg = err instanceof Error ? err.message : String(err);
875
+ if (msg.includes("404")) {
876
+ const target = histUrl ?? histOrigin ?? "";
877
+ const lines = [`No CrUX history data for ${target}.`, ""];
878
+ lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
879
+ if (histUrl) {
880
+ const originUrl = new URL(histUrl).origin;
881
+ lines.push(`Try origin-level data instead: origin "${originUrl}"`);
882
+ }
883
+ lines.push("For lab metrics without traffic requirements, use speed with a url instead.");
884
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
885
+ }
886
+ if (msg.includes("SERVICE_DISABLED")) {
887
+ return {
888
+ content: [
889
+ {
890
+ type: "text" as const,
891
+ text: "Chrome UX Report API is not enabled. Enable it at: https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com",
892
+ },
893
+ ],
894
+ };
895
+ }
896
+ return { content: [{ type: "text" as const, text: `Error querying CrUX History: ${msg}` }] };
897
+ }
575
898
  }
576
899
 
577
- if (!hasApiKey()) output += QUOTA_NOTE;
578
-
579
- return { content: [{ type: "text", text: output }] };
900
+ return { content: [{ type: "text" as const, text: `Unknown action: ${resolvedAction}` }] };
580
901
  },
581
902
  );
582
903
  }