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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * robots.txt parser per RFC 9309
2
+ * robots.txt parser based on RFC 9309
3
3
  * https://www.rfc-editor.org/rfc/rfc9309
4
4
  *
5
5
  * AI crawler registry sourced from:
@@ -152,17 +152,24 @@ export function parseRobotsTxt(raw: string): RobotsTxt {
152
152
  return { groups, sitemaps, raw, errors };
153
153
  }
154
154
 
155
- // --- Matching (per RFC 9309) ---
155
+ // --- Matching ---
156
+
157
+ // RFC 9309 §2.2.2: decode only unreserved ASCII; preserve reserved escapes on both sides.
158
+ function normalizePath(path: string): string {
159
+ return path.replace(/%[0-9a-f]{2}|\P{ASCII}/giu, (part) => {
160
+ if (part.startsWith("%")) {
161
+ const decoded = String.fromCharCode(Number.parseInt(part.slice(1), 16));
162
+ return /^[a-z0-9._~-]$/i.test(decoded) ? decoded : part.toUpperCase();
163
+ }
164
+ return encodeURIComponent(part.toWellFormed());
165
+ });
166
+ }
156
167
 
157
168
  function pathMatches(pattern: string, path: string): boolean {
158
169
  if (!pattern) return false;
159
170
 
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
- }
171
+ path = normalizePath(path);
172
+ pattern = normalizePath(pattern);
166
173
 
167
174
  let regex = "^";
