pagesight 0.17.0 → 0.18.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.
Files changed (70) hide show
  1. package/README.md +17 -83
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/snapshots.md +123 -0
  5. package/docs/usage.md +130 -0
  6. package/package.json +26 -29
  7. package/src/api/bing.ts +83 -0
  8. package/src/api/compare-snapshots.ts +377 -0
  9. package/src/api/discover.ts +39 -0
  10. package/src/api/doctor.ts +35 -0
  11. package/src/api/evidence-schema.ts +78 -0
  12. package/src/api/evidence.ts +99 -0
  13. package/src/api/execute.ts +147 -0
  14. package/src/api/index.ts +5 -0
  15. package/src/api/reports.ts +90 -0
  16. package/src/api/schema.ts +207 -0
  17. package/src/api/snapshot.ts +183 -0
  18. package/src/api/ui-findings.ts +68 -0
  19. package/src/cli.ts +182 -0
  20. package/src/http.ts +39 -0
  21. package/src/index.ts +8 -25
  22. package/src/mcp-server.ts +27 -0
  23. package/src/mcp.ts +5 -0
  24. package/src/providers/bing.ts +48 -0
  25. package/src/{lib → providers}/crux.ts +4 -9
  26. package/src/providers/ga.ts +114 -0
  27. package/src/providers/google-tokens.ts +86 -0
  28. package/src/providers/gsc-auth.ts +93 -0
  29. package/src/{lib → providers}/gsc.ts +27 -24
  30. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  31. package/src/shared/dates.ts +32 -0
  32. package/src/shared/http.ts +63 -0
  33. package/src/tools/ai.ts +19 -27
  34. package/src/tools/audit.ts +18 -98
  35. package/src/tools/observe.ts +28 -0
  36. package/src/tools/page/analyze.ts +194 -0
  37. package/src/tools/page/batch.ts +163 -0
  38. package/src/tools/page/contrast.ts +128 -0
  39. package/src/tools/page/links.ts +200 -0
  40. package/src/tools/page/metadata.ts +225 -0
  41. package/src/tools/page/structured-data.ts +288 -0
  42. package/src/tools/page/tool.ts +48 -0
  43. package/src/tools/search/actions.ts +56 -0
  44. package/src/tools/search/analytics.ts +266 -0
  45. package/src/tools/search/coverage.ts +247 -0
  46. package/src/tools/search/gaps.ts +129 -0
  47. package/src/tools/search/inspection.ts +160 -0
  48. package/src/tools/search/result.ts +3 -0
  49. package/src/tools/search/sample.ts +110 -0
  50. package/src/tools/search/schema.ts +62 -0
  51. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  52. package/src/tools/search/sites.ts +86 -0
  53. package/src/tools/search/tool.ts +11 -0
  54. package/src/tools/setup.ts +1 -1
  55. package/src/tools/speed/analyze.ts +191 -0
  56. package/src/tools/speed/batch.ts +265 -0
  57. package/src/tools/speed/crux.ts +176 -0
  58. package/src/tools/speed/pagespeed.ts +273 -0
  59. package/src/tools/speed/schema.ts +39 -0
  60. package/src/tools/speed/tool.ts +11 -0
  61. package/src/web/fetch.ts +31 -0
  62. package/src/web/images.ts +62 -0
  63. package/src/web/page-observation.ts +69 -0
  64. package/src/{lib → web}/robots.ts +20 -12
  65. package/src/web/sitemap-inventory.ts +64 -0
  66. package/src/web/sitemap-parser.ts +59 -0
  67. package/src/lib/auth.ts +0 -187
  68. package/src/tools/page.ts +0 -1241
  69. package/src/tools/search.ts +0 -1118
  70. package/src/tools/speed.ts +0 -956
