pagesight 0.12.1 → 0.12.2
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/package.json +1 -1
- package/src/index.ts +8 -18
- package/src/lib/sitemap.ts +106 -0
- package/src/tools/{robots.ts → ai.ts} +3 -3
- package/src/tools/audit.ts +1 -1
- package/src/tools/{metatags.ts → page.ts} +480 -131
- package/src/tools/search.ts +685 -0
- package/src/tools/{pagespeed.ts → speed.ts} +378 -57
- package/src/tools/crux.ts +0 -296
- package/src/tools/inspect.ts +0 -121
- package/src/tools/links.ts +0 -183
- package/src/tools/performance.ts +0 -325
- package/src/tools/sample-inspect.ts +0 -284
- package/src/tools/sitemaps.ts +0 -112
|
@@ -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
|
-
|
|
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
|
-
"
|
|
506
|
-
"Analyze
|
|
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
|
-
|
|
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
|
|
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
|
|
520
|
-
locale: z.string().optional().describe("Locale for
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
//
|
|
540
|
-
if (
|
|
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
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
852
|
+
// --- CrUX History action ---
|
|
853
|
+
if (resolvedAction === "crux_history") {
|
|
854
|
+
const histUrl = url;
|
|
855
|
+
const histOrigin = origin;
|
|
554
856
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
}
|