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.
- package/README.md +17 -83
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/snapshots.md +123 -0
- package/docs/usage.md +130 -0
- package/package.json +26 -29
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +377 -0
- package/src/api/discover.ts +39 -0
- package/src/api/doctor.ts +35 -0
- package/src/api/evidence-schema.ts +78 -0
- package/src/api/evidence.ts +99 -0
- package/src/api/execute.ts +147 -0
- package/src/api/index.ts +5 -0
- package/src/api/reports.ts +90 -0
- package/src/api/schema.ts +207 -0
- package/src/api/snapshot.ts +183 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/cli.ts +182 -0
- package/src/http.ts +39 -0
- package/src/index.ts +8 -25
- package/src/mcp-server.ts +27 -0
- package/src/mcp.ts +5 -0
- package/src/providers/bing.ts +48 -0
- package/src/{lib → providers}/crux.ts +4 -9
- package/src/providers/ga.ts +114 -0
- package/src/providers/google-tokens.ts +86 -0
- package/src/providers/gsc-auth.ts +93 -0
- package/src/{lib → providers}/gsc.ts +27 -24
- package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
- package/src/shared/dates.ts +32 -0
- package/src/shared/http.ts +63 -0
- package/src/tools/ai.ts +19 -27
- package/src/tools/audit.ts +18 -98
- package/src/tools/observe.ts +28 -0
- package/src/tools/page/analyze.ts +194 -0
- package/src/tools/page/batch.ts +163 -0
- package/src/tools/page/contrast.ts +128 -0
- package/src/tools/page/links.ts +200 -0
- package/src/tools/page/metadata.ts +225 -0
- package/src/tools/page/structured-data.ts +288 -0
- package/src/tools/page/tool.ts +48 -0
- package/src/tools/search/actions.ts +56 -0
- package/src/tools/search/analytics.ts +266 -0
- package/src/tools/search/coverage.ts +247 -0
- package/src/tools/search/gaps.ts +129 -0
- package/src/tools/search/inspection.ts +160 -0
- package/src/tools/search/result.ts +3 -0
- package/src/tools/search/sample.ts +110 -0
- package/src/tools/search/schema.ts +62 -0
- package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
- package/src/tools/search/sites.ts +86 -0
- package/src/tools/search/tool.ts +11 -0
- package/src/tools/setup.ts +1 -1
- package/src/tools/speed/analyze.ts +191 -0
- package/src/tools/speed/batch.ts +265 -0
- package/src/tools/speed/crux.ts +176 -0
- package/src/tools/speed/pagespeed.ts +273 -0
- package/src/tools/speed/schema.ts +39 -0
- package/src/tools/speed/tool.ts +11 -0
- package/src/web/fetch.ts +31 -0
- package/src/web/images.ts +62 -0
- package/src/web/page-observation.ts +69 -0
- package/src/{lib → web}/robots.ts +20 -12
- package/src/web/sitemap-inventory.ts +64 -0
- package/src/web/sitemap-parser.ts +59 -0
- package/src/lib/auth.ts +0 -187
- package/src/tools/page.ts +0 -1241
- package/src/tools/search.ts +0 -1118
- 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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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 ?? "
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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()
|