pagesight 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { registerMetatagsTool } from "./tools/metatags.js";
7
7
  import { registerPagespeedTool } from "./tools/pagespeed.js";
8
8
  import { registerPerformanceTool } from "./tools/performance.js";
9
9
  import { registerRobotsTool } from "./tools/robots.js";
10
+ import { registerSampleInspectTool } from "./tools/sample-inspect.js";
10
11
  import { registerSetupTool } from "./tools/setup.js";
11
12
  import { registerSitemapsTool } from "./tools/sitemaps.js";
12
13
 
@@ -23,6 +24,7 @@ registerMetatagsTool(server);
23
24
  registerPagespeedTool(server);
24
25
  registerPerformanceTool(server);
25
26
  registerRobotsTool(server);
27
+ registerSampleInspectTool(server);
26
28
  registerSitemapsTool(server);
27
29
  registerSetupTool(server);
28
30
 
@@ -0,0 +1,275 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { inspectUrl, listSitemaps } from "../lib/gsc.js";
4
+
5
+ interface SitemapParseResult {
6
+ urls: string[];
7
+ isSitemapIndex: boolean;
8
+ childSitemaps: string[];
9
+ }
10
+
11
+ function parseSitemapXml(xml: string): SitemapParseResult {
12
+ const urls: string[] = [];
13
+ const childSitemaps: string[] = [];
14
+
15
+ // Check if it's a sitemap index
16
+ const isSitemapIndex = /<sitemapindex/i.test(xml);
17
+
18
+ if (isSitemapIndex) {
19
+ for (const m of xml.matchAll(/<sitemap[^>]*>[\s\S]*?<loc>\s*(.*?)\s*<\/loc>[\s\S]*?<\/sitemap>/gi)) {
20
+ childSitemaps.push(m[1].trim());
21
+ }
22
+ } else {
23
+ for (const m of xml.matchAll(/<url[^>]*>[\s\S]*?<loc>\s*(.*?)\s*<\/loc>[\s\S]*?<\/url>/gi)) {
24
+ urls.push(m[1].trim());
25
+ }
26
+ }
27
+
28
+ return { urls, isSitemapIndex, childSitemaps };
29
+ }
30
+
31
+ async function fetchSitemap(sitemapUrl: string): Promise<SitemapParseResult> {
32
+ const res = await fetch(sitemapUrl, {
33
+ headers: { "User-Agent": "Pagesight/1.0" },
34
+ });
35
+
36
+ if (!res.ok) {
37
+ throw new Error(`Failed to fetch sitemap ${sitemapUrl}: HTTP ${res.status}`);
38
+ }
39
+
40
+ const xml = await res.text();
41
+ return parseSitemapXml(xml);
42
+ }
43
+
44
+ function sampleUrls(urls: string[], count: number, strategy: string): string[] {
45
+ if (urls.length <= count) return [...urls];
46
+
47
+ if (strategy === "first") {
48
+ return urls.slice(0, count);
49
+ }
50
+
51
+ if (strategy === "spread") {
52
+ const step = Math.floor(urls.length / count);
53
+ const sampled: string[] = [];
54
+ for (let i = 0; i < count; i++) {
55
+ sampled.push(urls[i * step]);
56
+ }
57
+ return sampled;
58
+ }
59
+
60
+ // random (default)
61
+ const shuffled = [...urls];
62
+ for (let i = shuffled.length - 1; i > 0; i--) {
63
+ const j = Math.floor(Math.random() * (i + 1));
64
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
65
+ }
66
+ return shuffled.slice(0, count);
67
+ }
68
+
69
+ interface InspectionSummary {
70
+ url: string;
71
+ verdict: string;
72
+ coverageState: string;
73
+ pageFetchState: string;
74
+ robotsTxtState: string;
75
+ indexingState: string;
76
+ lastCrawlTime: string | null;
77
+ googleCanonical: string | null;
78
+ error: string | null;
79
+ }
80
+
81
+ async function inspectSingle(url: string, siteUrl: string): Promise<InspectionSummary> {
82
+ try {
83
+ const r = await inspectUrl(url, siteUrl);
84
+ const idx = r.indexStatusResult;
85
+ return {
86
+ url,
87
+ verdict: idx.verdict,
88
+ coverageState: idx.coverageState,
89
+ pageFetchState: idx.pageFetchState,
90
+ robotsTxtState: idx.robotsTxtState,
91
+ indexingState: idx.indexingState,
92
+ lastCrawlTime: idx.lastCrawlTime ?? null,
93
+ googleCanonical: idx.googleCanonical ?? null,
94
+ error: null,
95
+ };
96
+ } catch (err) {
97
+ return {
98
+ url,
99
+ verdict: "ERROR",
100
+ coverageState: "ERROR",
101
+ pageFetchState: "ERROR",
102
+ robotsTxtState: "ERROR",
103
+ indexingState: "ERROR",
104
+ lastCrawlTime: null,
105
+ googleCanonical: null,
106
+ error: err instanceof Error ? err.message : String(err),
107
+ };
108
+ }
109
+ }
110
+
111
+ function formatResults(siteUrl: string, sitemapUrl: string, totalUrls: number, results: InspectionSummary[]): string {
112
+ const lines: string[] = [
113
+ `=== Sample Inspection: ${siteUrl} ===`,
114
+ `Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
115
+ `Sampled: ${results.length}`,
116
+ "",
117
+ ];
118
+
119
+ // Summary
120
+ const verdictCounts: Record<string, number> = {};
121
+ const coverageCounts: Record<string, number> = {};
122
+ const fetchCounts: Record<string, number> = {};
123
+ let indexed = 0;
124
+ let errors = 0;
125
+
126
+ for (const r of results) {
127
+ if (r.error) {
128
+ errors++;
129
+ continue;
130
+ }
131
+ verdictCounts[r.verdict] = (verdictCounts[r.verdict] ?? 0) + 1;
132
+ coverageCounts[r.coverageState] = (coverageCounts[r.coverageState] ?? 0) + 1;
133
+ fetchCounts[r.pageFetchState] = (fetchCounts[r.pageFetchState] ?? 0) + 1;
134
+ if (r.verdict === "PASS") indexed++;
135
+ }
136
+
137
+ const inspected = results.length - errors;
138
+ lines.push("--- Summary ---", "");
139
+ lines.push(`Indexed: ${indexed}/${inspected}`);
140
+
141
+ if (indexed < inspected) {
142
+ lines.push(`Not indexed: ${inspected - indexed}/${inspected}`);
143
+ for (const [state, count] of Object.entries(coverageCounts)) {
144
+ if (state !== "Submitted and indexed" && state !== "Indexing allowed") {
145
+ lines.push(` ${state}: ${count}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ // Page fetch issues
151
+ const fetchIssues = Object.entries(fetchCounts).filter(([s]) => s !== "SUCCESSFUL");
152
+ if (fetchIssues.length > 0) {
153
+ lines.push("");
154
+ lines.push("Page fetch issues:");
155
+ for (const [state, count] of fetchIssues) {
156
+ lines.push(` ${state}: ${count}`);
157
+ }
158
+ }
159
+
160
+ if (errors > 0) {
161
+ lines.push(`\nInspection errors: ${errors}`);
162
+ }
163
+
164
+ // Individual results
165
+ lines.push("", "--- Details ---", "");
166
+ for (let i = 0; i < results.length; i++) {
167
+ const r = results[i];
168
+ lines.push(`${i + 1}. ${r.url}`);
169
+ if (r.error) {
170
+ lines.push(` Error: ${r.error}`);
171
+ } else {
172
+ lines.push(` Verdict: ${r.verdict}`);
173
+ lines.push(` Coverage: ${r.coverageState}`);
174
+ lines.push(` Page fetch: ${r.pageFetchState}`);
175
+ if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${r.robotsTxtState}`);
176
+ if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${r.indexingState}`);
177
+ if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
178
+ if (r.googleCanonical && r.googleCanonical !== r.url) {
179
+ lines.push(` Google canonical: ${r.googleCanonical}`);
180
+ }
181
+ }
182
+ lines.push("");
183
+ }
184
+
185
+ return lines.join("\n");
186
+ }
187
+
188
+ export function registerSampleInspectTool(server: McpServer): void {
189
+ server.tool(
190
+ "sample_inspect",
191
+ "Sample URLs from a sitemap and batch-inspect them via Google Search Console. Diagnoses indexing issues by revealing patterns across multiple URLs — why pages aren't indexed, common fetch errors, robots.txt blocks.",
192
+ {
193
+ site_url: z.string().describe("GSC property (e.g., 'https://example.com/' or 'sc-domain:example.com')"),
194
+ sitemap_url: z
195
+ .string()
196
+ .url()
197
+ .optional()
198
+ .describe("Sitemap URL to sample from. If omitted, discovers sitemaps from GSC."),
199
+ sample_size: z.number().min(1).max(10).optional().describe("Number of URLs to inspect. Default: 5. Max: 10."),
200
+ strategy: z
201
+ .enum(["random", "first", "spread"])
202
+ .optional()
203
+ .describe("Sampling strategy. 'random' (default), 'first' (first N), 'spread' (evenly spaced)."),
204
+ },
205
+ async ({ site_url, sitemap_url, sample_size, strategy }) => {
206
+ const count = sample_size ?? 5;
207
+ const sampleStrategy = strategy ?? "random";
208
+
209
+ try {
210
+ // Discover sitemap URL if not provided
211
+ let resolvedSitemapUrl = sitemap_url;
212
+ if (!resolvedSitemapUrl) {
213
+ const sitemaps = await listSitemaps(site_url);
214
+ if (sitemaps.length === 0) {
215
+ return {
216
+ content: [
217
+ {
218
+ type: "text",
219
+ text: `No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`,
220
+ },
221
+ ],
222
+ };
223
+ }
224
+ // Pick the first non-index sitemap, or the first one
225
+ const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
226
+ resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
227
+ }
228
+
229
+ // Fetch and parse sitemap
230
+ let parsed = await fetchSitemap(resolvedSitemapUrl);
231
+
232
+ // If it's a sitemap index, fetch the first child
233
+ if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
234
+ const childUrl = parsed.childSitemaps[0];
235
+ parsed = await fetchSitemap(childUrl);
236
+ resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
237
+ }
238
+
239
+ if (parsed.urls.length === 0) {
240
+ return {
241
+ content: [
242
+ {
243
+ type: "text",
244
+ text: `Sitemap ${resolvedSitemapUrl} contains no URLs.`,
245
+ },
246
+ ],
247
+ };
248
+ }
249
+
250
+ // Sample URLs
251
+ const sampled = sampleUrls(parsed.urls, count, sampleStrategy);
252
+
253
+ // Inspect each URL sequentially (API rate limits)
254
+ const results: InspectionSummary[] = [];
255
+ for (const url of sampled) {
256
+ results.push(await inspectSingle(url, site_url));
257
+ }
258
+
259
+ return {
260
+ content: [
261
+ {
262
+ type: "text",
263
+ text: formatResults(site_url, resolvedSitemapUrl, parsed.urls.length, results),
264
+ },
265
+ ],
266
+ };
267
+ } catch (err) {
268
+ const msg = err instanceof Error ? err.message : String(err);
269
+ return {
270
+ content: [{ type: "text", text: `Error: ${msg}` }],
271
+ };
272
+ }
273
+ },
274
+ );
275
+ }