pagesight 0.1.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/.github/workflows/ci.yml +40 -0
- package/.releaserc.json +10 -0
- package/CLAUDE.md +52 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/biome.json +14 -0
- package/brand.md +275 -0
- package/bun.lock +1239 -0
- package/lefthook.yml +8 -0
- package/package.json +38 -0
- package/src/index.ts +26 -0
- package/src/lib/auth.ts +176 -0
- package/src/lib/crux.ts +131 -0
- package/src/lib/gsc.ts +173 -0
- package/src/lib/psi.ts +90 -0
- package/src/lib/robots.ts +279 -0
- package/src/tools/crux.ts +288 -0
- package/src/tools/inspect.ts +105 -0
- package/src/tools/pagespeed.ts +189 -0
- package/src/tools/performance.ts +167 -0
- package/src/tools/robots.ts +131 -0
- package/src/tools/setup.ts +114 -0
- package/src/tools/sitemaps.ts +112 -0
- package/tsconfig.json +11 -0
package/lefthook.yml
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pagesight",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Google's data + AI crawler intelligence for AI assistants. MCP server for SEO, GEO, and web performance.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pagesight": "src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/caiopizzol/sitelint.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/caiopizzol/sitelint",
|
|
15
|
+
"author": "Caio Pizzol",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "bun run src/index.ts",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"lint": "biome check src/",
|
|
20
|
+
"format": "biome format --write src/"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
25
|
+
"zod": "^3.24.4"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@biomejs/biome": "^2.4.10",
|
|
29
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
30
|
+
"@semantic-release/git": "^10.0.1",
|
|
31
|
+
"@semantic-release/github": "^12.0.6",
|
|
32
|
+
"@semantic-release/npm": "^13.1.5",
|
|
33
|
+
"@types/bun": "^1.3.11",
|
|
34
|
+
"lefthook": "^2.1.4",
|
|
35
|
+
"semantic-release": "^25.0.3",
|
|
36
|
+
"semantic-release-ai-notes": "^0.2.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { registerCruxTool } from "./tools/crux.js";
|
|
5
|
+
import { registerInspectTool } from "./tools/inspect.js";
|
|
6
|
+
import { registerPagespeedTool } from "./tools/pagespeed.js";
|
|
7
|
+
import { registerPerformanceTool } from "./tools/performance.js";
|
|
8
|
+
import { registerRobotsTool } from "./tools/robots.js";
|
|
9
|
+
import { registerSetupTool } from "./tools/setup.js";
|
|
10
|
+
import { registerSitemapsTool } from "./tools/sitemaps.js";
|
|
11
|
+
|
|
12
|
+
const server = new McpServer({
|
|
13
|
+
name: "pagesight",
|
|
14
|
+
version: "0.0.0",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
registerCruxTool(server);
|
|
18
|
+
registerInspectTool(server);
|
|
19
|
+
registerPagespeedTool(server);
|
|
20
|
+
registerPerformanceTool(server);
|
|
21
|
+
registerRobotsTool(server);
|
|
22
|
+
registerSitemapsTool(server);
|
|
23
|
+
registerSetupTool(server);
|
|
24
|
+
|
|
25
|
+
const transport = new StdioServerTransport();
|
|
26
|
+
await server.connect(transport);
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
const now = Math.floor(Date.now() / 1000);
|
|
19
|
+
|
|
20
|
+
const header = btoa(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
21
|
+
const payload = btoa(
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
iss: keyFile.client_email,
|
|
24
|
+
scope: SCOPES.join(" "),
|
|
25
|
+
aud: TOKEN_URL,
|
|
26
|
+
iat: now,
|
|
27
|
+
exp: now + 3600,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const signingInput = `${header}.${payload}`;
|
|
32
|
+
const key = await crypto.subtle.importKey(
|
|
33
|
+
"pkcs8",
|
|
34
|
+
pemToBuffer(keyFile.private_key),
|
|
35
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
36
|
+
false,
|
|
37
|
+
["sign"],
|
|
38
|
+
);
|
|
39
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
|
|
40
|
+
const jwt = `${signingInput}.${bufferToBase64Url(signature)}`;
|
|
41
|
+
|
|
42
|
+
const res = await fetch(TOKEN_URL, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
45
|
+
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const err = await res.text();
|
|
50
|
+
throw new Error(`Service account token exchange failed: ${err}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const data: TokenResponse = await res.json();
|
|
54
|
+
return data.access_token;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pemToBuffer(pem: string): ArrayBuffer {
|
|
58
|
+
const b64 = pem
|
|
59
|
+
.replace(/-----BEGIN PRIVATE KEY-----/g, "")
|
|
60
|
+
.replace(/-----END PRIVATE KEY-----/g, "")
|
|
61
|
+
.replace(/\s/g, "");
|
|
62
|
+
const binary = atob(b64);
|
|
63
|
+
const buf = new Uint8Array(binary.length);
|
|
64
|
+
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
|
|
65
|
+
return buf.buffer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bufferToBase64Url(buf: ArrayBuffer): string {
|
|
69
|
+
const bytes = new Uint8Array(buf);
|
|
70
|
+
let binary = "";
|
|
71
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
72
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- OAuth Refresh Token Auth ---
|
|
76
|
+
|
|
77
|
+
async function getOAuthToken(clientId: string, clientSecret: string, refreshToken: string): Promise<string> {
|
|
78
|
+
const res = await fetch(TOKEN_URL, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
81
|
+
body: new URLSearchParams({
|
|
82
|
+
grant_type: "refresh_token",
|
|
83
|
+
client_id: clientId,
|
|
84
|
+
client_secret: clientSecret,
|
|
85
|
+
refresh_token: refreshToken,
|
|
86
|
+
}).toString(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const err = await res.text();
|
|
91
|
+
throw new Error(`OAuth token refresh failed: ${err}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const data: TokenResponse = await res.json();
|
|
95
|
+
return data.access_token;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Public API ---
|
|
99
|
+
|
|
100
|
+
export async function getAccessToken(): Promise<string> {
|
|
101
|
+
// Return cached token if still valid
|
|
102
|
+
if (cachedToken && Date.now() < cachedToken.expiresAt) {
|
|
103
|
+
return cachedToken.token;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Try service account first
|
|
107
|
+
const saKeyPath = process.env.GSC_SERVICE_ACCOUNT_KEY;
|
|
108
|
+
if (saKeyPath && existsSync(saKeyPath)) {
|
|
109
|
+
const token = await getServiceAccountToken(saKeyPath);
|
|
110
|
+
cachedToken = { token, expiresAt: Date.now() + 3500_000 }; // ~58 min
|
|
111
|
+
return token;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Try OAuth refresh token
|
|
115
|
+
const clientId = process.env.GSC_CLIENT_ID;
|
|
116
|
+
const clientSecret = process.env.GSC_CLIENT_SECRET;
|
|
117
|
+
const refreshToken = process.env.GSC_REFRESH_TOKEN;
|
|
118
|
+
|
|
119
|
+
if (clientId && clientSecret && refreshToken) {
|
|
120
|
+
const token = await getOAuthToken(clientId, clientSecret, refreshToken);
|
|
121
|
+
cachedToken = { token, expiresAt: Date.now() + 3500_000 };
|
|
122
|
+
return token;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw new Error(
|
|
126
|
+
"No GSC credentials configured. Set either:\n" +
|
|
127
|
+
" - GSC_SERVICE_ACCOUNT_KEY (path to service account JSON)\n" +
|
|
128
|
+
" - GSC_CLIENT_ID + GSC_CLIENT_SECRET + GSC_REFRESH_TOKEN (OAuth 2.0)",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getAuthMethod(): string {
|
|
133
|
+
if (process.env.GSC_SERVICE_ACCOUNT_KEY) return "service_account";
|
|
134
|
+
if (process.env.GSC_REFRESH_TOKEN) return "oauth";
|
|
135
|
+
return "none";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- OAuth Setup Helper ---
|
|
139
|
+
|
|
140
|
+
export function getOAuthSetupUrl(clientId: string): string {
|
|
141
|
+
const params = new URLSearchParams({
|
|
142
|
+
client_id: clientId,
|
|
143
|
+
redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
|
|
144
|
+
response_type: "code",
|
|
145
|
+
scope: SCOPES.join(" "),
|
|
146
|
+
access_type: "offline",
|
|
147
|
+
prompt: "consent",
|
|
148
|
+
});
|
|
149
|
+
return `https://accounts.google.com/o/oauth2/auth?${params}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function exchangeCodeForToken(
|
|
153
|
+
clientId: string,
|
|
154
|
+
clientSecret: string,
|
|
155
|
+
code: string,
|
|
156
|
+
): Promise<{ refreshToken: string; accessToken: string }> {
|
|
157
|
+
const res = await fetch(TOKEN_URL, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
160
|
+
body: new URLSearchParams({
|
|
161
|
+
grant_type: "authorization_code",
|
|
162
|
+
client_id: clientId,
|
|
163
|
+
client_secret: clientSecret,
|
|
164
|
+
code,
|
|
165
|
+
redirect_uri: "urn:ietf:wg:oauth:2.0:oob",
|
|
166
|
+
}).toString(),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const err = await res.text();
|
|
171
|
+
throw new Error(`Code exchange failed: ${err}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const data = (await res.json()) as Record<string, string>;
|
|
175
|
+
return { refreshToken: data.refresh_token, accessToken: data.access_token };
|
|
176
|
+
}
|
package/src/lib/crux.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const CRUX_API = "https://chromeuxreport.googleapis.com/v1/records";
|
|
2
|
+
|
|
3
|
+
function getApiKey(): string {
|
|
4
|
+
const key = process.env.GOOGLE_API_KEY;
|
|
5
|
+
if (!key) throw new Error("GOOGLE_API_KEY is required for CrUX API.");
|
|
6
|
+
return key;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type CruxFormFactor = "DESKTOP" | "PHONE" | "TABLET";
|
|
10
|
+
|
|
11
|
+
export type CruxMetric =
|
|
12
|
+
| "cumulative_layout_shift"
|
|
13
|
+
| "first_contentful_paint"
|
|
14
|
+
| "interaction_to_next_paint"
|
|
15
|
+
| "largest_contentful_paint"
|
|
16
|
+
| "experimental_time_to_first_byte"
|
|
17
|
+
| "round_trip_time"
|
|
18
|
+
| "navigation_types"
|
|
19
|
+
| "form_factors";
|
|
20
|
+
|
|
21
|
+
export interface CruxHistogramBin {
|
|
22
|
+
start: number | string;
|
|
23
|
+
end?: number | string;
|
|
24
|
+
density: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CruxMetricData {
|
|
28
|
+
histogram?: CruxHistogramBin[];
|
|
29
|
+
percentiles?: { p75: number | string };
|
|
30
|
+
fractions?: Record<string, number>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CruxRecord {
|
|
34
|
+
key: { formFactor?: string; origin?: string; url?: string };
|
|
35
|
+
metrics: Record<string, CruxMetricData>;
|
|
36
|
+
collectionPeriod: {
|
|
37
|
+
firstDate: { year: number; month: number; day: number };
|
|
38
|
+
lastDate: { year: number; month: number; day: number };
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CruxResponse {
|
|
43
|
+
record: CruxRecord;
|
|
44
|
+
urlNormalizationDetails?: { originalUrl: string; normalizedUrl: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CruxHistoryMetricData {
|
|
48
|
+
histogramTimeseries?: Array<{
|
|
49
|
+
start: number | string;
|
|
50
|
+
end?: number | string;
|
|
51
|
+
densities: Array<number | null>;
|
|
52
|
+
}>;
|
|
53
|
+
percentilesTimeseries?: { p75s: Array<number | string | null> };
|
|
54
|
+
fractionTimeseries?: Record<string, { fractions: Array<number | null> }>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CruxHistoryRecord {
|
|
58
|
+
key: { formFactor?: string; origin?: string; url?: string };
|
|
59
|
+
metrics: Record<string, CruxHistoryMetricData>;
|
|
60
|
+
collectionPeriods: Array<{
|
|
61
|
+
firstDate: { year: number; month: number; day: number };
|
|
62
|
+
lastDate: { year: number; month: number; day: number };
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CruxHistoryResponse {
|
|
67
|
+
record: CruxHistoryRecord;
|
|
68
|
+
urlNormalizationDetails?: { originalUrl: string; normalizedUrl: string };
|
|
69
|
+
}
|
|
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> {
|
|
79
|
+
const key = getApiKey();
|
|
80
|
+
|
|
81
|
+
const body: Record<string, unknown> = {};
|
|
82
|
+
if (options.url) body.url = options.url;
|
|
83
|
+
if (options.origin) body.origin = options.origin;
|
|
84
|
+
if (options.formFactor) body.formFactor = options.formFactor;
|
|
85
|
+
if (options.metrics) body.metrics = options.metrics;
|
|
86
|
+
|
|
87
|
+
const res = await fetch(`${CRUX_API}:queryRecord?key=${key}`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const err = await res.text();
|
|
95
|
+
throw new Error(`CrUX API error (${res.status}): ${err}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return res.json() as Promise<CruxResponse>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- CrUX History API ---
|
|
102
|
+
|
|
103
|
+
export async function queryCruxHistory(options: {
|
|
104
|
+
url?: string;
|
|
105
|
+
origin?: string;
|
|
106
|
+
formFactor?: CruxFormFactor;
|
|
107
|
+
metrics?: string[];
|
|
108
|
+
collectionPeriodCount?: number;
|
|
109
|
+
}): 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>;
|
|
131
|
+
}
|
package/src/lib/gsc.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { getAccessToken } from "./auth.js";
|
|
2
|
+
|
|
3
|
+
const GSC_API = "https://searchconsole.googleapis.com/v1";
|
|
4
|
+
const WEBMASTERS_API = "https://www.googleapis.com/webmasters/v3";
|
|
5
|
+
|
|
6
|
+
async function gscFetch(url: string, body?: unknown): Promise<Record<string, unknown>> {
|
|
7
|
+
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
|
+
const err = await res.text();
|
|
19
|
+
throw new Error(`GSC API error (${res.status}): ${err}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return res.json();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- URL Inspection ---
|
|
26
|
+
|
|
27
|
+
export interface InspectionResult {
|
|
28
|
+
inspectionResultLink: string;
|
|
29
|
+
indexStatusResult: {
|
|
30
|
+
verdict: string;
|
|
31
|
+
coverageState: string;
|
|
32
|
+
robotsTxtState: string;
|
|
33
|
+
indexingState: string;
|
|
34
|
+
lastCrawlTime?: string;
|
|
35
|
+
pageFetchState: string;
|
|
36
|
+
googleCanonical?: string;
|
|
37
|
+
userCanonical?: string;
|
|
38
|
+
sitemap?: string[];
|
|
39
|
+
referringUrls?: string[];
|
|
40
|
+
crawledAs: string;
|
|
41
|
+
};
|
|
42
|
+
richResultsResult?: {
|
|
43
|
+
verdict: string;
|
|
44
|
+
detectedItems: Array<{
|
|
45
|
+
richResultType: string;
|
|
46
|
+
items: Array<{
|
|
47
|
+
name?: string;
|
|
48
|
+
issues: Array<{ issueMessage: string; severity: string }>;
|
|
49
|
+
}>;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
52
|
+
mobileUsabilityResult?: {
|
|
53
|
+
verdict: string;
|
|
54
|
+
issues?: Array<{ issueType: string; message?: string }>;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function inspectUrl(inspectionUrl: string, siteUrl: string): Promise<InspectionResult> {
|
|
59
|
+
const data = await gscFetch(`${GSC_API}/urlInspection/index:inspect`, {
|
|
60
|
+
inspectionUrl,
|
|
61
|
+
siteUrl,
|
|
62
|
+
});
|
|
63
|
+
return data.inspectionResult as InspectionResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Search Analytics ---
|
|
67
|
+
|
|
68
|
+
export interface SearchAnalyticsRow {
|
|
69
|
+
keys: string[];
|
|
70
|
+
clicks: number;
|
|
71
|
+
impressions: number;
|
|
72
|
+
ctr: number;
|
|
73
|
+
position: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SearchAnalyticsMetadata {
|
|
77
|
+
first_incomplete_date?: string;
|
|
78
|
+
first_incomplete_hour?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SearchAnalyticsResponse {
|
|
82
|
+
rows: SearchAnalyticsRow[];
|
|
83
|
+
responseAggregationType: string;
|
|
84
|
+
metadata?: SearchAnalyticsMetadata;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface SearchAnalyticsFilter {
|
|
88
|
+
dimension: string;
|
|
89
|
+
operator: string;
|
|
90
|
+
expression: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SearchAnalyticsOptions {
|
|
94
|
+
startDate: string;
|
|
95
|
+
endDate: string;
|
|
96
|
+
dimensions?: string[];
|
|
97
|
+
type?: string;
|
|
98
|
+
rowLimit?: number;
|
|
99
|
+
startRow?: number;
|
|
100
|
+
dimensionFilterGroups?: Array<{ groupType?: string; filters: SearchAnalyticsFilter[] }>;
|
|
101
|
+
dataState?: string;
|
|
102
|
+
aggregationType?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function querySearchAnalytics(
|
|
106
|
+
siteUrl: string,
|
|
107
|
+
options: SearchAnalyticsOptions,
|
|
108
|
+
): Promise<SearchAnalyticsResponse> {
|
|
109
|
+
const body: Record<string, unknown> = {
|
|
110
|
+
startDate: options.startDate,
|
|
111
|
+
endDate: options.endDate,
|
|
112
|
+
dimensions: options.dimensions ?? ["query", "page"],
|
|
113
|
+
type: options.type ?? "web",
|
|
114
|
+
rowLimit: options.rowLimit ?? 1000,
|
|
115
|
+
dataState: options.dataState ?? "all",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (options.startRow !== undefined) body.startRow = options.startRow;
|
|
119
|
+
if (options.aggregationType) body.aggregationType = options.aggregationType;
|
|
120
|
+
if (options.dimensionFilterGroups) body.dimensionFilterGroups = options.dimensionFilterGroups;
|
|
121
|
+
|
|
122
|
+
const data = await gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`, body);
|
|
123
|
+
return data as unknown as SearchAnalyticsResponse;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Sites ---
|
|
127
|
+
|
|
128
|
+
export interface GscSite {
|
|
129
|
+
siteUrl: string;
|
|
130
|
+
permissionLevel: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function listSites(): Promise<GscSite[]> {
|
|
134
|
+
const data = await gscFetch(`${WEBMASTERS_API}/sites`);
|
|
135
|
+
return (data.siteEntry as GscSite[] | undefined) ?? [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function getSite(siteUrl: string): Promise<GscSite> {
|
|
139
|
+
const data = await gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}`);
|
|
140
|
+
return data as unknown as GscSite;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- Sitemaps ---
|
|
144
|
+
|
|
145
|
+
export interface GscSitemapContent {
|
|
146
|
+
type: string;
|
|
147
|
+
submitted?: string;
|
|
148
|
+
indexed?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface GscSitemap {
|
|
152
|
+
path: string;
|
|
153
|
+
lastSubmitted?: string;
|
|
154
|
+
isPending: boolean;
|
|
155
|
+
isSitemapsIndex: boolean;
|
|
156
|
+
type?: string;
|
|
157
|
+
lastDownloaded?: string;
|
|
158
|
+
warnings?: string;
|
|
159
|
+
errors?: string;
|
|
160
|
+
contents?: GscSitemapContent[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function listSitemaps(siteUrl: string): Promise<GscSitemap[]> {
|
|
164
|
+
const data = await gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/sitemaps`);
|
|
165
|
+
return (data.sitemap as GscSitemap[] | undefined) ?? [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function getSitemap(siteUrl: string, feedpath: string): Promise<GscSitemap> {
|
|
169
|
+
const data = await gscFetch(
|
|
170
|
+
`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`,
|
|
171
|
+
);
|
|
172
|
+
return data as unknown as GscSitemap;
|
|
173
|
+
}
|
package/src/lib/psi.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const PSI_API = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
|
|
2
|
+
|
|
3
|
+
export interface PsiCategory {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
score: number | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PsiAudit {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
score: number | null;
|
|
14
|
+
scoreDisplayMode: string;
|
|
15
|
+
displayValue?: string;
|
|
16
|
+
numericValue?: number;
|
|
17
|
+
numericUnit?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PsiMetric {
|
|
21
|
+
percentile: number;
|
|
22
|
+
distributions: Array<{ min: number; max?: number; proportion: number }>;
|
|
23
|
+
category: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PsiLoadingExperience {
|
|
27
|
+
id: string;
|
|
28
|
+
metrics: Record<string, PsiMetric>;
|
|
29
|
+
overall_category: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PsiResult {
|
|
33
|
+
id: string;
|
|
34
|
+
loadingExperience?: PsiLoadingExperience;
|
|
35
|
+
originLoadingExperience?: PsiLoadingExperience;
|
|
36
|
+
lighthouseResult: {
|
|
37
|
+
requestedUrl: string;
|
|
38
|
+
finalUrl: string;
|
|
39
|
+
lighthouseVersion: string;
|
|
40
|
+
fetchTime: string;
|
|
41
|
+
audits: Record<string, PsiAudit>;
|
|
42
|
+
categories: Record<string, PsiCategory>;
|
|
43
|
+
timing: { total: number };
|
|
44
|
+
runtimeError?: { code: string; message: string };
|
|
45
|
+
runWarnings?: string[];
|
|
46
|
+
configSettings: {
|
|
47
|
+
emulatedFormFactor: string;
|
|
48
|
+
locale: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
analysisUTCTimestamp: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type PsiStrategy = "mobile" | "desktop";
|
|
55
|
+
export type PsiCategoryType = "performance" | "accessibility" | "best-practices" | "seo";
|
|
56
|
+
|
|
57
|
+
export async function runPagespeed(
|
|
58
|
+
url: string,
|
|
59
|
+
options?: {
|
|
60
|
+
strategy?: PsiStrategy;
|
|
61
|
+
categories?: PsiCategoryType[];
|
|
62
|
+
locale?: string;
|
|
63
|
+
},
|
|
64
|
+
): Promise<PsiResult> {
|
|
65
|
+
const params = new URLSearchParams({ url });
|
|
66
|
+
|
|
67
|
+
const apiKey = process.env.GOOGLE_API_KEY;
|
|
68
|
+
if (apiKey) params.set("key", apiKey);
|
|
69
|
+
|
|
70
|
+
const strategy = options?.strategy ?? "mobile";
|
|
71
|
+
params.set("strategy", strategy);
|
|
72
|
+
|
|
73
|
+
const categories = options?.categories ?? ["performance", "accessibility", "best-practices", "seo"];
|
|
74
|
+
for (const cat of categories) {
|
|
75
|
+
params.append("category", cat);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options?.locale) params.set("locale", options.locale);
|
|
79
|
+
|
|
80
|
+
const res = await fetch(`${PSI_API}?${params}`, {
|
|
81
|
+
headers: { "User-Agent": "Pagesight/0.1" },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const err = await res.text();
|
|
86
|
+
throw new Error(`PageSpeed API error (${res.status}): ${err}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return res.json() as Promise<PsiResult>;
|
|
90
|
+
}
|