@@ -0,0 +1,114 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { getOAuthToken, getServiceAccountToken } from "./google-tokens.js";
4
+ import { RequestError, requestJson } from "../shared/http.js";
5
+
6
+ const SCOPE = "https://www.googleapis.com/auth/analytics.readonly";
7
+ let cached: { path: string; token: string; expiresAt: number } | undefined;
8
+ let pending: Promise<string> | undefined;
9
+
10
+ export function gaCredentialPath(): string {
11
+ return (
12
+ process.env.PAGESIGHT_GA_CREDENTIALS ||
13
+ process.env.GOOGLE_APPLICATION_CREDENTIALS ||
14
+ join(homedir(), ".config/gcloud/application_default_credentials.json")
15
+ );
16
+ }
17
+
18
+ export async function gaAccessToken(): Promise<string> {
19
+ const path = gaCredentialPath();
20
+ if (cached?.path === path && cached.expiresAt > Date.now()) return cached.token;
21
+ if (pending) return pending;
22
+ pending = (async () => {
23
+ if (!(await Bun.file(path).exists()))
24
+ throw new RequestError(
25
+ "Set PAGESIGHT_GA_CREDENTIALS or GOOGLE_APPLICATION_CREDENTIALS to a Google credential JSON file",
26
+ null,
27
+ "not_configured",
28
+ );
29
+ let key: { type?: string; client_id?: string; client_secret?: string; refresh_token?: string };
30
+ try {
31
+ key = await Bun.file(path).json();
32
+ if (!key || typeof key !== "object") throw new Error();
33
+ } catch {
34
+ throw new RequestError("Cannot parse configured GA credential JSON", null, "invalid_credentials");
35
+ }
36
+ let token: string;
37
+ if (key.type === "service_account") token = await getServiceAccountToken(path, SCOPE);
38
+ else if (
39
+ key.type === "authorized_user" &&
40
+ typeof key.client_id === "string" &&
41
+ key.client_id &&
42
+ typeof key.client_secret === "string" &&
43
+ key.client_secret &&
44
+ typeof key.refresh_token === "string" &&
45
+ key.refresh_token
46
+ ) {
47
+ token = await getOAuthToken(key.client_id, key.client_secret, key.refresh_token);
48
+ } else
49
+ throw new RequestError(
50
+ "Supported GA credentials: service_account or authorized_user ADC",
51
+ null,
52
+ "unsupported_credentials",
53
+ );
54
+ cached = { path, token, expiresAt: Date.now() + 3_000_000 };
55
+ return token;
56
+ })();
57
+ try {
58
+ return await pending;
59
+ } finally {
60
+ pending = undefined;
61
+ }
62
+ }
63
+
64
+ export async function gaFetch<T>(path: string, body?: unknown, admin = false): Promise<T> {
65
+ const token = await gaAccessToken();
66
+ const base = admin ? "https://analyticsadmin.googleapis.com/v1beta/" : "https://analyticsdata.googleapis.com/v1beta/";
67
+ try {
68
+ return await requestJson<T>(base + path, {
69
+ method: body ? "POST" : "GET",
70
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
71
+ body: body ? JSON.stringify(body) : undefined,
72
+ });
73
+ } catch (error) {
74
+ if (error instanceof RequestError && error.status === 401) cached = undefined;
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ export function normalizeGaProperty(id: string): string {
80
+ const number = id.replace(/^properties\//, "");
81
+ if (!/^\d+$/.test(number)) throw new RequestError("GA property must be a numeric ID", null, "invalid_input");
82
+ return `properties/${number}`;
83
+ }
84
+
85
+ export interface GaReport {
86
+ rows?: Array<{ dimensionValues?: Array<{ value?: string }>; metricValues?: Array<{ value?: string }> }>;
87
+ rowCount?: number;
88
+ metadata?: {
89
+ timeZone?: string;
90
+ dataLossFromOtherRow?: boolean;
91
+ subjectToThresholding?: boolean;
92
+ samplingMetadatas?: unknown[];
93
+ [key: string]: unknown;
94
+ };
95
+ [key: string]: unknown;
96
+ }
97
+
98
+ export async function gaCredentialInfo() {
99
+ const source = process.env.PAGESIGHT_GA_CREDENTIALS
100
+ ? "PAGESIGHT_GA_CREDENTIALS"
101
+ : process.env.GOOGLE_APPLICATION_CREDENTIALS
102
+ ? "GOOGLE_APPLICATION_CREDENTIALS"
103
+ : "gcloud_adc";
104
+ try {
105
+ const key = await Bun.file(gaCredentialPath()).json();
106
+ return {
107
+ source,
108
+ type: typeof key.type === "string" ? key.type : null,
109
+ clientEmail: typeof key.client_email === "string" ? key.client_email : null,
110
+ };
111
+ } catch {
112
+ return { source, type: null, clientEmail: null };
113
+ }
114
+ }
@@ -0,0 +1,86 @@
1
+ import { RequestError, requestJson } from "../shared/http.js";
2
+
3
+ export const TOKEN_URL = "https://oauth2.googleapis.com/token";
4
+
5
+ interface TokenResponse {
6
+ access_token: string;
7
+ expires_in: number;
8
+ token_type: string;
9
+ }
10
+
11
+ export async function getServiceAccountToken(keyPath: string, scope: string): Promise<string> {
12
+ const keyFile = JSON.parse(await Bun.file(keyPath).text());
13
+ if (!keyFile.client_email || !keyFile.private_key) {
14
+ throw new Error("Service account key file missing client_email or private_key");
15
+ }
16
+ const now = Math.floor(Date.now() / 1000);
17
+
18
+ const header = toBase64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
19
+ const payload = toBase64Url(
20
+ JSON.stringify({
21
+ iss: keyFile.client_email,
22
+ scope,
23
+ aud: TOKEN_URL,
24
+ iat: now,
25
+ exp: now + 3600,
26
+ }),
27
+ );
28
+
29
+ const signingInput = `${header}.${payload}`;
30
+ const key = await crypto.subtle.importKey(
31
+ "pkcs8",
32
+ pemToBuffer(keyFile.private_key),
33
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
34
+ false,
35
+ ["sign"],
36
+ );
37
+ const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
38
+ const jwt = `${signingInput}.${bufferToBase64Url(signature)}`;
39
+
40
+ const data = await requestJson<TokenResponse>(TOKEN_URL, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
43
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: jwt }),
44
+ });
45
+ if (!data.access_token) throw new RequestError("Token exchange returned no access token", null, "invalid_response");
46
+ return data.access_token;
47
+ }
48
+
49
+ function pemToBuffer(pem: string): ArrayBuffer {
50
+ const b64 = pem
51
+ .replace(/-----BEGIN PRIVATE KEY-----/g, "")
52
+ .replace(/-----END PRIVATE KEY-----/g, "")
53
+ .replace(/\s/g, "");
54
+ const binary = atob(b64);
55
+ const buf = new Uint8Array(binary.length);
56
+ for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
57
+ return buf.buffer;
58
+ }
59
+
60
+ function toBase64Url(input: string): string {
61
+ return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
62
+ }
63
+
64
+ function bufferToBase64Url(buf: ArrayBuffer): string {
65
+ const bytes = new Uint8Array(buf);
66
+ let binary = "";
67
+ for (const b of bytes) binary += String.fromCharCode(b);
68
+ return toBase64Url(binary);
69
+ }
70
+
71
+ // --- OAuth Refresh Token Auth ---
72
+
73
+ export async function getOAuthToken(clientId: string, clientSecret: string, refreshToken: string): Promise<string> {
74
+ const data = await requestJson<TokenResponse>(TOKEN_URL, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
77
+ body: new URLSearchParams({
78
+ grant_type: "refresh_token",
79
+ client_id: clientId,
80
+ client_secret: clientSecret,
81
+ refresh_token: refreshToken,
82
+ }),
83
+ });
84
+ if (!data.access_token) throw new RequestError("Token refresh returned no access token", null, "invalid_response");
85
+ return data.access_token;
86
+ }
@@ -0,0 +1,93 @@
1
+ import { getOAuthToken, getServiceAccountToken, TOKEN_URL } from "./google-tokens.js";
2
+ import { existsSync } from "node:fs";
3
+ import { RequestError } from "../shared/http.js";
4
+
5
+ const SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"];
6
+ let cachedToken: { token: string; expiresAt: number } | null = null;
7
+
8
+ // --- Service Account Auth ---
9
+
10
+ // --- Public API ---
11
+
12
+ export async function getAccessToken(): Promise<string> {
13
+ if (cachedToken && Date.now() < cachedToken.expiresAt) {
14
+ return cachedToken.token;
15
+ }
16
+
17
+ // Try service account first
18
+ const saKeyPath = process.env.GSC_SERVICE_ACCOUNT_KEY;
19
+ if (saKeyPath && existsSync(saKeyPath)) {
20
+ const token = await getServiceAccountToken(saKeyPath, SCOPES.join(" "));
21
+ cachedToken = { token, expiresAt: Date.now() + 3500_000 }; // ~58 min
22
+ return token;
23
+ }
24
+
25
+ // Try OAuth refresh token
26
+ const clientId = process.env.GSC_CLIENT_ID;
27
+ const clientSecret = process.env.GSC_CLIENT_SECRET;
28
+ const refreshToken = process.env.GSC_REFRESH_TOKEN;
29
+
30
+ if (clientId && clientSecret && refreshToken) {
31
+ const token = await getOAuthToken(clientId, clientSecret, refreshToken);
32
+ cachedToken = { token, expiresAt: Date.now() + 3500_000 };
33
+ return token;
34
+ }
35
+
36
+ throw new RequestError(
37
+ "No GSC credentials configured. Set either:\n" +
38
+ " - GSC_SERVICE_ACCOUNT_KEY (path to service account JSON)\n" +
39
+ " - GSC_CLIENT_ID + GSC_CLIENT_SECRET + GSC_REFRESH_TOKEN (OAuth 2.0)",
40
+ null,
41
+ "not_configured",
42
+ );
43
+ }
44
+
45
+ export function clearTokenCache(): void {
46
+ cachedToken = null;
47
+ }
48
+
49
+ export function getAuthMethod(): string {
50
+ if (process.env.GSC_SERVICE_ACCOUNT_KEY) return "service_account";
51
+ if (process.env.GSC_REFRESH_TOKEN) return "oauth";
52
+ return "none";
53
+ }
54
+
55
+ // --- OAuth Setup Helper ---
56
+
57
+ export function getOAuthSetupUrl(clientId: string): string {
58
+ const params = new URLSearchParams({
59
+ client_id: clientId,
60
+ redirect_uri: "http://localhost",
61
+ response_type: "code",
62
+ scope: SCOPES.join(" "),
63
+ access_type: "offline",
64
+ prompt: "consent",
65
+ });
66
+ return `https://accounts.google.com/o/oauth2/auth?${params}`;
67
+ }
68
+
69
+ export async function exchangeCodeForToken(
70
+ clientId: string,
71
+ clientSecret: string,
72
+ code: string,
73
+ ): Promise<{ refreshToken: string; accessToken: string }> {
74
+ const res = await fetch(TOKEN_URL, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
77
+ body: new URLSearchParams({
78
+ grant_type: "authorization_code",
79
+ client_id: clientId,
80
+ client_secret: clientSecret,
81
+ code,
82
+ redirect_uri: "http://localhost",
83
+ }).toString(),
84
+ });
85
+
86
+ if (!res.ok) {
87
+ const err = await res.text();
88
+ throw new Error(`Code exchange failed: ${err}`);
89
+ }
90
+
91
+ const data = (await res.json()) as Record<string, string>;
92
+ return { refreshToken: data.refresh_token, accessToken: data.access_token };
93
+ }
@@ -1,30 +1,20 @@
1
- import { clearTokenCache, getAccessToken } from "./auth.js";
1
+ import { clearTokenCache, getAccessToken } from "./gsc-auth.js";
2
+ import { RequestError, requestJson } from "../shared/http.js";
2
3
 
