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.
Files changed (79) hide show
  1. package/README.md +20 -82
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/measurement.md +178 -0
  5. package/docs/opportunities.md +78 -0
  6. package/docs/snapshots.md +130 -0
  7. package/docs/usage.md +135 -0
  8. package/package.json +26 -29
  9. package/src/api/assessment.ts +315 -0
  10. package/src/api/bing.ts +83 -0
  11. package/src/api/compare-snapshots.ts +167 -0
  12. package/src/api/discover.ts +39 -0
  13. package/src/api/doctor.ts +35 -0
  14. package/src/api/evidence-schema.ts +78 -0
  15. package/src/api/evidence.ts +99 -0
  16. package/src/api/execute.ts +160 -0
  17. package/src/api/ga-freshness.ts +20 -0
  18. package/src/api/ga-realtime.ts +40 -0
  19. package/src/api/index.ts +5 -0
  20. package/src/api/opportunities.ts +317 -0
  21. package/src/api/report-table.ts +216 -0
  22. package/src/api/reports.ts +108 -0
  23. package/src/api/schema.ts +271 -0
  24. package/src/api/snapshot.ts +202 -0
  25. package/src/api/ui-findings.ts +68 -0
  26. package/src/assessment-text.ts +55 -0
  27. package/src/cli.ts +217 -0
  28. package/src/http.ts +39 -0
  29. package/src/index.ts +8 -25
  30. package/src/mcp-server.ts +27 -0
  31. package/src/mcp.ts +5 -0
  32. package/src/opportunities-text.ts +61 -0
  33. package/src/providers/bing.ts +48 -0
  34. package/src/{lib → providers}/crux.ts +4 -9
  35. package/src/providers/ga.ts +114 -0
  36. package/src/providers/google-tokens.ts +86 -0
  37. package/src/providers/gsc-auth.ts +93 -0
  38. package/src/{lib → providers}/gsc.ts +27 -24
  39. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  40. package/src/shared/dates.ts +32 -0
  41. package/src/shared/http.ts +63 -0
  42. package/src/tools/ai.ts +19 -27
  43. package/src/tools/audit.ts +18 -98
  44. package/src/tools/observe.ts +28 -0
  45. package/src/tools/page/analyze.ts +194 -0
  46. package/src/tools/page/batch.ts +163 -0
  47. package/src/tools/page/contrast.ts +128 -0
  48. package/src/tools/page/links.ts +200 -0
  49. package/src/tools/page/metadata.ts +225 -0
  50. package/src/tools/page/structured-data.ts +288 -0
  51. package/src/tools/page/tool.ts +48 -0
  52. package/src/tools/search/actions.ts +56 -0
  53. package/src/tools/search/analytics.ts +266 -0
  54. package/src/tools/search/coverage.ts +247 -0
  55. package/src/tools/search/gaps.ts +129 -0
  56. package/src/tools/search/inspection.ts +160 -0
  57. package/src/tools/search/result.ts +3 -0
  58. package/src/tools/search/sample.ts +110 -0
  59. package/src/tools/search/schema.ts +62 -0
  60. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  61. package/src/tools/search/sites.ts +86 -0
  62. package/src/tools/search/tool.ts +11 -0
  63. package/src/tools/setup.ts +1 -1
  64. package/src/tools/speed/analyze.ts +191 -0
  65. package/src/tools/speed/batch.ts +265 -0
  66. package/src/tools/speed/crux.ts +176 -0
  67. package/src/tools/speed/pagespeed.ts +273 -0
  68. package/src/tools/speed/schema.ts +39 -0
  69. package/src/tools/speed/tool.ts +11 -0
  70. package/src/web/fetch.ts +31 -0
  71. package/src/web/images.ts +62 -0
  72. package/src/web/page-observation.ts +69 -0
  73. package/src/{lib → web}/robots.ts +20 -12
  74. package/src/web/sitemap-inventory.ts +64 -0
  75. package/src/web/sitemap-parser.ts +59 -0
  76. package/src/lib/auth.ts +0 -187
  77. package/src/tools/page.ts +0 -1241
  78. package/src/tools/search.ts +0 -1118
  79. 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 (await res.json()) as Record<string, unknown>;
26
- } catch {
27
- throw new Error(`GSC API returned invalid JSON (${res.status})`);
10
+ return await requestJson<Record<string, unknown>>(url, {
11
+ method: body ? "POST" : "GET",
12
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
13
+ body: body ? JSON.stringify(body) : undefined,
14
+ });
15
+ } catch (error) {
16
+ if (error instanceof RequestError && error.status === 401) clearTokenCache();
17
+ throw error;
28
18
  }
29
19
  }
30
20
 
