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 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 7,772 sitemap URLs submitted, 0 indexed
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 No structured data (JSON-LD) found
30
+ LOW 6/139 AI crawlers blocked
22
31
  ```
23
32
 
24
33
  ## Tools
25
34
 
26
- | Tool | What it does |
27
- |------|-------------|
28
- | `audit` | One-call site audit. Runs all checks in parallel. Returns prioritized findings. |
29
- | `pagespeed` | Lighthouse scores, Core Web Vitals, opportunities, failing audits with fix links. |
30
- | `metatags` | OG, Twitter Card, canonical, JSON-LD with schema validation, redirect chain, image validation. |
31
- | `inspect` | Google index status, canonical choice, crawl state, rich results. |
32
- | `sample_inspect` | Sample URLs from a sitemap and batch-inspect. Diagnoses indexing patterns. |
33
- | `performance` | Search analytics — clicks, impressions, CTR, position. `compare: true` for period-over-period. |
34
- | `crux` | Real-user Core Web Vitals (p75, histograms). |
35
- | `crux_history` | CWV trends over time — up to 40 weekly data points. |
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
- `robots`, `metatags`, and `pagespeed` work without credentials.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.14.0",
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
 
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()
@@ -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
- method: "GET",
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}`);
@@ -430,10 +430,16 @@ function formatComparison(
430
430
  }
431
431
 
432
432
  function daysAgo(n: number): string {
433
- // GSC dates are in PT (Pacific Time). Use UTC-8 as a stable approximation.
434
- const now = new Date(Date.now() - 8 * 60 * 60 * 1000);
435
- now.setDate(now.getDate() - n);
436
- return now.toISOString().split("T")[0];
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 =
@@ -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 either url or origin, not both." }] };
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 either url or origin, not both." }] };
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 either url or origin, not both." }] };
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 either url or origin, not both." }] };
868
+ return { content: [{ type: "text" as const, text: "Error: provide url or origin, not both." }] };
867
869
  }
868
870
 
869
871
  try {