3
4
  const GSC_API = "https://searchconsole.googleapis.com/v1";
4
5
  const WEBMASTERS_API = "https://www.googleapis.com/webmasters/v3";
5
6
 
6
7
  async function gscFetch(url: string, body?: unknown): Promise<Record<string, unknown>> {
7
8
  const token = await getAccessToken();
8
- const res = await fetch(url, {
9
- method: body ? "POST" : "GET",
10
- headers: {
11
- Authorization: `Bearer ${token}`,
12
- "Content-Type": "application/json",
13
- },
14
- body: body ? JSON.stringify(body) : undefined,
15
- });
16
-
17
- if (!res.ok) {
18
- // Clear cached token on 401 so next call gets a fresh one
19
- if (res.status === 401) clearTokenCache();
20
- const err = await res.text();
21
- throw new Error(`GSC API error (${res.status}): ${err}`);
22
- }
23
-
24
9
  try {
25
- return (await res.json()) as Record<string, unknown>;
26
- } catch {
27
- throw new Error(`GSC API returned invalid JSON (${res.status})`);
10
+ return await requestJson<Record<string, unknown>>(url, {
11
+ method: body ? "POST" : "GET",
12
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
13
+ body: body ? JSON.stringify(body) : undefined,
14
+ });
15
+ } catch (error) {
16
+ if (error instanceof RequestError && error.status === 401) clearTokenCache();
17
+ throw error;
28
18
  }
29
19
  }