@@ -62,6 +52,11 @@ export interface InspectionResult {
62
52
  }
63
53
 
64
54
  export async function inspectUrl(inspectionUrl: string, siteUrl: string): Promise<InspectionResult> {
55
+ const data = await inspectUrlResponse(inspectionUrl, siteUrl);
56
+ return data.inspectionResult as InspectionResult;
57
+ }
58
+
59
+ export async function inspectUrlResponse(inspectionUrl: string, siteUrl: string): Promise<Record<string, unknown>> {
65
60
  const data = await gscFetch(`${GSC_API}/urlInspection/index:inspect`, {
66
61
  inspectionUrl,
67
62
  siteUrl,
@@ -69,7 +64,7 @@ export async function inspectUrl(inspectionUrl: string, siteUrl: string): Promis
69
64
  if (!data.inspectionResult) {
70
65
  throw new Error("GSC API returned no inspection result");
71
66
  }
72
- return data.inspectionResult as InspectionResult;
67
+ return data;
73
68
  }
74
69
 
75
70
  // --- Search Analytics ---
@@ -121,7 +116,7 @@ export async function querySearchAnalytics(
121
116
  dimensions: options.dimensions ?? ["query", "page"],
122
117
  type: options.type ?? "web",
123
118
  rowLimit: options.rowLimit ?? 1000,
124
- dataState: options.dataState ?? "all",
119
+ dataState: options.dataState ?? "final",
125
120
  };
126
121
 
127
122
  if (options.startRow !== undefined) body.startRow = options.startRow;
@@ -140,10 +135,14 @@ export interface GscSite {
140
135
  }
141
136
 
142
137
  export async function listSites(): Promise<GscSite[]> {
143
- const data = await gscFetch(`${WEBMASTERS_API}/sites`);
138
+ const data = await listSitesResponse();
144
139
  return (data.siteEntry as GscSite[] | undefined) ?? [];
145
140
  }
146
141
 
142
+ export function listSitesResponse(): Promise<Record<string, unknown>> {
143
+ return gscFetch(`${WEBMASTERS_API}/sites`);
144
+ }
145
+
147
146
  export async function getSite(siteUrl: string): Promise<GscSite> {
148
147
  const data = await gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}`);
149
148
  return data as unknown as GscSite;
@@ -170,10 +169,14 @@ export interface GscSitemap {
170
169
  }
171
170
 
172
171
  export async function listSitemaps(siteUrl: string): Promise<GscSitemap[]> {
173
- const data = await gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/sitemaps`);
172
+ const data = await listSitemapsResponse(siteUrl);
174
173
  return (data.sitemap as GscSitemap[] | undefined) ?? [];
175
174
  }
176
175
 