168
175
  for (let i = 0; i < pattern.length; i++) {
@@ -231,7 +238,7 @@ export function isAllowed(
231
238
 
232
239
  for (const rule of matchedRules) {
233
240
  if (pathMatches(rule.path, path)) {
234
- const ruleLength = rule.path.length;
241
+ const ruleLength = normalizePath(rule.path).length;
235
242
  if (ruleLength > bestLength || (ruleLength === bestLength && rule.type === "allow")) {
236
243
  bestRule = rule;
237
244
  bestLength = ruleLength;
@@ -278,14 +285,15 @@ export async function fetchRobotsTxt(origin: string): Promise<{ robotsTxt: Robot
278
285
  redirect: "follow",
279
286
  });
280
287
 
281
- // RFC 9309: 4xx (except 429) = no restrictions (allow all)
282
- // 5xx and 429 = assume complete disallow
288
+ // RFC 9309 §§2.3.1.3–4 distinguish unavailable (4xx) from unreachable (5xx).
289
+ // Pagesight treats 429 conservatively; Google also excludes it from the allow-on-4xx rule.
290
+ // https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt#handling-http-status-codes
283
291
  if (res.status >= 500 || res.status === 429) {
284
292
  const disallowAll: RobotsTxt = {
285
293
  groups: [{ userAgents: ["*"], rules: [{ type: "disallow", path: "/" }] }],
286
294
  sitemaps: [],
287
295
  raw: "",
288
- errors: [`Server returned ${res.status} — treating as full disallow per RFC 9309`],
296
+ errors: [`Server returned ${res.status} — treating as full disallow`],
289
297
  };
290
298
  return { robotsTxt: disallowAll, statusCode: res.status };
291
299
  }
@@ -0,0 +1,64 @@
1
+ import { RequestError } from "../shared/http.js";
2
+ import { parseInventorySitemap } from "./sitemap-parser.js";
3
+ import { fetchText } from "./fetch.js";
4
+
5
+ export async function observeSitemap(url: string) {
6
+ const origin = new URL(url).origin;
7
+ const queue = [url];
8
+ const scheduled = new Set(queue);
9
+ let omittedChildReferences = 0;
10
+ const visited = new Set<string>();
11
+ const urls = new Set<string>();
12
+ const documents: Array<{ url: string; status: number; sha256: string; bytes: number }> = [];
13
+ const errors: Array<{ url: string; code: string }> = [];
14
+ const futureLastmods = new Set<string>();
15
+ const today = new Date().toISOString().slice(0, 10);
16
+ let bytes = 0;
17
+ while (queue.length && visited.size < 5 && bytes < 8_000_000) {
18
+ const next = queue.shift();
19
+ if (!next || visited.has(next)) continue;
20
+ visited.add(next);
21
+ try {
22
+ if (new URL(next).origin !== origin)
23
+ throw new RequestError("Child sitemap is outside the site origin", null, "outside_origin");
24
+ const page = await fetchText(next, 8_000_000 - bytes, origin);
25
+ if (!page.response.ok) throw new RequestError("Sitemap fetch failed", page.response.status);
26
+ bytes += Buffer.byteLength(page.body);
27
+ const parsed = parseInventorySitemap(page.body);
28
+ documents.push({
29
+ url: page.finalUrl,
30
+ status: page.response.status,
31
+ bytes: Buffer.byteLength(page.body),
32
+ sha256: Bun.CryptoHasher.hash("sha256", page.body, "hex"),
33
+ });
34
+ for (const child of parsed.children) {
35
+ if (scheduled.has(child)) continue;
36
+ if (scheduled.size >= 5) {
37
+ omittedChildReferences++;
38
+ continue;
39
+ }
40
+ scheduled.add(child);
41
+ queue.push(child);
42
+ }
43
+ for (const item of parsed.urls) urls.add(item);
44
+ for (const lastmod of parsed.lastmods) if (lastmod.slice(0, 10) > today) futureLastmods.add(lastmod);
45
+ } catch (error) {
46
+ errors.push({ url: next, code: error instanceof RequestError ? error.code : "fetch_failed" });
47
+ }
48
+ }
49
+ return {
50
+ sitemap: url,
51
+ complete: queue.length === 0 && errors.length === 0 && omittedChildReferences === 0,
52
+ documents,
53
+ urls: [...urls],
54
+ urlCount: urls.size,
55
+ bytes,
56
+ unvisited: queue,
57
+ omittedChildReferences,
58
+ errors,
59
+ futureLastmods: [...futureLastmods],
60
+ warnings: [
61
+ "Sitemap membership is not indexing evidence. Inventory is limited to five same-origin XML documents and 8 MB.",
62
+ ],
63
+ };
64
+ }
@@ -0,0 +1,59 @@
1
+ import { SaxesParser, type SaxesTagNS } from "saxes";
2
+ import { RequestError } from "../shared/http.js";
3
+
4
+ export function parseInventorySitemap(xml: string) {
5
+ const parser = new SaxesParser({ xmlns: true });
6
+ const stack: SaxesTagNS[] = [];
7
+ const urls: string[] = [];
8
+ const children: string[] = [];
9
+ const lastmods: string[] = [];
10
+ let root = "";
11
+ let text = "";
12
+ let locations = 0;
13
+ const invalid = () => {
14
+ throw new RequestError("Expected well-formed sitemap XML", null, "invalid_sitemap");
15
+ };
16
+ parser.on("error", invalid);
17
+ parser.on("doctype", invalid);
18
+ parser.on("opentag", (tag) => {
19
+ stack.push(tag);
20
+ if (stack.length === 1) {
21
+ root = tag.local;
22
+ if (tag.prefix)
23
+ throw new RequestError(
24
+ "Prefixed sitemap XML is not supported by this inventory reader",
25
+ null,
26
+ "unsupported_sitemap",
27
+ );
28
+ if (
29
+ !["urlset", "sitemapindex"].includes(root) ||
30
+ (tag.uri && tag.uri !== "http://www.sitemaps.org/schemas/sitemap/0.9")
31
+ )
32
+ invalid();
33
+ }
34
+ if (stack.length === 2) {
35
+ if (tag.local !== (root === "urlset" ? "url" : "sitemap") || tag.uri !== stack[0].uri) invalid();
36
+ locations = 0;
37
+ }
38
+ if (stack.length === 3) text = "";
39
+ if (stack.length > 3 && ["loc", "lastmod"].includes(stack[2].local) && stack[2].uri === stack[0].uri) invalid();
40
+ });
41
+ const append = (value: string) => {
42
+ if (stack.length === 3) text += value;
43
+ };
44
+ parser.on("text", append);
45
+ parser.on("cdata", append);
46
+ parser.on("closetag", (tag) => {
47
+ if (stack.length === 3 && tag.uri === stack[0].uri) {
48
+ if (tag.local === "loc") {
49
+ if (++locations !== 1 || !text.trim()) invalid();
50
+ (root === "urlset" ? urls : children).push(text.trim());
51
+ }
52
+ if (tag.local === "lastmod") lastmods.push(text.trim());
53
+ }
54
+ if (stack.length === 2 && locations !== 1) invalid();
55
+ stack.pop();
56
+ });
57
+ parser.write(xml).close();
58
+ return { urls, children, lastmods };
59
+ }
package/src/lib/auth.ts DELETED
@@ -1,187 +0,0 @@
1
- import { existsSync } from "node:fs";
2
-
3
- const SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"];
4
- const TOKEN_URL = "https://oauth2.googleapis.com/token";
5
-
6
- interface TokenResponse {
7
- access_token: string;
8
- expires_in: number;
9
- token_type: string;
10
- }
11
-
12
- let cachedToken: { token: string; expiresAt: number } | null = null;
13
-
14
- // --- Service Account Auth ---
15
-
16
- async function getServiceAccountToken(keyPath: string): Promise<string> {
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
- }
21
- const now = Math.floor(Date.now() / 1000);
22
-
23
- const header = toBase64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
24
- const payload = toBase64Url(
25
- JSON.stringify({
26
- iss: keyFile.client_email,
27
- scope: SCOPES.join(" "),
28
- aud: TOKEN_URL,
29
- iat: now,
30
- exp: now + 3600,
31
- }),
32
- );
33
-
34
- const signingInput = `${header}.${payload}`;
35
- const key = await crypto.subtle.importKey(
36
- "pkcs8",
37
- pemToBuffer(keyFile.private_key),
38
- { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
39
- false,
40
- ["sign"],
41
- );
42
- const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
43
- const jwt = `${signingInput}.${bufferToBase64Url(signature)}`;
44
-
45
- const res = await fetch(TOKEN_URL, {
46
- method: "POST",
47
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
48
- body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
49
- });
50
-
51
- if (!res.ok) {
52
- const err = await res.text();
53
- throw new Error(`Service account token exchange failed: ${err}`);
54
- }
55
-
56
- const data: TokenResponse = await res.json();
57
- return data.access_token;
58
- }
59
-
60
- function pemToBuffer(pem: string): ArrayBuffer {
61
- const b64 = pem
62
- .replace(/-----BEGIN PRIVATE KEY-----/g, "")
63
- .replace(/-----END PRIVATE KEY-----/g, "")
64
- .replace(/\s/g, "");
65
- const binary = atob(b64);
66
- const buf = new Uint8Array(binary.length);
67
- for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
68
- return buf.buffer;
69
- }
70
-
71
- function toBase64Url(input: string): string {
72
- return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
73
- }
74
-
75
- function bufferToBase64Url(buf: ArrayBuffer): string {
76
- const bytes = new Uint8Array(buf);
77
- let binary = "";
78
- for (const b of bytes) binary += String.fromCharCode(b);
79
- return toBase64Url(binary);
80
- }
81
-
82
- // --- OAuth Refresh Token Auth ---
83
-
84
- async function getOAuthToken(clientId: string, clientSecret: string, refreshToken: string): Promise<string> {
85
- const res = await fetch(TOKEN_URL, {
86
- method: "POST",
87
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
88
- body: new URLSearchParams({
89
- grant_type: "refresh_token",
90
- client_id: clientId,
91
- client_secret: clientSecret,
92
- refresh_token: refreshToken,
93
- }).toString(),
94
- });
95
-
96
- if (!res.ok) {
97
- const err = await res.text();
98
- throw new Error(`OAuth token refresh failed: ${err}`);
99
- }
100
-
101
- const data: TokenResponse = await res.json();
102
- return data.access_token;
103
- }
104
-
105
- // --- Public API ---
106
-
107
- export async function getAccessToken(): Promise<string> {
108
- // Return cached token if still valid
109
- if (cachedToken && Date.now() < cachedToken.expiresAt) {
110
- return cachedToken.token;
111
- }
112
-
113
- // Try service account first
114
- const saKeyPath = process.env.GSC_SERVICE_ACCOUNT_KEY;
115
- if (saKeyPath && existsSync(saKeyPath)) {
116
- const token = await getServiceAccountToken(saKeyPath);
117
- cachedToken = { token, expiresAt: Date.now() + 3500_000 }; // ~58 min
118
- return token;
119
- }
120
-
121
- // Try OAuth refresh token
122
- const clientId = process.env.GSC_CLIENT_ID;
123
- const clientSecret = process.env.GSC_CLIENT_SECRET;
124
- const refreshToken = process.env.GSC_REFRESH_TOKEN;
125
-
126
- if (clientId && clientSecret && refreshToken) {
127
- const token = await getOAuthToken(clientId, clientSecret, refreshToken);
128
- cachedToken = { token, expiresAt: Date.now() + 3500_000 };
129
- return token;
130
- }
131
-
132
- throw new Error(
133
- "No GSC credentials configured. Set either:\n" +
134
- " - GSC_SERVICE_ACCOUNT_KEY (path to service account JSON)\n" +
135
- " - GSC_CLIENT_ID + GSC_CLIENT_SECRET + GSC_REFRESH_TOKEN (OAuth 2.0)",
136
- );
137
- }
138
-
139
- export function clearTokenCache(): void {
140
- cachedToken = null;
141
- }
142
-
143
- export function getAuthMethod(): string {
144
- if (process.env.GSC_SERVICE_ACCOUNT_KEY) return "service_account";
145
- if (process.env.GSC_REFRESH_TOKEN) return "oauth";
146
- return "none";
147
- }
148
-
149
- // --- OAuth Setup Helper ---
150
-
151
- export function getOAuthSetupUrl(clientId: string): string {
152
- const params = new URLSearchParams({
153
- client_id: clientId,
154
- redirect_uri: "http://localhost",
155
- response_type: "code",
156
- scope: SCOPES.join(" "),
157
- access_type: "offline",
158
- prompt: "consent",
159
- });
160
- return `https://accounts.google.com/o/oauth2/auth?${params}`;
161
- }
162
-
163
- export async function exchangeCodeForToken(
164
- clientId: string,
165
- clientSecret: string,
166
- code: string,
167
- ): Promise<{ refreshToken: string; accessToken: string }> {
168
- const res = await fetch(TOKEN_URL, {
169
- method: "POST",
170
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
171
- body: new URLSearchParams({
172
- grant_type: "authorization_code",
173
- client_id: clientId,
174
- client_secret: clientSecret,
175
- code,
176
- redirect_uri: "http://localhost",
177
- }).toString(),
178
- });
179
-
180
- if (!res.ok) {
181
- const err = await res.text();
182
- throw new Error(`Code exchange failed: ${err}`);
183
- }
184
-
185
- const data = (await res.json()) as Record<string, string>;
186
- return { refreshToken: data.refresh_token, accessToken: data.access_token };
187
- }