30
20
 
@@ -62,6 +52,11 @@ export interface InspectionResult {
62
52
  }
63
53
 
64
54
  export async function inspectUrl(inspectionUrl: string, siteUrl: string): Promise<InspectionResult> {
55
+ const data = await inspectUrlResponse(inspectionUrl, siteUrl);
56
+ return data.inspectionResult as InspectionResult;
57
+ }
58
+
59
+ export async function inspectUrlResponse(inspectionUrl: string, siteUrl: string): Promise<Record<string, unknown>> {
65
60
  const data = await gscFetch(`${GSC_API}/urlInspection/index:inspect`, {
66
61
  inspectionUrl,
67
62
  siteUrl,
@@ -69,7 +64,7 @@ export async function inspectUrl(inspectionUrl: string, siteUrl: string): Promis
69
64
  if (!data.inspectionResult) {
70
65
  throw new Error("GSC API returned no inspection result");
71
66
  }
72
- return data.inspectionResult as InspectionResult;
67
+ return data;
73
68
  }
74
69
 
75
70
  // --- Search Analytics ---
@@ -121,7 +116,7 @@ export async function querySearchAnalytics(
121
116
  dimensions: options.dimensions ?? ["query", "page"],
122
117
  type: options.type ?? "web",
123
118
  rowLimit: options.rowLimit ?? 1000,
124
- dataState: options.dataState ?? "all",
119
+ dataState: options.dataState ?? "final",
125
120
  };
126
121
 
127
122
  if (options.startRow !== undefined) body.startRow = options.startRow;
@@ -140,10 +135,14 @@ export interface GscSite {
140
135
  }
141
136
 
142
137
  export async function listSites(): Promise<GscSite[]> {
143
- const data = await gscFetch(`${WEBMASTERS_API}/sites`);
138
+ const data = await listSitesResponse();
144
139
  return (data.siteEntry as GscSite[] | undefined) ?? [];
145
140
  }
146
141
 
142
+ export function listSitesResponse(): Promise<Record<string, unknown>> {
143
+ return gscFetch(`${WEBMASTERS_API}/sites`);
144
+ }
145
+
147
146
  export async function getSite(siteUrl: string): Promise<GscSite> {
148
147
  const data = await gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}`);
149
148
  return data as unknown as GscSite;
@@ -170,10 +169,14 @@ export interface GscSitemap {
170
169
  }
171
170
 
172
171
  export async function listSitemaps(siteUrl: string): Promise<GscSitemap[]> {
173
- const data = await gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/sitemaps`);
172
+ const data = await listSitemapsResponse(siteUrl);
174
173
  return (data.sitemap as GscSitemap[] | undefined) ?? [];
175
174
  }