176
+ export function listSitemapsResponse(siteUrl: string): Promise<Record<string, unknown>> {
177
+ return gscFetch(`${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/sitemaps`);
178
+ }
179
+
177
180
  export async function getSitemap(siteUrl: string, feedpath: string): Promise<GscSitemap> {
178
181
  const data = await gscFetch(
179
182
  `${WEBMASTERS_API}/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}`,
@@ -1,3 +1,5 @@
1
+ import { requestJson } from "../shared/http.js";
2
+
1
3
  const PSI_API = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
2
4
 
3
5
  export interface PsiCategory {
@@ -106,18 +108,5 @@ export async function runPagespeed(
106
108
 
107
109
  if (options?.locale) params.set("locale", options.locale);
108
110
 
109
- const res = await fetch(`${PSI_API}?${params}`, {
110
- headers: { "User-Agent": "Pagesight/0.1" },
111
- });
112
-
113
- if (!res.ok) {
114
- const err = await res.text();
115
- throw new Error(`PageSpeed API error (${res.status}): ${err}`);
116
- }
117
-
118
- try {
119
- return (await res.json()) as PsiResult;
120
- } catch {
121
- throw new Error(`PageSpeed API returned invalid JSON (${res.status})`);
122
- }
111
+ return requestJson<PsiResult>(`${PSI_API}?${params}`, { headers: { "User-Agent": "Pagesight/0.17" } }, 60_000);
123
112
  }
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+
3
+ export const dateSchema = z
4
+ .string()
5
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
6
+ .refine((value) => {
7
+ const date = new Date(value);
8
+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
9
+ }, "Use a real YYYY-MM-DD date");
10
+
11
+ export function pacificDate(now = new Date()): string {
12
+ return new Intl.DateTimeFormat("en-CA", {
13
+ timeZone: "America/Los_Angeles",
14
+ year: "numeric",
15
+ month: "2-digit",
16
+ day: "2-digit",
17
+ }).format(now);
18
+ }
19
+
20
+ export function defaultDates(now = new Date()): { startDate: string; endDate: string } {
21
+ const end = new Date(`${pacificDate(now)}T12:00:00Z`);
22
+ end.setUTCDate(end.getUTCDate() - 3);
23
+ const start = new Date(end);
24
+ start.setUTCDate(start.getUTCDate() - 27);
25
+ return { startDate: start.toISOString().slice(0, 10), endDate: end.toISOString().slice(0, 10) };
26
+ }
27
+
28
+ export function pacificDaysAgo(days: number, now = new Date()): string {
29
+ const date = new Date(`${pacificDate(now)}T12:00:00Z`);
30
+ date.setUTCDate(date.getUTCDate() - days);
31
+ return date.toISOString().slice(0, 10);
32
+ }
@@ -0,0 +1,63 @@
1
+ export class RequestError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly status: number | null = null,
5
+ public readonly code = "request_failed",
6
+ ) {
7
+ super(message);
8
+ }
9
+ }
10
+
11
+ export async function requestJson<T>(url: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<T> {
12
+ const signal = AbortSignal.timeout(timeoutMs);
13
+ let response: Response;
14
+ try {
15
+ response = await fetch(url, { ...init, signal });
16
+ } catch {
17
+ throw new RequestError("Request failed or timed out", null, "network_error");
18
+ }
19
+ if (!response.ok) {
20
+ // Provider bodies and request URLs can contain credentials; never echo them into evidence.
21
+ const code =
22
+ response.status === 401
23
+ ? "unauthenticated"
24
+ : response.status === 403
25
+ ? "forbidden"
26
+ : response.status === 429
27
+ ? "quota_exceeded"
28
+ : response.status === 404
29
+ ? "not_found"
30
+ : "provider_error";
31
+ throw new RequestError(`Provider returned HTTP ${response.status}`, response.status, code);
32
+ }
33
+ try {
34
+ return (await response.json()) as T;
35
+ } catch (error) {
36
+ if (signal.aborted || !(error instanceof SyntaxError))
37
+ throw new RequestError("Response body failed or timed out", null, "network_error");
38
+ throw new RequestError("Provider returned invalid JSON", response.status, "invalid_response");
39
+ }
40
+ }
41
+
42
+ export async function readBounded(response: Response, maxBytes: number): Promise<string> {
43
+ if (Number(response.headers.get("content-length")) > maxBytes) {
44
+ await response.body?.cancel();
45
+ throw new RequestError("Response exceeds byte limit", null, "size_limit");
46
+ }
47
+ const reader = response.body?.getReader();
48
+ if (!reader) return "";
49
+ const chunks: Uint8Array[] = [];
50
+ let size = 0;
51
+ try {
52
+ while (true) {
53
+ const { done, value } = await reader.read();
54
+ if (done) break;
55
+ size += value.byteLength;
56
+ if (size > maxBytes) throw new RequestError("Response exceeds byte limit", null, "size_limit");
57
+ chunks.push(value);
58
+ }
59
+ } finally {
60
+ await reader.cancel();
61
+ }
62
+ return Buffer.concat(chunks).toString("utf8");
63
+ }
package/src/tools/ai.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { auditAiCrawlers, type CrawlerStatus, fetchRobotsTxt, isAllowed, type RobotsTxt } from "../lib/robots.js";
4
-
5
- // --- llms.txt detection ---
3
+ import { readBounded, RequestError } from "../shared/http.js";
4
+ import { auditAiCrawlers, type CrawlerStatus, fetchRobotsTxt, isAllowed, type RobotsTxt } from "../web/robots.js";
6
5
 
7
6
  interface LlmsTxtResult {
8
7
  exists: boolean;
@@ -15,32 +14,20 @@ interface LlmsTxtResult {
15
14
 
16
15
  async function checkLlmsTxt(origin: string, path: string): Promise<LlmsTxtResult> {
17
16
  try {
18
- const controller = new AbortController();
19
- const timeout = setTimeout(() => controller.abort(), 10_000);
20
17
  const res = await fetch(`${origin}${path}`, {
21
18
  headers: { "User-Agent": "Pagesight/1.0" },
22
19
  redirect: "follow",
23
- signal: controller.signal,
20
+ signal: AbortSignal.timeout(10_000),
24
21
  });
25
- clearTimeout(timeout);
26
22
  if (!res.ok) return { exists: false, size: null, firstLine: null, lineCount: null, sections: [], linkCount: null };
27
- // Cap at 1MB to avoid OOM on large responses
28
- const contentLength = Number(res.headers.get("content-length") ?? 0);
29
- if (contentLength > 1_048_576) {
30
- return {
31
- exists: true,
32
- size: contentLength,
33
- firstLine: "(file too large to preview)",
34
- lineCount: null,
35
- sections: [],
36
- linkCount: null,
37
- };
38
- }
39
- const text = await res.text();
40
- if (text.length > 1_048_576) {
23
+ let text: string;
24
+ try {
25
+ text = await readBounded(res, 1_048_576);
26
+ } catch (error) {
27
+ if (!(error instanceof RequestError) || error.code !== "size_limit") throw error;
41
28
  return {
42
29
  exists: true,
43
- size: text.length,
30
+ size: null,
44
31
  firstLine: "(file too large to preview)",
45
32
  lineCount: null,
46
33
  sections: [],
@@ -51,7 +38,14 @@ async function checkLlmsTxt(origin: string, path: string): Promise<LlmsTxtResult
51
38
  const firstLine = textLines.find((l) => l.trim().length > 0)?.trim() ?? null;
52
39
  const sections = textLines.filter((l) => /^##\s/.test(l)).map((l) => l.replace(/^##\s+/, "").trim());
53
40
  const linkCount = (text.match(/\[.*?\]\(https?:\/\/[^)]+\)/g) ?? []).length;
54
- return { exists: true, size: text.length, firstLine, lineCount: textLines.length, sections, linkCount };
41
+ return {
42
+ exists: true,
43
+ size: new TextEncoder().encode(text).byteLength,
44
+ firstLine,
45
+ lineCount: textLines.length,
46
+ sections,
47
+ linkCount,
48
+ };
55
49
  } catch {
56
50
  return { exists: false, size: null, firstLine: null, lineCount: null, sections: [], linkCount: null };
57
51
  }
@@ -84,9 +78,7 @@ function formatLlmsTxt(llmsTxt: LlmsTxtResult, llmsFullTxt: LlmsTxtResult): stri
84
78
  return lines;
85
79
  }
86
80
 
87
- // --- Category summary ---
88
-
89
- function formatCategorySummary(crawlers: CrawlerStatus[]): string[] {
81
+ export function formatCategorySummary(crawlers: CrawlerStatus[]): string[] {
90
82
  const categoryOrder = ["Training", "Search", "Assistant", "Agent", "Other"];
91
83
  const summary = new Map<string, { blocked: number; allowed: number }>();
92
84
 
@@ -245,7 +237,7 @@ function formatRobotsAudit(origin: string, robots: RobotsTxt, statusCode: number
245
237
  export function registerAiTool(server: McpServer): void {
246
238
  server.tool(
247
239
  "ai",
248
- "Analyze your site's AI visibility. Audits AI crawler access (training, search, assistant, agent) via robots.txt, checks for llms.txt and llms-full.txt, validates syntax per RFC 9309, and tests specific path access. Shows how AI systems see your site.",
240
+ "Analyze your site's AI visibility. Audits AI crawler access (training, search, assistant, agent) via robots.txt, checks for llms.txt and llms-full.txt, reports robots.txt parse errors, and tests specific path access. Shows how AI systems see your site.",
249
241
  {
250
242
  url: z
251
243
  .string()
@@ -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 "../lib/gsc.js";
4
- import { type PsiCategoryType, type PsiResult, runPagespeed } from "../lib/psi.js";
5
- import { auditAiCrawlers, fetchRobotsTxt, isAllowed } from "../lib/robots.js";
6
- import { fetchSitemap, inspectSingle, sampleUrls } from "../lib/sitemap.js";
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
- // Exhausted redirects
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(sitemapCount: number, totalSubmitted: number, totalIndexed: number, findings: Finding[]) {
186
- if (sitemapCount === 0) {
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
- if (totalSubmitted > 0 && totalIndexed === 0) {
191
- findings.push({
192
- severity: "HIGH",
193
- message: `${totalSubmitted.toLocaleString()} sitemap URLs submitted, 0 indexed`,
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: `${totalSubmitted.toLocaleString()} sitemap URLs, only ${pct}% indexed (${totalIndexed.toLocaleString()})`,
191
+ message: `${sitemap.path}: ${sitemap.errors} sitemap errors`,
202
192
  source: "sitemaps",
203
193
  });
204
- } else if (pct < 80) {
194
+ }
195
+ if (Number(sitemap.warnings) > 0) {
205
196
  findings.push({
206
197
  severity: "MEDIUM",
207
- message: `${totalSubmitted.toLocaleString()} sitemap URLs, ${pct}% indexed (${totalIndexed.toLocaleString()})`,
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).catch(() => null) : Promise.resolve(null),
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 statusMatch = psiErr.match(/\((\d{3})\)/);
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
- const sitemaps = sitemapResult.value;
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
+ }