pagesight 0.17.0 → 0.19.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 +20 -82
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/opportunities.md +78 -0
- package/docs/snapshots.md +130 -0
- package/docs/usage.md +135 -0
- package/package.json +26 -29
- package/src/api/assessment.ts +315 -0
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +167 -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 +160 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/index.ts +5 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +108 -0
- package/src/api/schema.ts +271 -0
- package/src/api/snapshot.ts +202 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +217 -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/opportunities-text.ts +61 -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,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()
|
package/src/tools/audit.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { inspectUrl, listSitemaps } from "../
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { type GscSitemap, inspectUrl, listSitemaps } from "../providers/gsc.js";
|
|
4
|
+
import { RequestError } from "../shared/http.js";
|
|
5
|
+
import { type PsiCategoryType, type PsiResult, runPagespeed } from "../providers/pagespeed.js";
|
|
6
|
+
import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../web/robots.js";
|
|
7
7
|
|
|
8
8
|
type Severity = "HIGH" | "MEDIUM" | "LOW";
|
|
9
9
|
|
|
@@ -13,8 +13,6 @@ interface Finding {
|
|
|
13
13
|
source: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// --- Metatags (inline, no external dep needed) ---
|
|
17
|
-
|
|
18
16
|
interface MetaCheckResult {
|
|
19
17
|
title: string | null;
|
|
20
18
|
description: string | null;
|
|
@@ -66,7 +64,7 @@ async function checkMeta(url: string): Promise<MetaCheckResult> {
|
|
|
66
64
|
};
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
//
|
|
67
|
+
// The hop limit or a redirect without Location prevented a page response.
|
|
70
68
|
return {
|
|
71
69
|
title: null,
|
|
72
70
|
description: null,
|
|
@@ -80,8 +78,6 @@ async function checkMeta(url: string): Promise<MetaCheckResult> {
|
|
|
80
78
|
};
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
// --- Audit logic ---
|
|
84
|
-
|
|
85
81
|
function addMetaFindings(meta: MetaCheckResult, findings: Finding[]) {
|
|
86
82
|
if (!meta.title) findings.push({ severity: "HIGH", message: "Missing <title> tag", source: "metatags" });
|
|
87
83
|
if (!meta.description) findings.push({ severity: "HIGH", message: "Missing meta description", source: "metatags" });
|
|
@@ -182,70 +178,30 @@ function addRobotsFindings(
|
|
|
182
178
|
}
|
|
183
179
|
}
|
|
184
180
|
|
|
185
|
-
function addSitemapFindings(
|
|
186
|
-
if (
|
|
181
|
+
export function addSitemapFindings(sitemaps: GscSitemap[], findings: Finding[]) {
|
|
182
|
+
if (sitemaps.length === 0) {
|
|
187
183
|
findings.push({ severity: "MEDIUM", message: "No sitemaps submitted to GSC", source: "sitemaps" });
|
|
188
|
-
return;
|
|
189
184
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
source: "sitemaps",
|
|
195
|
-
});
|
|
196
|
-
} else if (totalSubmitted > 0) {
|
|
197
|
-
const pct = Math.round((totalIndexed / totalSubmitted) * 100);
|
|
198
|
-
if (pct < 50) {
|
|
185
|
+
// contents[].indexed is deprecated; sitemap submission is not indexing evidence.
|
|
186
|
+
// https://developers.google.com/webmaster-tools/v1/sitemaps
|
|
187
|
+
for (const sitemap of sitemaps) {
|
|
188
|
+
if (Number(sitemap.errors) > 0) {
|
|
199
189
|
findings.push({
|
|
200
190
|
severity: "HIGH",
|
|
201
|
-
message: `${
|
|
191
|
+
message: `${sitemap.path}: ${sitemap.errors} sitemap errors`,
|
|
202
192
|
source: "sitemaps",
|
|
203
193
|
});
|
|
204
|
-
}
|
|
194
|
+
}
|
|
195
|
+
if (Number(sitemap.warnings) > 0) {
|
|
205
196
|
findings.push({
|
|
206
197
|
severity: "MEDIUM",
|
|
207
|
-
message: `${
|
|
198
|
+
message: `${sitemap.path}: ${sitemap.warnings} sitemap warnings`,
|
|
208
199
|
source: "sitemaps",
|
|
209
200
|
});
|
|
210
201
|
}
|
|
211
202
|
}
|
|
212
203
|
}
|
|
213
204
|
|
|
214
|
-
function formatDrillDown(
|
|
215
|
-
inspections: Array<{ url: string; verdict: string; coverageState: string; error: string | null }>,
|
|
216
|
-
): string {
|
|
217
|
-
const valid = inspections.filter((r) => !r.error);
|
|
218
|
-
if (valid.length === 0) return "";
|
|
219
|
-
|
|
220
|
-
const indexed = valid.filter((r) => r.verdict === "PASS").length;
|
|
221
|
-
const lines: string[] = [` Auto-inspected ${valid.length} URLs:`];
|
|
222
|
-
lines.push(` - ${indexed}/${valid.length} indexed`);
|
|
223
|
-
|
|
224
|
-
const stateCounts: Record<string, { count: number; urls: string[] }> = {};
|
|
225
|
-
for (const r of valid) {
|
|
226
|
-
if (r.verdict !== "PASS") {
|
|
227
|
-
let path: string;
|
|
228
|
-
try {
|
|
229
|
-
path = new URL(r.url).pathname;
|
|
230
|
-
} catch {
|
|
231
|
-
path = r.url;
|
|
232
|
-
}
|
|
233
|
-
const existing = stateCounts[r.coverageState];
|
|
234
|
-
if (existing) {
|
|
235
|
-
existing.count++;
|
|
236
|
-
existing.urls.push(path);
|
|
237
|
-
} else {
|
|
238
|
-
stateCounts[r.coverageState] = { count: 1, urls: [path] };
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
for (const [state, { count, urls }] of Object.entries(stateCounts)) {
|
|
243
|
-
lines.push(` - ${count}/${valid.length} ${state}: ${urls.join(", ")}`);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return lines.join("\n");
|
|
247
|
-
}
|
|
248
|
-
|
|
249
205
|
function addInspectFindings(verdict: string, coverageState: string, findings: Finding[]) {
|
|
250
206
|
if (verdict === "FAIL") {
|
|
251
207
|
findings.push({ severity: "HIGH", message: `URL not indexed: ${coverageState}`, source: "inspect" });
|
|
@@ -266,7 +222,6 @@ function formatAudit(url: string, findings: Finding[], errors: string[]): string
|
|
|
266
222
|
lines.push("");
|
|
267
223
|
}
|
|
268
224
|
|
|
269
|
-
// Sort: HIGH first, then MEDIUM, then LOW
|
|
270
225
|
const order: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
|
|
271
226
|
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
272
227
|
|
|
@@ -299,7 +254,6 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
299
254
|
const errors: string[] = [];
|
|
300
255
|
const origin = new URL(url).origin;
|
|
301
256
|
|
|
302
|
-
// Run independent checks in parallel
|
|
303
257
|
const [metaResult, pagespeedResult, robotsResult, sitemapResult, inspectResult] = await Promise.allSettled([
|
|
304
258
|
checkMeta(url),
|
|
305
259
|
runPagespeed(url, {
|
|
@@ -312,7 +266,7 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
312
266
|
return { robotsTxt, crawlers, googlebotAllowed };
|
|
313
267
|
}),
|
|
314
268
|
site_url ? listSitemaps(site_url) : Promise.resolve(null),
|
|
315
|
-
site_url ? inspectUrl(url, site_url)
|
|
269
|
+
site_url ? inspectUrl(url, site_url) : Promise.resolve(null),
|
|
316
270
|
]);
|
|
317
271
|
|
|
318
272
|
// Process meta
|
|
@@ -327,8 +281,7 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
327
281
|
addPagespeedFindings(pagespeedResult.value, findings);
|
|
328
282
|
} else {
|
|
329
283
|
const psiErr = String(pagespeedResult.reason);
|
|
330
|
-
const
|
|
331
|
-
const status = statusMatch ? Number(statusMatch[1]) : 0;
|
|
284
|
+
const status = pagespeedResult.reason instanceof RequestError ? pagespeedResult.reason.status : null;
|
|
332
285
|
if (status === 403) {
|
|
333
286
|
errors.push("PageSpeed: SKIPPED (API key not authorized — enable PageSpeed Insights API in Google Cloud)");
|
|
334
287
|
} else if (status === 429) {
|
|
@@ -349,40 +302,7 @@ export function registerAuditTool(server: McpServer): void {
|
|
|
349
302
|
|
|
350
303
|
// Process sitemaps
|
|
351
304
|
if (sitemapResult.status === "fulfilled" && sitemapResult.value) {
|
|
352
|
-
|
|
353
|
-
let totalSubmitted = 0;
|
|
354
|
-
let totalIndexed = 0;
|
|
355
|
-
for (const sm of sitemaps) {
|
|
356
|
-
for (const c of sm.contents ?? []) {
|
|
357
|
-
totalSubmitted += Number(c.submitted ?? 0);
|
|
358
|
-
totalIndexed += Number(c.indexed ?? 0);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
addSitemapFindings(sitemaps.length, totalSubmitted, totalIndexed, findings);
|
|
362
|
-
|
|
363
|
-
// Auto-drill-down: when indexing is low, sample-inspect to explain why
|
|
364
|
-
const indexPct = totalSubmitted > 0 ? (totalIndexed / totalSubmitted) * 100 : 100;
|
|
365
|
-
if (site_url && indexPct < 50 && sitemaps.length > 0) {
|
|
366
|
-
try {
|
|
367
|
-
const sitemapPath = (sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0]).path;
|
|
368
|
-
let parsed = await fetchSitemap(sitemapPath);
|
|
369
|
-
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
370
|
-
parsed = await fetchSitemap(parsed.childSitemaps[0]);
|
|
371
|
-
}
|
|
372
|
-
if (parsed.urls.length > 0) {
|
|
373
|
-
const sampled = sampleUrls(parsed.urls, 5, "spread");
|
|
374
|
-
const inspections = await Promise.all(sampled.map((u) => inspectSingle(u, site_url)));
|
|
375
|
-
const drillDown = formatDrillDown(inspections);
|
|
376
|
-
// Append drill-down to the sitemap finding
|
|
377
|
-
const sitemapFinding = findings.find((f) => f.source === "sitemaps" && f.severity === "HIGH");
|
|
378
|
-
if (sitemapFinding) {
|
|
379
|
-
sitemapFinding.message += `\n${drillDown}`;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
} catch {
|
|
383
|
-
// Drill-down is best-effort — don't fail the audit
|
|
384
|
-
}
|
|
385
|
-
}
|
|
305
|
+
addSitemapFindings(sitemapResult.value, findings);
|
|
386
306
|
} else if (sitemapResult.status === "rejected") {
|
|
387
307
|
errors.push(`Sitemaps: ${sitemapResult.reason}`);
|
|
388
308
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { execute, operationSchema } from "../api/index.js";
|
|
3
|
+
import { RequestError } from "../shared/http.js";
|
|
4
|
+
|
|
5
|
+
export function registerObserveTool(server: McpServer): void {
|
|
6
|
+
server.tool(
|
|
7
|
+
"observe",
|
|
8
|
+
"Run the shared Pagesight API. Read-only operations: discover; bing.sites/queries/pages/traffic/crawl-stats/crawl-issues/url-info/link-counts/url-links; evidence.import (unverified UI findings); gsc.sites/sitemaps/inspect/report; ga.accounts/property/key-events/report/realtime; page (metadata and image ALT evidence); speed.psi/crux/history; doctor; snapshot; assess (saved evidence summary); compare. Pass the API operation object as request. Returns structured evidence, exact effective requests, provider responses and partial errors. See the CLI --help and README for report/config shapes.",
|
|
9
|
+
{ request: operationSchema },
|
|
10
|
+
async ({ request }) => {
|
|
11
|
+
try {
|
|
12
|
+
const result = await execute(request);
|
|
13
|
+
return {
|
|
14
|
+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
15
|
+
structuredContent: { ...result },
|
|
16
|
+
isError: result.status === "error",
|
|
17
|
+
};
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{ type: "text" as const, text: error instanceof RequestError ? error.message : "Invalid operation" },
|
|
22
|
+
],
|
|
23
|
+
isError: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
}
|