176
175
 
176
+ export function listSitemapsResponse(siteUrl: string): Promise<Record<string, unknown>> {
177
+ return gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/sitemaps`);
178
+ }
179
+
177
180
  export async function getSitemap(siteUrl: string, feedpath: string): Promise<GscSitemap> {
178
181
  const data = await gscFetch(
179
182
  `${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`,
@@ -1,3 +1,5 @@
1
+ import { requestJson } from "../shared/http.js";
2
+
1
3
  const PSI_API = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
2
4
 
3
5
  export interface PsiCategory {
@@ -106,18 +108,5 @@ export async function runPagespeed(
106
108
 
107
109
  if (options?.locale) params.set("locale", options.locale);
108
110
 
109
- const res = await fetch(`${PSI_API}?${params}`, {
110
- headers: { "User-Agent": "Pagesight/0.1" },
111
- });
112
-
113
- if (!res.ok) {
114
- const err = await res.text();
115
- throw new Error(`PageSpeed API error (${res.status}): ${err}`);
116
- }
117
-
118
- try {
119
- return (await res.json()) as PsiResult;
120
- } catch {
121
- throw new Error(`PageSpeed API returned invalid JSON (${res.status})`);
122
- }
111
+ return requestJson<PsiResult>(`${PSI_API}?${params}`, { headers: { "User-Agent": "Pagesight/0.17" } }, 60_000);
123
112
  }
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+
3
+ export const dateSchema = z
4
+ .string()
5
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
6
+ .refine((value) => {
7
+ const date = new Date(value);
8
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
9
+ }, "Use a real YYYY-MM-DD date");
10
+
11
+ export function pacificDate(now = new Date()): string {
12
+ return new Intl.DateTimeFormat("en-CA", {
13
+ timeZone: "America/Los_Angeles",
14
+ year: "numeric",
15
+ month: "2-digit",
16
+ day: "2-digit",
17
+ }).format(now);
18
+ }
19
+
20
+ export function defaultDates(now = new Date()): { startDate: string; endDate: string } {
21
+ const end = new Date(`${pacificDate(now)}T12:00:00Z`);
22
+ end.setUTCDate(end.getUTCDate() - 3);
23
+ const start = new Date(end);
24
+ start.setUTCDate(start.getUTCDate() - 27);
25
+ return { startDate: start.toISOString().slice(0, 10), endDate: end.toISOString().slice(0, 10) };
26
+ }
27
+
28
+ export function pacificDaysAgo(days: number, now = new Date()): string {
29
+ const date = new Date(`${pacificDate(now)}T12:00:00Z`);
30
+ date.setUTCDate(date.getUTCDate() - days);
31
+ return date.toISOString().slice(0, 10);
32
+ }
@@ -0,0 +1,63 @@
1
+ export class RequestError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly status: number | null = null,
5
+ public readonly code = "request_failed",
6
+ ) {
7
+ super(message);
8
+ }
9
+ }
10
+
11
+ export async function requestJson<T>(url: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<T> {
12
+ const signal = AbortSignal.timeout(timeoutMs);
13
+ let response: Response;
14
+ try {
15
+ response = await fetch(url, { ...init, signal });
16
+ } catch {
17
+ throw new RequestError("Request failed or timed out", null, "network_error");
18
+ }
19
+ if (!response.ok) {
20
+ // Provider bodies and request URLs can contain credentials; never echo them into evidence.
21
+ const code =
22
+ response.status === 401
23
+ ? "unauthenticated"
24
+ : response.status === 403
25
+ ? "forbidden"
26
+ : response.status === 429
27
+ ? "quota_exceeded"
28
+ : response.status === 404
29
+ ? "not_found"
30
+ : "provider_error";
31
+ throw new RequestError(`Provider returned HTTP ${response.status}`, response.status, code);
32
+ }
33
+ try {
34
+ return (await response.json()) as T;
35
+ } catch (error) {
36
+ if (signal.aborted || !(error instanceof SyntaxError))
37
+ throw new RequestError("Response body failed or timed out", null, "network_error");
38
+ throw new RequestError("Provider returned invalid JSON", response.status, "invalid_response");
39
+ }
40
+ }
41
+
42
+ export async function readBounded(response: Response, maxBytes: number): Promise<string> {
43
+ if (Number(response.headers.get("content-length")) > maxBytes) {
44
+ await response.body?.cancel();
45
+ throw new RequestError("Response exceeds byte limit", null, "size_limit");
46
+ }
47
+ const reader = response.body?.getReader();
48
+ if (!reader) return "";
49
+ const chunks: Uint8Array[] = [];
50
+ let size = 0;
51
+ try {
52
+ while (true) {
53
+ const { done, value } = await reader.read();
54
+ if (done) break;
55
+ size += value.byteLength;
56
+ if (size > maxBytes) throw new RequestError("Response exceeds byte limit", null, "size_limit");
57
+ chunks.push(value);
58
+ }
59
+ } finally {
60
+ await reader.cancel();
61
+ }
62
+ return Buffer.concat(chunks).toString("utf8");
63
+ }
package/src/tools/ai.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { auditAiCrawlers, type CrawlerStatus, fetchRobotsTxt, isAllowed, type RobotsTxt } from "../lib/robots.js";
4
-
5
- // --- llms.txt detection ---
3
+ import { readBounded, RequestError } from "../shared/http.js";
4
+ import { auditAiCrawlers, type CrawlerStatus, fetchRobotsTxt, isAllowed, type RobotsTxt } from "../web/robots.js";
6
5
 
7
6
  interface LlmsTxtResult {
8
7
  exists: boolean;
@@ -15,32 +14,20 @@ interface LlmsTxtResult {
15
14
 
16
15
  async function checkLlmsTxt(origin: string, path: string): Promise<LlmsTxtResult> {
17
16
  try {
18
- const controller = new AbortController();
19
- const timeout = setTimeout(() => controller.abort(), 10_000);
20
17
  const res = await fetch(`${origin}${path}`, {
21
18
  headers: { "User-Agent": "Pagesight/1.0" },
22
19
  redirect: "follow",
23
- signal: controller.signal,
20
+ signal: AbortSignal.timeout(10_000),
24
21
  });
25
- clearTimeout(timeout);
26
22
  if (!res.ok) return { exists: false, size: null, firstLine: null, lineCount: null, sections: [], linkCount: null };
27
- // Cap at 1MB to avoid OOM on large responses
28
- const contentLength = Number(res.headers.get("content-length") ?? 0);
29
- if (contentLength > 1_048_576) {
30
- return {
31
- exists: true,
32
- size: contentLength,
33
- firstLine: "(file too large to preview)",
34
- lineCount: null,
35
- sections: [],
36
- linkCount: null,
37
- };
38
- }
39
- const text = await res.text();
40
- if (text.length > 1_048_576) {
23
+ let text: string;
24
+ try {
25
+ text = await readBounded(res, 1_048_576);
26
+ } catch (error) {
27
+ if (!(error instanceof RequestError) || error.code !== "size_limit") throw error;
41
28
  return {
42
29
  exists: true,
43
- size: text.length,
30
+ size: null,
44
31
  firstLine: "(file too large to preview)",
45
32
  lineCount: null,
46
33
  sections: [],
@@ -51,7 +38,14 @@ async function checkLlmsTxt(origin: string, path: string): Promise<LlmsTxtResult
51
38
  const firstLine = textLines.find((l) => l.trim().length > 0)?.trim() ?? null;
52
39
  const sections = textLines.filter((l) => /^##\s/.test(l)).map((l) => l.replace(/^##\s+/, "").trim());
53
40
  const linkCount = (text.match(/\[.*?\]\(https?:\/\/[^)]+\)/g) ?? []).length;
54
- return { exists: true, size: text.length, firstLine, lineCount: textLines.length, sections, linkCount };
41
+ return {
42
+ exists: true,
43
+ size: new TextEncoder().encode(text).byteLength,
44
+ firstLine,
45
+ lineCount: textLines.length,
46
+ sections,
47
+ linkCount,
48
+ };
55
49
  } catch {
56
50
  return { exists: false, size: null, firstLine: null, lineCount: null, sections: [], linkCount: null };
57
51
  }
@@ -84,9 +78,7 @@ function formatLlmsTxt(llmsTxt: LlmsTxtResult, llmsFullTxt: LlmsTxtResult): stri
84
78
  return lines;
85
79
  }
86
80
 
87
- // --- Category summary ---
88
-
89
- function formatCategorySummary(crawlers: CrawlerStatus[]): string[] {
81
+ export function formatCategorySummary(crawlers: CrawlerStatus[]): string[] {
90
82
  const categoryOrder = ["Training", "Search", "Assistant", "Agent", "Other"];
91
83
  const summary = new Map<string, { blocked: number; allowed: number }>();
92
84
 
@@ -245,7 +237,7 @@ function formatRobotsAudit(origin: string, robots: RobotsTxt, statusCode: number
245
237
  export function registerAiTool(server: McpServer): void {
246
238
  server.tool(
247
239
  "ai",
248
- "Analyze your site's AI visibility. Audits AI crawler access (training, search, assistant, agent) via robots.txt, checks for llms.txt and llms-full.txt, validates syntax per RFC 9309, and tests specific path access. Shows how AI systems see your site.",
240
+ "Analyze your site's AI visibility. Audits AI crawler access (training, search, assistant, agent) via robots.txt, checks for llms.txt and llms-full.txt, reports robots.txt parse errors, and tests specific path access. Shows how AI systems see your site.",
249
241
  {
250
242
  url: z
251
243
  .string()