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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
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
- // --- CrUX Daily API ---
72
-
73
- export async function queryCrux(options: {
74
- url?: string;
75
- origin?: string;
76
- formFactor?: CruxFormFactor;
77
- metrics?: string[];
78
- }): Promise<CruxResponse> {
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}:queryRecord?key=${key}`, {
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<CruxResponse>;
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 async function queryCruxHistory(options: {
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
- const key = getApiKey();
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
- return res.json();
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
- return res.json() as Promise<PsiResult>;
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];
@@ -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
- const xml = await res.text();
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
 
@@ -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
- const path = new URL(r.url).pathname;
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 res = await fetch(imageUrl, { method: "HEAD", redirect: "follow" });
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
- const u = new URL(r.url);
939
- const allSameHost = results.every((x) => new URL(x.url).hostname === u.hostname);
940
- const label = allSameHost ? u.pathname : `${u.hostname}${u.pathname}`;
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}`);
@@ -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 =
@@ -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