pagesight 0.12.1 → 0.13.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.
@@ -1,325 +0,0 @@
1
- import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { z } from "zod";
3
- import { querySearchAnalytics, type SearchAnalyticsFilter, type SearchAnalyticsResponse } from "../lib/gsc.js";
4
-
5
- interface Totals {
6
- clicks: number;
7
- impressions: number;
8
- ctr: number;
9
- position: number;
10
- }
11
-
12
- function computeTotals(rows: SearchAnalyticsResponse["rows"]): Totals {
13
- const r = rows ?? [];
14
- const clicks = r.reduce((sum, row) => sum + row.clicks, 0);
15
- const impressions = r.reduce((sum, row) => sum + row.impressions, 0);
16
- const ctr = impressions > 0 ? clicks / impressions : 0;
17
- const position = impressions > 0 ? r.reduce((sum, row) => sum + row.position * row.impressions, 0) / impressions : 0;
18
- return { clicks, impressions, ctr, position };
19
- }
20
-
21
- function pctChange(current: number, previous: number): string {
22
- if (previous === 0) return current > 0 ? "+∞" : "0%";
23
- const change = ((current - previous) / previous) * 100;
24
- return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
25
- }
26
-
27
- function formatPerformance(
28
- siteUrl: string,
29
- result: SearchAnalyticsResponse,
30
- dimensions: string[],
31
- startDate: string,
32
- endDate: string,
33
- ): string {
34
- const rows = result.rows ?? [];
35
- const lines: string[] = [
36
- `=== Search Performance: ${siteUrl} ===`,
37
- `Period: ${startDate} to ${endDate}`,
38
- `Dimensions: ${dimensions.join(", ")}`,
39
- `Aggregation: ${result.responseAggregationType ?? "auto"}`,
40
- `Results: ${rows.length}`,
41
- "",
42
- ];
43
-
44
- if (result.metadata) {
45
- if (result.metadata.first_incomplete_date)
46
- lines.push(`Data incomplete from: ${result.metadata.first_incomplete_date}`);
47
- if (result.metadata.first_incomplete_hour)
48
- lines.push(`Hourly data incomplete from: ${result.metadata.first_incomplete_hour}`);
49
- lines.push("");
50
- }
51
-
52
- if (rows.length === 0) {
53
- lines.push("No data found for this period and filters.");
54
- return lines.join("\n");
55
- }
56
-
57
- const totals = computeTotals(rows);
58
-
59
- lines.push(
60
- `--- Summary (${rows.length} rows returned) ---`,
61
- "",
62
- `Clicks: ${totals.clicks.toLocaleString()}`,
63
- `Impressions: ${totals.impressions.toLocaleString()}`,
64
- `Avg CTR: ${(totals.ctr * 100).toFixed(1)}%`,
65
- `Avg Position: ${totals.position.toFixed(1)}`,
66
- "",
67
- "--- Top Results ---",
68
- "",
69
- );
70
-
71
- const top = rows.slice(0, 25);
72
- for (const row of top) {
73
- const keys = row.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
74
- lines.push(`${keys}`);
75
- lines.push(
76
- ` Clicks: ${row.clicks} | Impressions: ${row.impressions} | CTR: ${(row.ctr * 100).toFixed(1)}% | Position: ${row.position.toFixed(1)}`,
77
- );
78
- }
79
-
80
- if (rows.length > 25) {
81
- lines.push("", `... and ${rows.length - 25} more rows`);
82
- }
83
-
84
- return lines.join("\n");
85
- }
86
-
87
- function formatComparison(
88
- siteUrl: string,
89
- current: SearchAnalyticsResponse,
90
- previous: SearchAnalyticsResponse,
91
- dimensions: string[],
92
- currentStart: string,
93
- currentEnd: string,
94
- previousStart: string,
95
- previousEnd: string,
96
- ): string {
97
- const curRows = current.rows ?? [];
98
- const prevRows = previous.rows ?? [];
99
- const cur = computeTotals(curRows);
100
- const prev = computeTotals(prevRows);
101
-
102
- const lines: string[] = [
103
- `=== Search Performance: ${siteUrl} ===`,
104
- `Current: ${currentStart} to ${currentEnd}`,
105
- `Previous: ${previousStart} to ${previousEnd}`,
106
- `Dimensions: ${dimensions.join(", ")}`,
107
- "",
108
- "--- Summary ---",
109
- "",
110
- ` Current Previous Change`,
111
- `Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
112
- `Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
113
- `Avg CTR: ${`${(cur.ctr * 100).toFixed(1)}%`.padEnd(12)} ${`${(prev.ctr * 100).toFixed(1)}%`.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`,
114
- `Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${prev.position === 0 && cur.position > 0 ? "new" : cur.position < prev.position ? "improved" : cur.position > prev.position ? "regressed" : "stable"} (${(cur.position - prev.position).toFixed(1)})`,
115
- "",
116
- ];
117
-
118
- // Build lookup for previous period rows
119
- const prevMap = new Map<string, (typeof prevRows)[0]>();
120
- for (const row of prevRows) {
121
- prevMap.set(row.keys.join("|"), row);
122
- }
123
-
124
- // Find biggest movers (by click change)
125
- const movers: Array<{ keys: string[]; curClicks: number; prevClicks: number; curPos: number; prevPos: number }> = [];
126
- for (const row of curRows) {
127
- const key = row.keys.join("|");
128
- const prevRow = prevMap.get(key);
129
- movers.push({
130
- keys: row.keys,
131
- curClicks: row.clicks,
132
- prevClicks: prevRow?.clicks ?? 0,
133
- curPos: row.position,
134
- prevPos: prevRow?.position ?? 0,
135
- });
136
- }
137
-
138
- // Also include rows that disappeared (were in previous but not in current)
139
- const curKeys = new Set(curRows.map((r) => r.keys.join("|")));
140
- for (const row of prevRows) {
141
- const key = row.keys.join("|");
142
- if (!curKeys.has(key)) {
143
- movers.push({
144
- keys: row.keys,
145
- curClicks: 0,
146
- prevClicks: row.clicks,
147
- curPos: 0,
148
- prevPos: row.position,
149
- });
150
- }
151
- }
152
-
153
- // Sort by absolute click change descending
154
- movers.sort((a, b) => Math.abs(b.curClicks - b.prevClicks) - Math.abs(a.curClicks - a.prevClicks));
155
-
156
- const improved = movers.filter((m) => m.curClicks > m.prevClicks).slice(0, 10);
157
- const regressed = movers.filter((m) => m.curClicks < m.prevClicks).slice(0, 10);
158
-
159
- if (improved.length > 0) {
160
- lines.push("--- Improved ---", "");
161
- for (const m of improved) {
162
- const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
163
- const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
164
- lines.push(`${keys}`);
165
- lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
166
- }
167
- lines.push("");
168
- }
169
-
170
- if (regressed.length > 0) {
171
- lines.push("--- Regressed ---", "");
172
- for (const m of regressed) {
173
- const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
174
- const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
175
- lines.push(`${keys}`);
176
- lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
177
- }
178
- lines.push("");
179
- }
180
-
181
- return lines.join("\n");
182
- }
183
-
184
- function daysAgo(n: number): string {
185
- // GSC dates are in PT (Pacific Time). Use UTC-8 as a stable approximation.
186
- const now = new Date(Date.now() - 8 * 60 * 60 * 1000);
187
- now.setDate(now.getDate() - n);
188
- return now.toISOString().split("T")[0];
189
- }
190
-
191
- export function registerPerformanceTool(server: McpServer): void {
192
- server.tool(
193
- "performance",
194
- "Query Google Search Console search analytics. Returns clicks, impressions, CTR, and position data. Supports all dimensions, search types, filter operators, aggregation modes, and pagination.",
195
- {
196
- site_url: z.string().describe("The GSC property (e.g., 'https://example.com/' or 'sc-domain:example.com')"),
197
- start_date: z.string().optional().describe("Start date (YYYY-MM-DD). Default: 28 days ago."),
198
- end_date: z.string().optional().describe("End date (YYYY-MM-DD). Default: 3 days ago."),
199
- dimensions: z
200
- .array(z.enum(["query", "page", "country", "device", "date", "searchAppearance", "hour"]))
201
- .optional()
202
- .describe(
203
- "Dimensions to group by. Default: ['query', 'page']. Use 'hour' for hourly data (requires dataState='hourly_all').",
204
- ),
205
- search_type: z
206
- .enum(["web", "image", "video", "news", "discover", "googleNews"])
207
- .optional()
208
- .describe("Filter by search type. Default: 'web'."),
209
- data_state: z
210
- .enum(["all", "final", "hourly_all"])
211
- .optional()
212
- .describe(
213
- "Data freshness. 'all' includes fresh data, 'final' only finalized, 'hourly_all' for hourly granularity. Default: 'all'.",
214
- ),
215
- aggregation_type: z
216
- .enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"])
217
- .optional()
218
- .describe("How to aggregate results. Default: 'auto'."),
219
- filters: z
220
- .array(
221
- z.object({
222
- dimension: z
223
- .enum(["query", "page", "country", "device", "searchAppearance"])
224
- .describe("Dimension to filter on."),
225
- operator: z
226
- .enum(["equals", "contains", "notEquals", "notContains", "includingRegex", "excludingRegex"])
227
- .describe("Filter operator."),
228
- expression: z
229
- .string()
230
- .describe(
231
- "Filter value. For country use ISO 3166-1 alpha-3 (e.g., 'FRA'). For device: 'DESKTOP', 'MOBILE', 'TABLET'.",
232
- ),
233
- }),
234
- )
235
- .optional()
236
- .describe("Dimension filters. Combined with AND logic."),
237
- row_limit: z.number().optional().describe("Max rows (1-25000). Default: 1000."),
238
- start_row: z.number().optional().describe("Zero-based offset for pagination. Default: 0."),
239
- compare: z
240
- .boolean()
241
- .optional()
242
- .describe(
243
- "Compare against the previous period of equal length. Shows click/impression/CTR/position deltas and top movers.",
244
- ),
245
- },
246
- async ({
247
- site_url,
248
- start_date,
249
- end_date,
250
- dimensions,
251
- search_type,
252
- data_state,
253
- aggregation_type,
254
- filters,
255
- row_limit,
256
- start_row,
257
- compare,
258
- }) => {
259
- const startDate = start_date ?? daysAgo(28);
260
- const endDate = end_date ?? daysAgo(3);
261
- const dims = dimensions ?? ["query", "page"];
262
-
263
- const filterGroups =
264
- filters && filters.length > 0
265
- ? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
266
- : undefined;
267
-
268
- const queryOpts = {
269
- startDate,
270
- endDate,
271
- dimensions: dims,
272
- type: search_type,
273
- dataState: data_state,
274
- aggregationType: aggregation_type,
275
- rowLimit: row_limit ?? 1000,
276
- startRow: start_row,
277
- dimensionFilterGroups: filterGroups,
278
- };
279
-
280
- try {
281
- if (compare) {
282
- // Calculate previous period of equal length
283
- const start = new Date(startDate);
284
- const end = new Date(endDate);
285
- const durationMs = end.getTime() - start.getTime();
286
- const prevEnd = new Date(start.getTime() - 1 * 24 * 60 * 60 * 1000); // day before current start
287
- const prevStart = new Date(prevEnd.getTime() - durationMs);
288
- const prevStartDate = prevStart.toISOString().split("T")[0];
289
- const prevEndDate = prevEnd.toISOString().split("T")[0];
290
-
291
- const [current, previous] = await Promise.all([
292
- querySearchAnalytics(site_url, queryOpts),
293
- querySearchAnalytics(site_url, { ...queryOpts, startDate: prevStartDate, endDate: prevEndDate }),
294
- ]);
295
-
296
- return {
297
- content: [
298
- {
299
- type: "text",
300
- text: formatComparison(
301
- site_url,
302
- current,
303
- previous,
304
- dims,
305
- startDate,
306
- endDate,
307
- prevStartDate,
308
- prevEndDate,
309
- ),
310
- },
311
- ],
312
- };
313
- }
314
-
315
- const result = await querySearchAnalytics(site_url, queryOpts);
316
- return {
317
- content: [{ type: "text", text: formatPerformance(site_url, result, dims, startDate, endDate) }],
318
- };
319
- } catch (err) {
320
- const msg = err instanceof Error ? err.message : String(err);
321
- return { content: [{ type: "text", text: `Error querying search analytics: ${msg}` }] };
322
- }
323
- },
324
- );
325
- }
@@ -1,284 +0,0 @@
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
- export interface SitemapParseResult {
6
- urls: string[];
7
- isSitemapIndex: boolean;
8
- childSitemaps: string[];
9
- }
10
-
11
- export 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
- export 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
- export 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
- export 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
- export 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 humanizeState(state: string): string {
112
- const map: Record<string, string> = {
113
- PAGE_FETCH_STATE_UNSPECIFIED: "not yet crawled",
114
- ROBOTS_TXT_STATE_UNSPECIFIED: "not yet checked",
115
- INDEXING_STATE_UNSPECIFIED: "not yet determined",
116
- };
117
- return map[state] ?? state;
118
- }
119
-
120
- function formatResults(siteUrl: string, sitemapUrl: string, totalUrls: number, results: InspectionSummary[]): string {
121
- const lines: string[] = [
122
- `=== Sample Inspection: ${siteUrl} ===`,
123
- `Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
124
- `Sampled: ${results.length}`,
125
- "",
126
- ];
127
-
128
- // Summary
129
- const verdictCounts: Record<string, number> = {};
130
- const coverageCounts: Record<string, number> = {};
131
- const fetchCounts: Record<string, number> = {};
132
- let indexed = 0;
133
- let errors = 0;
134
-
135
- for (const r of results) {
136
- if (r.error) {
137
- errors++;
138
- continue;
139
- }
140
- verdictCounts[r.verdict] = (verdictCounts[r.verdict] ?? 0) + 1;
141
- coverageCounts[r.coverageState] = (coverageCounts[r.coverageState] ?? 0) + 1;
142
- fetchCounts[r.pageFetchState] = (fetchCounts[r.pageFetchState] ?? 0) + 1;
143
- if (r.verdict === "PASS") indexed++;
144
- }
145
-
146
- const inspected = results.length - errors;
147
- lines.push("--- Summary ---", "");
148
- lines.push(`Indexed: ${indexed}/${inspected}`);
149
-
150
- if (indexed < inspected) {
151
- lines.push(`Not indexed: ${inspected - indexed}/${inspected}`);
152
- for (const [state, count] of Object.entries(coverageCounts)) {
153
- if (state !== "Submitted and indexed" && state !== "Indexing allowed") {
154
- lines.push(` ${state}: ${count}`);
155
- }
156
- }
157
- }
158
-
159
- // Page fetch issues
160
- const fetchIssues = Object.entries(fetchCounts).filter(([s]) => s !== "SUCCESSFUL");
161
- if (fetchIssues.length > 0) {
162
- lines.push("");
163
- lines.push("Page fetch issues:");
164
- for (const [state, count] of fetchIssues) {
165
- lines.push(` ${humanizeState(state)}: ${count}`);
166
- }
167
- }
168
-
169
- if (errors > 0) {
170
- lines.push(`\nInspection errors: ${errors}`);
171
- }
172
-
173
- // Individual results
174
- lines.push("", "--- Details ---", "");
175
- for (let i = 0; i < results.length; i++) {
176
- const r = results[i];
177
- lines.push(`${i + 1}. ${r.url}`);
178
- if (r.error) {
179
- lines.push(` Error: ${r.error}`);
180
- } else {
181
- lines.push(` Verdict: ${r.verdict}`);
182
- lines.push(` Coverage: ${r.coverageState}`);
183
- lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
184
- if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
185
- if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
186
- if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
187
- if (r.googleCanonical && r.googleCanonical !== r.url) {
188
- lines.push(` Google canonical: ${r.googleCanonical}`);
189
- }
190
- }
191
- lines.push("");
192
- }
193
-
194
- return lines.join("\n");
195
- }
196
-
197
- export function registerSampleInspectTool(server: McpServer): void {
198
- server.tool(
199
- "sample_inspect",
200
- "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.",
201
- {
202
- site_url: z.string().describe("GSC property (e.g., 'https://example.com/' or 'sc-domain:example.com')"),
203
- sitemap_url: z
204
- .string()
205
- .url()
206
- .optional()
207
- .describe("Sitemap URL to sample from. If omitted, discovers sitemaps from GSC."),
208
- sample_size: z.number().min(1).max(10).optional().describe("Number of URLs to inspect. Default: 5. Max: 10."),
209
- strategy: z
210
- .enum(["random", "first", "spread"])
211
- .optional()
212
- .describe("Sampling strategy. 'random' (default), 'first' (first N), 'spread' (evenly spaced)."),
213
- },
214
- async ({ site_url, sitemap_url, sample_size, strategy }) => {
215
- const count = sample_size ?? 5;
216
- const sampleStrategy = strategy ?? "random";
217
-
218
- try {
219
- // Discover sitemap URL if not provided
220
- let resolvedSitemapUrl = sitemap_url;
221
- if (!resolvedSitemapUrl) {
222
- const sitemaps = await listSitemaps(site_url);
223
- if (sitemaps.length === 0) {
224
- return {
225
- content: [
226
- {
227
- type: "text",
228
- text: `No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`,
229
- },
230
- ],
231
- };
232
- }
233
- // Pick the first non-index sitemap, or the first one
234
- const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
235
- resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
236
- }
237
-
238
- // Fetch and parse sitemap
239
- let parsed = await fetchSitemap(resolvedSitemapUrl);
240
-
241
- // If it's a sitemap index, fetch the first child
242
- if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
243
- const childUrl = parsed.childSitemaps[0];
244
- parsed = await fetchSitemap(childUrl);
245
- resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
246
- }
247
-
248
- if (parsed.urls.length === 0) {
249
- return {
250
- content: [
251
- {
252
- type: "text",
253
- text: `Sitemap ${resolvedSitemapUrl} contains no URLs.`,
254
- },
255
- ],
256
- };
257
- }
258
-
259
- // Sample URLs
260
- const sampled = sampleUrls(parsed.urls, count, sampleStrategy);
261
-
262
- // Inspect each URL sequentially (API rate limits)
263
- const results: InspectionSummary[] = [];
264
- for (const url of sampled) {
265
- results.push(await inspectSingle(url, site_url));
266
- }
267
-
268
- return {
269
- content: [
270
- {
271
- type: "text",
272
- text: formatResults(site_url, resolvedSitemapUrl, parsed.urls.length, results),
273
- },
274
- ],
275
- };
276
- } catch (err) {
277
- const msg = err instanceof Error ? err.message : String(err);
278
- return {
279
- content: [{ type: "text", text: `Error: ${msg}` }],
280
- };
281
- }
282
- },
283
- );
284
- }