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 +1 -1
- package/src/index.ts +2 -0
- package/src/tools/sample-inspect.ts +275 -0
package/package.json
CHANGED
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
|
+
}
|