pagesight 0.14.0 → 0.14.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/README.md +24 -18
- package/package.json +1 -1
- package/src/lib/auth.ts +3 -0
- package/src/lib/crux.ts +30 -32
- package/src/lib/gsc.ts +8 -1
- package/src/lib/psi.ts +5 -1
- package/src/lib/robots.ts +7 -0
- package/src/lib/sitemap.ts +10 -2
- package/src/tools/ai.ts +13 -0
- package/src/tools/audit.ts +7 -5
- package/src/tools/page.ts +37 -8
- package/src/tools/search.ts +17 -4
- package/src/tools/speed.ts +6 -4
package/README.md
CHANGED
|
@@ -8,34 +8,40 @@ See your site the way search engines and AI see it.
|
|
|
8
8
|
npm install pagesight
|
|
9
9
|
```
|
|
10
10
|
|
|
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.
|
|
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, link health — one package, one call.
|
|
12
12
|
|
|
13
13
|
```
|
|
14
14
|
=== Site Audit: https://example.com ===
|
|
15
15
|
|
|
16
|
+
2 checks failed — results below are partial:
|
|
17
|
+
|
|
18
|
+
FAIL PageSpeed: quota exceeded
|
|
19
|
+
FAIL Sitemaps: permission denied
|
|
20
|
+
|
|
21
|
+
5 findings:
|
|
22
|
+
|
|
16
23
|
HIGH Missing canonical URL
|
|
17
|
-
HIGH
|
|
24
|
+
HIGH 22 sitemap URLs submitted, 0 indexed
|
|
25
|
+
Auto-inspected 5 URLs:
|
|
26
|
+
- 2/5 indexed
|
|
27
|
+
- 3/5 Discovered - currently not indexed: /docs/, /pricing/, /about/
|
|
18
28
|
MEDIUM Missing og:image — no social preview image
|
|
19
|
-
MEDIUM Accessibility score: 89/100
|
|
20
29
|
LOW Missing Twitter Card tags
|
|
21
|
-
LOW
|
|
30
|
+
LOW 6/139 AI crawlers blocked
|
|
22
31
|
```
|
|
23
32
|
|
|
24
33
|
## Tools
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
|
29
|
-
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
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. |
|
|
35
|
+
6 tools organized by intent:
|
|
36
|
+
|
|
37
|
+
| Tool | Intent | What it does |
|
|
38
|
+
|------|--------|-------------|
|
|
39
|
+
| `audit` | How's my site? | One-call site audit. Runs all checks in parallel. Prioritized findings with auto-drill-down on indexing issues. |
|
|
40
|
+
| `page` | What's on this URL? | Meta tags, OG, Twitter Card, JSON-LD validation (19 schema types), internal link health, redirect chains, WCAG contrast checker. Batch mode for multiple URLs. |
|
|
41
|
+
| `speed` | How fast is it? | PageSpeed single/batch/compare with Lighthouse scores and opportunities. CrUX real-user metrics (snapshot + history trends). |
|
|
42
|
+
| `search` | How's Google seeing me? | URL inspection, sample-inspect from sitemaps, sitemap management, search analytics with period-over-period comparison. |
|
|
43
|
+
| `ai` | How's AI seeing me? | AI crawler audit (139+ bots by category), robots.txt validation (RFC 9309), llms.txt detection, path access checks. |
|
|
44
|
+
| `setup` | Auth config | Auth status check and OAuth setup flow. |
|
|
39
45
|
|
|
40
46
|
## Setup
|
|
41
47
|
|
|
@@ -58,7 +64,7 @@ Add to your MCP config:
|
|
|
58
64
|
}
|
|
59
65
|
```
|
|
60
66
|
|
|
61
|
-
`
|
|
67
|
+
`page`, `speed`, and `ai` work without credentials. `search` and `audit` (for GSC checks) require OAuth or a service account.
|
|
62
68
|
|
|
63
69
|
### Full setup
|
|
64
70
|
|
package/package.json
CHANGED
package/src/lib/auth.ts
CHANGED
|
@@ -15,6 +15,9 @@ let cachedToken: { token: string; expiresAt: number } | null = null;
|
|
|
15
15
|
|
|
16
16
|
async function getServiceAccountToken(keyPath: string): Promise<string> {
|
|
17
17
|
const keyFile = JSON.parse(await Bun.file(keyPath).text());
|
|
18
|
+
if (!keyFile.client_email || !keyFile.private_key) {
|
|
19
|
+
throw new Error("Service account key file missing client_email or private_key");
|
|
20
|
+
}
|
|
18
21
|
const now = Math.floor(Date.now() / 1000);
|
|
19
22
|
|
|
20
23
|
const header = toBase64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
package/src/lib/crux.ts
CHANGED
|
@@ -68,14 +68,18 @@ export interface CruxHistoryResponse {
|
|
|
68
68
|
urlNormalizationDetails?: { originalUrl: string; normalizedUrl: string };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// ---
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
// --- Shared fetch helper ---
|
|
72
|
+
|
|
73
|
+
async function cruxFetch<T>(
|
|
74
|
+
endpoint: string,
|
|
75
|
+
options: {
|
|
76
|
+
url?: string;
|
|
77
|
+
origin?: string;
|
|
78
|
+
formFactor?: CruxFormFactor;
|
|
79
|
+
metrics?: string[];
|
|
80
|
+
collectionPeriodCount?: number;
|
|
81
|
+
},
|
|
82
|
+
): Promise<T> {
|
|
79
83
|
const key = getApiKey();
|
|
80
84
|
|
|
81
85
|
const body: Record<string, unknown> = {};
|
|
@@ -83,8 +87,11 @@ export async function queryCrux(options: {
|
|
|
83
87
|
if (options.origin) body.origin = options.origin;
|
|
84
88
|
if (options.formFactor) body.formFactor = options.formFactor;
|
|
85
89
|
if (options.metrics) body.metrics = options.metrics;
|
|
90
|
+
if (options.collectionPeriodCount !== undefined && options.collectionPeriodCount !== 0) {
|
|
91
|
+
body.collectionPeriodCount = options.collectionPeriodCount;
|
|
92
|
+
}
|
|
86
93
|
|
|
87
|
-
const res = await fetch(`${CRUX_API}
|
|
94
|
+
const res = await fetch(`${CRUX_API}:${endpoint}?key=${key}`, {
|
|
88
95
|
method: "POST",
|
|
89
96
|
headers: { "Content-Type": "application/json" },
|
|
90
97
|
body: JSON.stringify(body),
|
|
@@ -95,37 +102,28 @@ export async function queryCrux(options: {
|
|
|
95
102
|
throw new Error(`CrUX API error (${res.status}): ${err}`);
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
return res.json() as Promise<
|
|
105
|
+
return res.json() as Promise<T>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- CrUX Daily API ---
|
|
109
|
+
|
|
110
|
+
export function queryCrux(options: {
|
|
111
|
+
url?: string;
|
|
112
|
+
origin?: string;
|
|
113
|
+
formFactor?: CruxFormFactor;
|
|
114
|
+
metrics?: string[];
|
|
115
|
+
}): Promise<CruxResponse> {
|
|
116
|
+
return cruxFetch<CruxResponse>("queryRecord", options);
|
|
99
117
|
}
|
|
100
118
|
|
|
101
119
|
// --- CrUX History API ---
|
|
102
120
|
|
|
103
|
-
export
|
|
121
|
+
export function queryCruxHistory(options: {
|
|
104
122
|
url?: string;
|
|
105
123
|
origin?: string;
|
|
106
124
|
formFactor?: CruxFormFactor;
|
|
107
125
|
metrics?: string[];
|
|
108
126
|
collectionPeriodCount?: number;
|
|
109
127
|
}): Promise<CruxHistoryResponse> {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const body: Record<string, unknown> = {};
|
|
113
|
-
if (options.url) body.url = options.url;
|
|
114
|
-
if (options.origin) body.origin = options.origin;
|
|
115
|
-
if (options.formFactor) body.formFactor = options.formFactor;
|
|
116
|
-
if (options.metrics) body.metrics = options.metrics;
|
|
117
|
-
if (options.collectionPeriodCount) body.collectionPeriodCount = options.collectionPeriodCount;
|
|
118
|
-
|
|
119
|
-
const res = await fetch(`${CRUX_API}:queryHistoryRecord?key=${key}`, {
|
|
120
|
-
method: "POST",
|
|
121
|
-
headers: { "Content-Type": "application/json" },
|
|
122
|
-
body: JSON.stringify(body),
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
if (!res.ok) {
|
|
126
|
-
const err = await res.text();
|
|
127
|
-
throw new Error(`CrUX History API error (${res.status}): ${err}`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return res.json() as Promise<CruxHistoryResponse>;
|
|
128
|
+
return cruxFetch<CruxHistoryResponse>("queryHistoryRecord", options);
|
|
131
129
|
}
|
package/src/lib/gsc.ts
CHANGED
|
@@ -21,7 +21,11 @@ async function gscFetch(url: string, body?: unknown): Promise<Record<string, unk
|
|
|
21
21
|
throw new Error(`GSC API error (${res.status}): ${err}`);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
try {
|
|
25
|
+
return (await res.json()) as Record<string, unknown>;
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`GSC API returned invalid JSON (${res.status})`);
|
|
28
|
+
}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
// --- URL Inspection ---
|
|
@@ -62,6 +66,9 @@ export async function inspectUrl(inspectionUrl: string, siteUrl: string): Promis
|
|
|
62
66
|
inspectionUrl,
|
|
63
67
|
siteUrl,
|
|
64
68
|
});
|
|
69
|
+
if (!data.inspectionResult) {
|
|
70
|
+
throw new Error("GSC API returned no inspection result");
|
|
71
|
+
}
|
|
65
72
|
return data.inspectionResult as InspectionResult;
|
|
66
73
|
}
|
|
67
74
|
|
package/src/lib/psi.ts
CHANGED
|
@@ -115,5 +115,9 @@ export async function runPagespeed(
|
|
|
115
115
|
throw new Error(`PageSpeed API error (${res.status}): ${err}`);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
try {
|
|
119
|
+
return (await res.json()) as PsiResult;
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error(`PageSpeed API returned invalid JSON (${res.status})`);
|
|
122
|
+
}
|
|
119
123
|
}
|
package/src/lib/robots.ts
CHANGED
|
@@ -157,6 +157,13 @@ export function parseRobotsTxt(raw: string): RobotsTxt {
|
|
|
157
157
|
function pathMatches(pattern: string, path: string): boolean {
|
|
158
158
|
if (!pattern) return false;
|
|
159
159
|
|
|
160
|
+
// RFC 9309 §2.2.2: decode percent-encoded characters for comparison
|
|
161
|
+
try {
|
|
162
|
+
path = decodeURIComponent(path);
|
|
163
|
+
} catch {
|
|
164
|
+
// malformed encoding, use as-is
|
|
165
|
+
}
|
|
166
|
+
|
|
160
167
|
let regex = "^";
|
|
161
168
|
for (let i = 0; i < pattern.length; i++) {
|
|
162
169
|
const c = pattern[i];
|
package/src/lib/sitemap.ts
CHANGED
|
@@ -27,14 +27,22 @@ export function parseSitemapXml(xml: string): SitemapParseResult {
|
|
|
27
27
|
|
|
28
28
|
export async function fetchSitemap(sitemapUrl: string): Promise<SitemapParseResult> {
|
|
29
29
|
const res = await fetch(sitemapUrl, {
|
|
30
|
-
headers: { "User-Agent": "Pagesight/1.0" },
|
|
30
|
+
headers: { "User-Agent": "Pagesight/1.0", "Accept-Encoding": "gzip, deflate" },
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
if (!res.ok) {
|
|
34
34
|
throw new Error(`Failed to fetch sitemap ${sitemapUrl}: HTTP ${res.status}`);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
let xml: string;
|
|
38
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
39
|
+
if (sitemapUrl.endsWith(".gz") || contentType.includes("gzip") || contentType.includes("application/x-gzip")) {
|
|
40
|
+
const buffer = await res.arrayBuffer();
|
|
41
|
+
const decompressed = Bun.gunzipSync(new Uint8Array(buffer));
|
|
42
|
+
xml = new TextDecoder().decode(decompressed);
|
|
43
|
+
} else {
|
|
44
|
+
xml = await res.text();
|
|
45
|
+
}
|
|
38
46
|
return parseSitemapXml(xml);
|
|
39
47
|
}
|
|
40
48
|
|
package/src/tools/ai.ts
CHANGED
|
@@ -12,12 +12,24 @@ interface LlmsTxtResult {
|
|
|
12
12
|
|
|
13
13
|
async function checkLlmsTxt(origin: string, path: string): Promise<LlmsTxtResult> {
|
|
14
14
|
try {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
15
17
|
const res = await fetch(`${origin}${path}`, {
|
|
16
18
|
headers: { "User-Agent": "Pagesight/1.0" },
|
|
17
19
|
redirect: "follow",
|
|
20
|
+
signal: controller.signal,
|
|
18
21
|
});
|
|
22
|
+
clearTimeout(timeout);
|
|
19
23
|
if (!res.ok) return { exists: false, size: null, firstLine: null };
|
|
24
|
+
// Cap at 1MB to avoid OOM on large responses
|
|
25
|
+
const contentLength = Number(res.headers.get("content-length") ?? 0);
|
|
26
|
+
if (contentLength > 1_048_576) {
|
|
27
|
+
return { exists: true, size: contentLength, firstLine: "(file too large to preview)" };
|
|
28
|
+
}
|
|
20
29
|
const text = await res.text();
|
|
30
|
+
if (text.length > 1_048_576) {
|
|
31
|
+
return { exists: true, size: text.length, firstLine: "(file too large to preview)" };
|
|
32
|
+
}
|
|
21
33
|
const firstLine =
|
|
22
34
|
text
|
|
23
35
|
.split("\n")
|
|
@@ -215,6 +227,7 @@ export function registerAiTool(server: McpServer): void {
|
|
|
215
227
|
{
|
|
216
228
|
url: z
|
|
217
229
|
.string()
|
|
230
|
+
.url()
|
|
218
231
|
.describe("Site URL or origin (e.g., 'https://example.com'). Fetches /robots.txt from this origin."),
|
|
219
232
|
check_path: z
|
|
220
233
|
.string()
|
package/src/tools/audit.ts
CHANGED
|
@@ -224,7 +224,12 @@ function formatDrillDown(
|
|
|
224
224
|
const stateCounts: Record<string, { count: number; urls: string[] }> = {};
|
|
225
225
|
for (const r of valid) {
|
|
226
226
|
if (r.verdict !== "PASS") {
|
|
227
|
-
|
|
227
|
+
let path: string;
|
|
228
|
+
try {
|
|
229
|
+
path = new URL(r.url).pathname;
|
|
230
|
+
} catch {
|
|
231
|
+
path = r.url;
|
|
232
|
+
}
|
|
228
233
|
const existing = stateCounts[r.coverageState];
|
|
229
234
|
if (existing) {
|
|
230
235
|
existing.count++;
|
|
@@ -357,10 +362,7 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
357
362
|
}
|
|
358
363
|
if (parsed.urls.length > 0) {
|
|
359
364
|
const sampled = sampleUrls(parsed.urls, 5, "spread");
|
|
360
|
-
const inspections =
|
|
361
|
-
for (const u of sampled) {
|
|
362
|
-
inspections.push(await inspectSingle(u, site_url));
|
|
363
|
-
}
|
|
365
|
+
const inspections = await Promise.all(sampled.map((u) => inspectSingle(u, site_url)));
|
|
364
366
|
const drillDown = formatDrillDown(inspections);
|
|
365
367
|
// Append drill-down to the sitemap finding
|
|
366
368
|
const sitemapFinding = findings.find((f) => f.source === "sitemaps" && f.severity === "HIGH");
|
package/src/tools/page.ts
CHANGED
|
@@ -105,6 +105,15 @@ function parseHead(html: string): ParsedHead {
|
|
|
105
105
|
hreflang.push({ lang: hm[2], href: hm[1] });
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Dedup hreflang entries
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
const dedupedHreflang = hreflang.filter((h) => {
|
|
111
|
+
const key = `${h.lang}:${h.href}`;
|
|
112
|
+
if (seen.has(key)) return false;
|
|
113
|
+
seen.add(key);
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
|
|
108
117
|
// JSON-LD
|
|
109
118
|
const jsonLd: unknown[] = [];
|
|
110
119
|
for (const ld of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
@@ -115,7 +124,7 @@ function parseHead(html: string): ParsedHead {
|
|
|
115
124
|
}
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
return { title, charset, canonical, meta, hreflang, jsonLd };
|
|
127
|
+
return { title, charset, canonical, meta, hreflang: dedupedHreflang, jsonLd };
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
function getMeta(meta: MetaTag[], key: string): string | null {
|
|
@@ -276,8 +285,6 @@ const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
|
276
285
|
function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
|
|
277
286
|
const val = obj[field];
|
|
278
287
|
if (val !== undefined && val !== null && val !== "") return val;
|
|
279
|
-
// Check if it's a nested object with a value (e.g., logo might be {url: "..."} or a string)
|
|
280
|
-
if (typeof val === "object" && val !== null) return val;
|
|
281
288
|
return undefined;
|
|
282
289
|
}
|
|
283
290
|
|
|
@@ -438,10 +445,14 @@ async function followRedirects(
|
|
|
438
445
|
let current = url;
|
|
439
446
|
|
|
440
447
|
for (let i = 0; i < maxHops; i++) {
|
|
448
|
+
const controller = new AbortController();
|
|
449
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
441
450
|
const res = await fetch(current, {
|
|
442
451
|
headers: { "User-Agent": ua, Accept: "text/html" },
|
|
443
452
|
redirect: "manual",
|
|
453
|
+
signal: controller.signal,
|
|
444
454
|
});
|
|
455
|
+
clearTimeout(timeout);
|
|
445
456
|
|
|
446
457
|
chain.push({ url: current, status: res.status });
|
|
447
458
|
|
|
@@ -465,7 +476,10 @@ async function followRedirects(
|
|
|
465
476
|
|
|
466
477
|
async function checkImage(imageUrl: string, tag: string): Promise<ImageCheck> {
|
|
467
478
|
try {
|
|
468
|
-
const
|
|
479
|
+
const controller = new AbortController();
|
|
480
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
481
|
+
const res = await fetch(imageUrl, { method: "HEAD", redirect: "follow", signal: controller.signal });
|
|
482
|
+
clearTimeout(timeout);
|
|
469
483
|
return {
|
|
470
484
|
url: imageUrl,
|
|
471
485
|
tag,
|
|
@@ -614,11 +628,15 @@ async function checkLink(href: string): Promise<LinkResult> {
|
|
|
614
628
|
|
|
615
629
|
try {
|
|
616
630
|
for (let i = 0; i < 10; i++) {
|
|
631
|
+
const controller = new AbortController();
|
|
632
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
617
633
|
const res = await fetch(current, {
|
|
618
|
-
method: "
|
|
634
|
+
method: "HEAD",
|
|
619
635
|
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
620
636
|
redirect: "manual",
|
|
637
|
+
signal: controller.signal,
|
|
621
638
|
});
|
|
639
|
+
clearTimeout(timeout);
|
|
622
640
|
|
|
623
641
|
chain.push({ url: current, status: res.status });
|
|
624
642
|
|
|
@@ -935,9 +953,20 @@ export function registerPageTool(server: McpServer): void {
|
|
|
935
953
|
const lines: string[] = [`=== Batch Page Analysis (${results.length} URLs) ===`, ""];
|
|
936
954
|
|
|
937
955
|
for (const r of results) {
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
956
|
+
let label: string;
|
|
957
|
+
try {
|
|
958
|
+
const u = new URL(r.url);
|
|
959
|
+
const allSameHost = results.every((x) => {
|
|
960
|
+
try {
|
|
961
|
+
return new URL(x.url).hostname === u.hostname;
|
|
962
|
+
} catch {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
label = allSameHost ? u.pathname : `${u.hostname}${u.pathname}`;
|
|
967
|
+
} catch {
|
|
968
|
+
label = r.url;
|
|
969
|
+
}
|
|
941
970
|
lines.push(label);
|
|
942
971
|
lines.push(` Title: ${r.title}`);
|
|
943
972
|
lines.push(` Description: ${r.description} Canonical: ${r.canonical} JSON-LD: ${r.jsonLd}`);
|
package/src/tools/search.ts
CHANGED
|
@@ -430,10 +430,16 @@ function formatComparison(
|
|
|
430
430
|
}
|
|
431
431
|
|
|
432
432
|
function daysAgo(n: number): string {
|
|
433
|
-
// GSC dates are in
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
433
|
+
// GSC dates are in Pacific Time. Use Intl to handle DST correctly.
|
|
434
|
+
const d = new Date();
|
|
435
|
+
d.setDate(d.getDate() - n);
|
|
436
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
437
|
+
timeZone: "America/Los_Angeles",
|
|
438
|
+
year: "numeric",
|
|
439
|
+
month: "2-digit",
|
|
440
|
+
day: "2-digit",
|
|
441
|
+
}).format(d);
|
|
442
|
+
return parts; // en-CA formats as YYYY-MM-DD
|
|
437
443
|
}
|
|
438
444
|
|
|
439
445
|
// ── Tool registration ──
|
|
@@ -630,6 +636,13 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
630
636
|
|
|
631
637
|
const startDate = start_date ?? daysAgo(28);
|
|
632
638
|
const endDate = end_date ?? daysAgo(3);
|
|
639
|
+
|
|
640
|
+
if (start_date && Number.isNaN(new Date(start_date).getTime())) {
|
|
641
|
+
return textResult(`Error: invalid start_date "${start_date}". Use YYYY-MM-DD format.`);
|
|
642
|
+
}
|
|
643
|
+
if (end_date && Number.isNaN(new Date(end_date).getTime())) {
|
|
644
|
+
return textResult(`Error: invalid end_date "${end_date}". Use YYYY-MM-DD format.`);
|
|
645
|
+
}
|
|
633
646
|
const dims = dimensions ?? ["query", "page"];
|
|
634
647
|
|
|
635
648
|
const filterGroups =
|
package/src/tools/speed.ts
CHANGED
|
@@ -312,6 +312,7 @@ function formatDelta(a: number | null, b: number | null): string {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
function formatBatchCompare(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
|
|
315
|
+
if (results.length < 2) return "Error: compare requires at least 2 results.";
|
|
315
316
|
const [a, b] = results;
|
|
316
317
|
const lhrA = a.result.lighthouseResult;
|
|
317
318
|
const lhrB = b.result.lighthouseResult;
|
|
@@ -395,6 +396,7 @@ function formatBatchCompare(results: Array<{ url: string; result: PsiResult }>,
|
|
|
395
396
|
}
|
|
396
397
|
|
|
397
398
|
function formatBatchTable(results: Array<{ url: string; result: PsiResult }>, strategy: string): string {
|
|
399
|
+
if (results.length === 0) return "Error: no results to display.";
|
|
398
400
|
const lines: string[] = [`=== Batch PageSpeed (${results.length} URLs, ${strategy}) ===`, ""];
|
|
399
401
|
|
|
400
402
|
// Collect all category IDs from first result
|
|
@@ -813,10 +815,10 @@ export function registerSpeedTool(server: McpServer): void {
|
|
|
813
815
|
const cruxOrigin = origin;
|
|
814
816
|
|
|
815
817
|
if (!cruxUrl && !cruxOrigin) {
|
|
816
|
-
return { content: [{ type: "text" as const, text: "Error: provide
|
|
818
|
+
return { content: [{ type: "text" as const, text: "Error: provide url or origin for CrUX data." }] };
|
|
817
819
|
}
|
|
818
820
|
if (cruxUrl && cruxOrigin) {
|
|
819
|
-
return { content: [{ type: "text" as const, text: "Error: provide
|
|
821
|
+
return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
|
|
820
822
|
}
|
|
821
823
|
|
|
822
824
|
try {
|
|
@@ -860,10 +862,10 @@ export function registerSpeedTool(server: McpServer): void {
|
|
|
860
862
|
const histOrigin = origin;
|
|
861
863
|
|
|
862
864
|
if (!histUrl && !histOrigin) {
|
|
863
|
-
return { content: [{ type: "text" as const, text: "Error: provide
|
|
865
|
+
return { content: [{ type: "text" as const, text: "Error: provide url or origin for CrUX history." }] };
|
|
864
866
|
}
|
|
865
867
|
if (histUrl && histOrigin) {
|
|
866
|
-
return { content: [{ type: "text" as const, text: "Error: provide
|
|
868
|
+
return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
|
|
867
869
|
}
|
|
868
870
|
|
|
869
871
|
try {
|