pagesight 0.14.1 → 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/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/audit.ts +7 -5
- package/src/tools/page.ts +36 -7
- package/src/tools/search.ts +7 -0
- package/src/tools/speed.ts +2 -0
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/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
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
|
@@ -636,6 +636,13 @@ export function registerSearchTool(server: McpServer): void {
|
|
|
636
636
|
|
|
637
637
|
const startDate = start_date ?? daysAgo(28);
|
|
638
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
|
+
}
|
|
639
646
|
const dims = dimensions ?? ["query", "page"];
|
|
640
647
|
|
|
641
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
|