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.
package/src/tools/crux.ts DELETED
@@ -1,296 +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
-
11
- function formatDate(d: { year: number; month: number; day: number }): string {
12
- return `${d.year}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`;
13
- }
14
-
15
- const METRIC_LABELS: Record<string, string> = {
16
- cumulative_layout_shift: "CLS",
17
- first_contentful_paint: "FCP",
18
- interaction_to_next_paint: "INP",
19
- largest_contentful_paint: "LCP",
20
- experimental_time_to_first_byte: "TTFB",
21
- round_trip_time: "RTT",
22
- navigation_types: "Navigation Types",
23
- form_factors: "Form Factors",
24
- };
25
-
26
- function formatCrux(target: string, result: CruxResponse): string {
27
- const r = result.record;
28
- const period = r.collectionPeriod;
29
- const lines: string[] = [
30
- `=== CrUX: ${target} ===`,
31
- `Form factor: ${r.key.formFactor ?? "all"}`,
32
- `Period: ${formatDate(period.firstDate)} to ${formatDate(period.lastDate)}`,
33
- "",
34
- ];
35
-
36
- if (result.urlNormalizationDetails) {
37
- const norm = result.urlNormalizationDetails;
38
- if (norm.originalUrl !== norm.normalizedUrl) {
39
- lines.push(`Normalized: ${norm.originalUrl} → ${norm.normalizedUrl}`, "");
40
- }
41
- }
42
-
43
- lines.push("--- Metrics (p75) ---", "");
44
-
45
- for (const [key, metric] of Object.entries(r.metrics)) {
46
- const label = METRIC_LABELS[key] ?? key;
47
-
48
- if (metric.percentiles) {
49
- const val = metric.percentiles.p75;
50
- const unit = key === "cumulative_layout_shift" ? "" : "ms";
51
- lines.push(`${label}: ${val}${unit}`);
52
-
53
- if (metric.histogram) {
54
- const buckets = metric.histogram.map((b) => `${Math.round(b.density * 100)}%`).join(" / ");
55
- lines.push(` Distribution (good/needs improvement/poor): ${buckets}`);
56
- }
57
- } else if (metric.fractions) {
58
- lines.push(`${label}:`);
59
- for (const [fKey, fVal] of Object.entries(metric.fractions)) {
60
- lines.push(` ${fKey}: ${(fVal * 100).toFixed(1)}%`);
61
- }
62
- }
63
- }
64
-
65
- return lines.join("\n");
66
- }
67
-
68
- function formatCruxHistory(target: string, result: CruxHistoryResponse): string {
69
- const r = result.record;
70
- const periods = r.collectionPeriods;
71
- const lines: string[] = [
72
- `=== CrUX History: ${target} ===`,
73
- `Form factor: ${r.key.formFactor ?? "all"}`,
74
- `Periods: ${periods.length} (${formatDate(periods[0].firstDate)} to ${formatDate(periods[periods.length - 1].lastDate)})`,
75
- "",
76
- ];
77
-
78
- if (result.urlNormalizationDetails) {
79
- const norm = result.urlNormalizationDetails;
80
- if (norm.originalUrl !== norm.normalizedUrl) {
81
- lines.push(`Normalized: ${norm.originalUrl} → ${norm.normalizedUrl}`, "");
82
- }
83
- }
84
-
85
- lines.push("--- p75 Trend ---", "");
86
-
87
- for (const [key, metric] of Object.entries(r.metrics)) {
88
- const label = METRIC_LABELS[key] ?? key;
89
-
90
- if (metric.percentilesTimeseries) {
91
- const values = metric.percentilesTimeseries.p75s;
92
- const first = values[0];
93
- const last = values[values.length - 1];
94
- const unit = key === "cumulative_layout_shift" ? "" : "ms";
95
-
96
- if (first === null && last === null) {
97
- lines.push(`${label}: insufficient data`);
98
- continue;
99
- }
100
-
101
- lines.push(
102
- `${label}: ${first ?? "N/A"}${first !== null ? unit : ""} → ${last ?? "N/A"}${last !== null ? unit : ""} (${values.length} points)`,
103
- );
104
-
105
- // Show trend direction
106
- if (first !== null && last !== null) {
107
- const f = Number(first);
108
- const l = Number(last);
109
- if (!Number.isNaN(f) && !Number.isNaN(l)) {
110
- const change = ((l - f) / f) * 100;
111
- const dir = change > 5 ? "worse" : change < -5 ? "improved" : "stable";
112
- lines.push(` Trend: ${change > 0 ? "+" : ""}${change.toFixed(1)}% (${dir})`);
113
- }
114
- }
115
- } else if (metric.fractionTimeseries) {
116
- lines.push(`${label}: (fraction timeseries, ${periods.length} points)`);
117
- for (const [fKey, fData] of Object.entries(metric.fractionTimeseries)) {
118
- const fracs = fData.fractions;
119
- const first = fracs[0];
120
- const last = fracs[fracs.length - 1];
121
- if (first !== null && last !== null && !Number.isNaN(first) && !Number.isNaN(last)) {
122
- lines.push(` ${fKey}: ${(first * 100).toFixed(1)}% → ${(last * 100).toFixed(1)}%`);
123
- }
124
- }
125
- }
126
- }
127
-
128
- // Show last 5 data points as table for core metrics
129
- const coreMetrics = ["largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift"];
130
- const available = coreMetrics.filter((m) => r.metrics[m]?.percentilesTimeseries);
131
-
132
- if (available.length > 0 && periods.length >= 5) {
133
- lines.push("", "--- Recent Data Points ---", "");
134
- const lastN = 5;
135
- const startIdx = periods.length - lastN;
136
-
137
- lines.push(`${"Date".padEnd(12)} ${available.map((m) => (METRIC_LABELS[m] ?? m).padEnd(10)).join(" ")}`);
138
- for (let i = startIdx; i < periods.length; i++) {
139
- const date = formatDate(periods[i].lastDate);
140
- const vals = available.map((m) => {
141
- const v = r.metrics[m].percentilesTimeseries?.p75s[i];
142
- return String(v ?? "N/A").padEnd(10);
143
- });
144
- lines.push(`${date.padEnd(12)} ${vals.join(" ")}`);
145
- }
146
- }
147
-
148
- return lines.join("\n");
149
- }
150
-
151
- export function registerCruxTool(server: McpServer): void {
152
- server.tool(
153
- "crux",
154
- "Query Chrome UX Report (CrUX) for real-world Core Web Vitals data. Returns p75 metrics from actual Chrome users over a 28-day rolling window.",
155
- {
156
- url: z.string().optional().describe("Specific page URL. Provide either url or origin."),
157
- origin: z.string().optional().describe("Origin URL (e.g., 'https://example.com'). Provide either url or origin."),
158
- form_factor: z
159
- .enum(["DESKTOP", "PHONE", "TABLET"])
160
- .optional()
161
- .describe("Filter by device type. Omit for all devices."),
162
- metrics: z
163
- .array(
164
- z.enum([
165
- "cumulative_layout_shift",
166
- "first_contentful_paint",
167
- "interaction_to_next_paint",
168
- "largest_contentful_paint",
169
- "experimental_time_to_first_byte",
170
- "round_trip_time",
171
- "navigation_types",
172
- "form_factors",
173
- ]),
174
- )
175
- .optional()
176
- .describe("Specific metrics to return. Default: all available."),
177
- },
178
- async ({ url, origin, form_factor, metrics }) => {
179
- if (!url && !origin) {
180
- return { content: [{ type: "text", text: "Error: provide either url or origin, not both." }] };
181
- }
182
- if (url && origin) {
183
- return { content: [{ type: "text", text: "Error: provide either url or origin, not both." }] };
184
- }
185
- try {
186
- const result = await queryCrux({
187
- url,
188
- origin,
189
- formFactor: form_factor as CruxFormFactor | undefined,
190
- metrics,
191
- });
192
- return { content: [{ type: "text", text: formatCrux(url ?? origin ?? "", result) }] };
193
- } catch (err) {
194
- const msg = err instanceof Error ? err.message : String(err);
195
- if (msg.includes("404")) {
196
- const target = url ?? origin ?? "";
197
- const lines = [`No CrUX data for ${target}.`, ""];
198
- lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
199
- if (url) {
200
- const originUrl = new URL(url).origin;
201
- lines.push(`Try origin-level data instead: origin "${originUrl}"`);
202
- }
203
- lines.push("For lab metrics without traffic requirements, use the pagespeed tool.");
204
- return { content: [{ type: "text", text: lines.join("\n") }] };
205
- }
206
- if (msg.includes("SERVICE_DISABLED") || msg.includes("API_KEY_SERVICE_BLOCKED")) {
207
- return {
208
- content: [
209
- {
210
- type: "text",
211
- 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).",
212
- },
213
- ],
214
- };
215
- }
216
- return { content: [{ type: "text", text: `Error querying CrUX: ${msg}` }] };
217
- }
218
- },
219
- );
220
-
221
- server.tool(
222
- "crux_history",
223
- "Query CrUX History API for Core Web Vitals trends over time. Returns up to 40 weekly data points (~10 months) of real-world performance data.",
224
- {
225
- url: z.string().optional().describe("Specific page URL. Provide either url or origin."),
226
- origin: z.string().optional().describe("Origin URL (e.g., 'https://example.com'). Provide either url or origin."),
227
- form_factor: z
228
- .enum(["DESKTOP", "PHONE", "TABLET"])
229
- .optional()
230
- .describe("Filter by device type. Omit for all devices."),
231
- metrics: z
232
- .array(
233
- z.enum([
234
- "cumulative_layout_shift",
235
- "first_contentful_paint",
236
- "interaction_to_next_paint",
237
- "largest_contentful_paint",
238
- "experimental_time_to_first_byte",
239
- "round_trip_time",
240
- "navigation_types",
241
- "form_factors",
242
- ]),
243
- )
244
- .optional()
245
- .describe("Specific metrics to return. Default: all available."),
246
- periods: z
247
- .number()
248
- .min(1)
249
- .max(40)
250
- .optional()
251
- .describe("Number of collection periods (1-40). Default: 25. Each is a 28-day window."),
252
- },
253
- async ({ url, origin, form_factor, metrics, periods }) => {
254
- if (!url && !origin) {
255
- return { content: [{ type: "text", text: "Error: provide either url or origin, not both." }] };
256
- }
257
- if (url && origin) {
258
- return { content: [{ type: "text", text: "Error: provide either url or origin, not both." }] };
259
- }
260
- try {
261
- const result = await queryCruxHistory({
262
- url,
263
- origin,
264
- formFactor: form_factor as CruxFormFactor | undefined,
265
- metrics,
266
- collectionPeriodCount: periods,
267
- });
268
- return { content: [{ type: "text", text: formatCruxHistory(url ?? origin ?? "", result) }] };
269
- } catch (err) {
270
- const msg = err instanceof Error ? err.message : String(err);
271
- if (msg.includes("404")) {
272
- const target = url ?? origin ?? "";
273
- const lines = [`No CrUX history data for ${target}.`, ""];
274
- lines.push("CrUX requires sufficient Chrome user traffic (roughly 1,000+ monthly visits).");
275
- if (url) {
276
- const originUrl = new URL(url).origin;
277
- lines.push(`Try origin-level data instead: origin "${originUrl}"`);
278
- }
279
- lines.push("For lab metrics without traffic requirements, use the pagespeed tool.");
280
- return { content: [{ type: "text", text: lines.join("\n") }] };
281
- }
282
- if (msg.includes("SERVICE_DISABLED")) {
283
- return {
284
- content: [
285
- {
286
- type: "text",
287
- text: "Chrome UX Report API is not enabled. Enable it at: https://console.cloud.google.com/apis/library/chromeuxreport.googleapis.com",
288
- },
289
- ],
290
- };
291
- }
292
- return { content: [{ type: "text", text: `Error querying CrUX History: ${msg}` }] };
293
- }
294
- },
295
- );
296
- }
@@ -1,121 +0,0 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { z } from "zod";
3
- import { type InspectionResult, inspectUrl } from "../lib/gsc.js";
4
-
5
- function formatInspection(url: string, siteUrl: string, r: InspectionResult): string {
6
- const idx = r.indexStatusResult;
7
- const lines: string[] = [
8
- `=== URL Inspection: ${url} ===`,
9
- `Property: ${siteUrl}`,
10
- "",
11
- "--- Index Status ---",
12
- "",
13
- `Verdict: ${idx.verdict}`,
14
- `Coverage: ${idx.coverageState}`,
15
- `Page fetch: ${idx.pageFetchState}`,
16
- `Robots.txt: ${idx.robotsTxtState}`,
17
- `Indexing: ${idx.indexingState}`,
18
- `Crawled as: ${idx.crawledAs ?? "unknown"}`,
19
- ];
20
-
21
- if (idx.lastCrawlTime) lines.push(`Last crawled: ${idx.lastCrawlTime}`);
22
- if (idx.userCanonical) lines.push(`Your canonical: ${idx.userCanonical}`);
23
- if (idx.googleCanonical) lines.push(`Google's canonical: ${idx.googleCanonical}`);
24
-
25
- if (idx.userCanonical && idx.googleCanonical && idx.userCanonical !== idx.googleCanonical) {
26
- lines.push(`\n⚠ CANONICAL MISMATCH: You declared "${idx.userCanonical}" but Google chose "${idx.googleCanonical}"`);
27
- }
28
-
29
- if (idx.sitemap && idx.sitemap.length > 0) {
30
- lines.push(`\nSitemaps: ${idx.sitemap.join(", ")}`);
31
- }
32
-
33
- if (idx.referringUrls && idx.referringUrls.length > 0) {
34
- lines.push(`\nReferring URLs: ${idx.referringUrls.join(", ")}`);
35
- }
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
-
42
- // Rich Results
43
- if (r.richResultsResult) {
44
- lines.push("", "--- Rich Results ---", "");
45
- lines.push(`Verdict: ${r.richResultsResult.verdict}`);
46
-
47
- for (const item of r.richResultsResult.detectedItems ?? []) {
48
- lines.push(`\nType: ${item.richResultType}`);
49
- for (const instance of item.items ?? []) {
50
- if (instance.name) lines.push(` Name: ${instance.name}`);
51
- const issues = instance.issues ?? [];
52
- if (issues.length === 0) {
53
- lines.push(" Status: PASS");
54
- } else {
55
- for (const issue of issues) {
56
- lines.push(` ${issue.severity}: ${issue.issueMessage}`);
57
- }
58
- }
59
- }
60
- }
61
- }
62
-
63
- // Mobile Usability (deprecated but still returned)
64
- if (r.mobileUsabilityResult) {
65
- lines.push("", "--- Mobile Usability (deprecated) ---", "");
66
- lines.push(`Verdict: ${r.mobileUsabilityResult.verdict}`);
67
- if (r.mobileUsabilityResult.issues) {
68
- for (const issue of r.mobileUsabilityResult.issues) {
69
- lines.push(` ${issue.issueType}${issue.message ? `: ${issue.message}` : ""}`);
70
- }
71
- }
72
- }
73
-
74
- // Inspection link
75
- if (r.inspectionResultLink) {
76
- lines.push("", `Full report: ${r.inspectionResultLink}`);
77
- }
78
-
79
- return lines.join("\n");
80
- }
81
-
82
- export function registerInspectTool(server: McpServer): void {
83
- server.tool(
84
- "inspect",
85
- "Inspect a URL using Google Search Console. Returns index status, canonical, crawl status, rich results validation, and more — directly from Google's index.",
86
- {
87
- url: z.string().url().describe("The URL to inspect"),
88
- site_url: z.string().describe("The GSC property (e.g., 'https://example.com/' or 'sc-domain:example.com')"),
89
- },
90
- async ({ url, site_url }) => {
91
- try {
92
- const result = await inspectUrl(url, site_url);
93
- return { content: [{ type: "text", text: formatInspection(url, site_url, result) }] };
94
- } catch (err) {
95
- const msg = err instanceof Error ? err.message : String(err);
96
- if (msg.includes("PERMISSION_DENIED")) {
97
- const domain = new URL(url).hostname;
98
- return {
99
- content: [
100
- {
101
- type: "text",
102
- text: [
103
- `Error: Cannot inspect "${url}" — permission denied for property "${site_url}".`,
104
- "",
105
- "Possible causes:",
106
- ` 1. ${domain} is not a verified property in Google Search Console`,
107
- ` 2. The authenticated account does not have access to "${site_url}"`,
108
- ` 3. The property format is wrong — try "sc-domain:${domain}" or "https://${domain}/"`,
109
- "",
110
- "To verify a property: https://search.google.com/search-console",
111
- "To check your auth: use the setup tool to verify credentials",
112
- ].join("\n"),
113
- },
114
- ],
115
- };
116
- }
117
- return { content: [{ type: "text", text: `Error inspecting URL: ${msg}` }] };
118
- }
119
- },
120
- );
121
- }
@@ -1,183 +0,0 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { z } from "zod";
3
-
4
- interface LinkResult {
5
- href: string;
6
- status: number | null;
7
- redirectChain: Array<{ url: string; status: number }>;
8
- finalUrl: string | null;
9
- error: string | null;
10
- }
11
-
12
- async function checkLink(href: string): Promise<LinkResult> {
13
- const chain: Array<{ url: string; status: number }> = [];
14
- let current = href;
15
-
16
- try {
17
- for (let i = 0; i < 10; i++) {
18
- const res = await fetch(current, {
19
- method: "GET",
20
- headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
21
- redirect: "manual",
22
- });
23
-
24
- chain.push({ url: current, status: res.status });
25
-
26
- if (res.status >= 300 && res.status < 400) {
27
- const location = res.headers.get("location");
28
- if (!location) break;
29
- current = new URL(location, current).href;
30
- continue;
31
- }
32
-
33
- return {
34
- href,
35
- status: res.status,
36
- redirectChain: chain,
37
- finalUrl: chain.length > 1 ? current : null,
38
- error: null,
39
- };
40
- }
41
-
42
- return {
43
- href,
44
- status: chain[chain.length - 1]?.status ?? null,
45
- redirectChain: chain,
46
- finalUrl: current,
47
- error: "Too many redirects",
48
- };
49
- } catch (err) {
50
- return {
51
- href,
52
- status: null,
53
- redirectChain: chain,
54
- finalUrl: null,
55
- error: err instanceof Error ? err.message : String(err),
56
- };
57
- }
58
- }
59
-
60
- function extractInternalLinks(html: string, origin: string): string[] {
61
- const links = new Set<string>();
62
-
63
- for (const match of html.matchAll(/<a\s[^>]*href=["']([^"'#]+)/gi)) {
64
- const raw = match[1].trim();
65
- if (!raw || raw.startsWith("javascript:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) continue;
66
-
67
- try {
68
- const resolved = new URL(raw, origin).href;
69
- if (resolved.startsWith(origin) && !resolved.includes("/cdn-cgi/")) {
70
- links.add(resolved);
71
- }
72
- } catch {
73
- // Invalid URL, skip
74
- }
75
- }
76
-
77
- return [...links];
78
- }
79
-
80
- function formatResults(url: string, results: LinkResult[]): string {
81
- const lines: string[] = [`=== Internal Links: ${url} ===`, `Found ${results.length} internal links`, ""];
82
-
83
- const broken = results.filter((r) => r.error || (r.status && r.status >= 400));
84
- const redirected = results.filter((r) => !r.error && r.redirectChain.length > 1 && r.status && r.status < 400);
85
- const ok = results.filter((r) => !r.error && r.redirectChain.length === 1 && r.status && r.status < 400);
86
-
87
- // Summary
88
- lines.push("--- Summary ---", "");
89
- lines.push(`OK: ${ok.length}`);
90
- if (redirected.length > 0) lines.push(`Redirected: ${redirected.length}`);
91
- if (broken.length > 0) lines.push(`Broken: ${broken.length}`);
92
- lines.push("");
93
-
94
- // Broken links
95
- if (broken.length > 0) {
96
- lines.push("--- Broken Links ---", "");
97
- for (const r of broken) {
98
- if (r.error) {
99
- lines.push(`FAIL ${r.href}`);
100
- lines.push(` Error: ${r.error}`);
101
- } else {
102
- lines.push(`FAIL ${r.href} → ${r.status}`);
103
- }
104
- }
105
- lines.push("");
106
- }
107
-
108
- // Redirect chains
109
- if (redirected.length > 0) {
110
- lines.push("--- Redirect Chains ---", "");
111
- for (const r of redirected) {
112
- const hops = r.redirectChain.length - 1;
113
- const chainStr = r.redirectChain.map((h) => `${h.status}`).join(" → ");
114
- lines.push(`REDIRECT ${r.href}`);
115
- lines.push(` ${chainStr} → ${r.finalUrl} (${hops} hop${hops > 1 ? "s" : ""})`);
116
- }
117
- lines.push("");
118
- }
119
-
120
- return lines.join("\n");
121
- }
122
-
123
- export function registerLinksTool(server: McpServer): void {
124
- server.tool(
125
- "links",
126
- "Check internal links on a page. Fetches the page, extracts all internal <a href> links, and checks each for broken links (404/5xx), redirect chains, and errors.",
127
- {
128
- url: z.string().url().describe("The page URL to check internal links for."),
129
- },
130
- async ({ url }) => {
131
- try {
132
- const origin = new URL(url).origin;
133
-
134
- // Fetch the page
135
- const res = await fetch(url, {
136
- headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
137
- redirect: "follow",
138
- });
139
-
140
- if (!res.ok) {
141
- return {
142
- content: [{ type: "text", text: `Error fetching ${url}: HTTP ${res.status}` }],
143
- };
144
- }
145
-
146
- const html = await res.text();
147
- const links = extractInternalLinks(html, origin);
148
-
149
- if (links.length === 0) {
150
- return {
151
- content: [
152
- {
153
- type: "text",
154
- text: `No internal links found on ${url}\nNote: this page may use client-side rendering (SPA). Only static <a href> links in the HTML are detected.`,
155
- },
156
- ],
157
- };
158
- }
159
-
160
- // Check links with concurrency 5
161
- const concurrency = 5;
162
- const results: LinkResult[] = [];
163
- let i = 0;
164
-
165
- while (i < links.length) {
166
- const batch = links.slice(i, i + concurrency);
167
- const settled = await Promise.all(batch.map((href) => checkLink(href)));
168
- results.push(...settled);
169
- i += concurrency;
170
- }
171
-
172
- return {
173
- content: [{ type: "text", text: formatResults(url, results) }],
174
- };
175
- } catch (err) {
176
- const msg = err instanceof Error ? err.message : String(err);
177
- return {
178
- content: [{ type: "text", text: `Error checking links: ${msg}` }],
179
- };
180
- }
181
- },
182
- );
183
- }