pagesight 0.17.0 → 0.18.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 +17 -83
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/snapshots.md +123 -0
- package/docs/usage.md +130 -0
- package/package.json +26 -29
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +377 -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 +147 -0
- package/src/api/index.ts +5 -0
- package/src/api/reports.ts +90 -0
- package/src/api/schema.ts +207 -0
- package/src/api/snapshot.ts +183 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/cli.ts +182 -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/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
package/src/tools/search.ts
DELETED
|
@@ -1,1118 +0,0 @@
|
|
|
1
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import {
|
|
4
|
-
type GscSite,
|
|
5
|
-
type GscSitemap,
|
|
6
|
-
getSite,
|
|
7
|
-
getSitemap,
|
|
8
|
-
type InspectionResult,
|
|
9
|
-
inspectUrl,
|
|
10
|
-
listSitemaps,
|
|
11
|
-
listSites,
|
|
12
|
-
querySearchAnalytics,
|
|
13
|
-
type SearchAnalyticsFilter,
|
|
14
|
-
type SearchAnalyticsResponse,
|
|
15
|
-
} from "../lib/gsc.js";
|
|
16
|
-
import { fetchSitemap, type InspectionSummary, inspectSingle, sampleUrls } from "../lib/sitemap.js";
|
|
17
|
-
|
|
18
|
-
// ── Inspect formatters ──
|
|
19
|
-
|
|
20
|
-
function formatInspection(url: string, siteUrl: string, r: InspectionResult): string {
|
|
21
|
-
const idx = r.indexStatusResult;
|
|
22
|
-
const lines: string[] = [
|
|
23
|
-
`=== URL Inspection: ${url} ===`,
|
|
24
|
-
`Property: ${siteUrl}`,
|
|
25
|
-
"",
|
|
26
|
-
"--- Index Status ---",
|
|
27
|
-
"",
|
|
28
|
-
`Verdict: ${idx.verdict}`,
|
|
29
|
-
`Coverage: ${idx.coverageState}`,
|
|
30
|
-
`Page fetch: ${idx.pageFetchState}`,
|
|
31
|
-
`Robots.txt: ${idx.robotsTxtState}`,
|
|
32
|
-
`Indexing: ${idx.indexingState}`,
|
|
33
|
-
`Crawled as: ${idx.crawledAs ?? "unknown"}`,
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
if (idx.lastCrawlTime) lines.push(`Last crawled: ${idx.lastCrawlTime}`);
|
|
37
|
-
if (idx.userCanonical) lines.push(`Your canonical: ${idx.userCanonical}`);
|
|
38
|
-
if (idx.googleCanonical) lines.push(`Google's canonical: ${idx.googleCanonical}`);
|
|
39
|
-
|
|
40
|
-
if (idx.userCanonical && idx.googleCanonical && idx.userCanonical !== idx.googleCanonical) {
|
|
41
|
-
lines.push(`\n⚠ CANONICAL MISMATCH: You declared "${idx.userCanonical}" but Google chose "${idx.googleCanonical}"`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (idx.sitemap && idx.sitemap.length > 0) {
|
|
45
|
-
lines.push(`\nSitemaps: ${idx.sitemap.join(", ")}`);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (idx.referringUrls && idx.referringUrls.length > 0) {
|
|
49
|
-
lines.push(`\nReferring URLs: ${idx.referringUrls.join(", ")}`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (idx.verdict !== "PASS") {
|
|
53
|
-
const gscUrl = `https://search.google.com/search-console/inspect?resource_id=${encodeURIComponent(siteUrl)}&id=${encodeURIComponent(url)}`;
|
|
54
|
-
lines.push("", "→ This page is not indexed. Request indexing manually in Google Search Console:", ` ${gscUrl}`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Rich Results
|
|
58
|
-
if (r.richResultsResult) {
|
|
59
|
-
lines.push("", "--- Rich Results ---", "");
|
|
60
|
-
lines.push(`Verdict: ${r.richResultsResult.verdict}`);
|
|
61
|
-
|
|
62
|
-
for (const item of r.richResultsResult.detectedItems ?? []) {
|
|
63
|
-
lines.push(`\nType: ${item.richResultType}`);
|
|
64
|
-
for (const instance of item.items ?? []) {
|
|
65
|
-
if (instance.name) lines.push(` Name: ${instance.name}`);
|
|
66
|
-
const issues = instance.issues ?? [];
|
|
67
|
-
if (issues.length === 0) {
|
|
68
|
-
lines.push(" Status: PASS");
|
|
69
|
-
} else {
|
|
70
|
-
for (const issue of issues) {
|
|
71
|
-
lines.push(` ${issue.severity}: ${issue.issueMessage}`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Mobile Usability (deprecated but still returned)
|
|
79
|
-
if (r.mobileUsabilityResult) {
|
|
80
|
-
lines.push("", "--- Mobile Usability (deprecated) ---", "");
|
|
81
|
-
lines.push(`Verdict: ${r.mobileUsabilityResult.verdict}`);
|
|
82
|
-
if (r.mobileUsabilityResult.issues) {
|
|
83
|
-
for (const issue of r.mobileUsabilityResult.issues) {
|
|
84
|
-
lines.push(` ${issue.issueType}${issue.message ? `: ${issue.message}` : ""}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Inspection link
|
|
90
|
-
if (r.inspectionResultLink) {
|
|
91
|
-
lines.push("", `Full report: ${r.inspectionResultLink}`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return lines.join("\n");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── Sample-inspect formatters ──
|
|
98
|
-
|
|
99
|
-
function humanizeState(state: string): string {
|
|
100
|
-
const map: Record<string, string> = {
|
|
101
|
-
PAGE_FETCH_STATE_UNSPECIFIED: "not yet crawled",
|
|
102
|
-
ROBOTS_TXT_STATE_UNSPECIFIED: "not yet checked",
|
|
103
|
-
INDEXING_STATE_UNSPECIFIED: "not yet determined",
|
|
104
|
-
};
|
|
105
|
-
return map[state] ?? state;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ── Coverage helpers ──
|
|
109
|
-
|
|
110
|
-
const COVERAGE_FILTERS = [
|
|
111
|
-
"not_indexed",
|
|
112
|
-
"server_error",
|
|
113
|
-
"redirect",
|
|
114
|
-
"soft_404",
|
|
115
|
-
"blocked",
|
|
116
|
-
"duplicate",
|
|
117
|
-
"discovered",
|
|
118
|
-
"crawled_not_indexed",
|
|
119
|
-
] as const;
|
|
120
|
-
|
|
121
|
-
type CoverageFilter = (typeof COVERAGE_FILTERS)[number];
|
|
122
|
-
|
|
123
|
-
function matchesCoverageFilter(result: InspectionSummary, filter: CoverageFilter): boolean {
|
|
124
|
-
if (result.error) return false;
|
|
125
|
-
const cs = result.coverageState.toLowerCase();
|
|
126
|
-
const pf = result.pageFetchState.toLowerCase();
|
|
127
|
-
switch (filter) {
|
|
128
|
-
case "not_indexed":
|
|
129
|
-
return result.verdict !== "PASS";
|
|
130
|
-
case "server_error":
|
|
131
|
-
return cs.includes("server error") || pf === "server_error";
|
|
132
|
-
case "redirect":
|
|
133
|
-
return cs.includes("redirect") || pf.includes("redirect");
|
|
134
|
-
case "soft_404":
|
|
135
|
-
return cs.includes("soft 404") || pf === "soft_404";
|
|
136
|
-
case "blocked":
|
|
137
|
-
return cs.includes("blocked");
|
|
138
|
-
case "duplicate":
|
|
139
|
-
return cs.includes("duplicate");
|
|
140
|
-
case "discovered":
|
|
141
|
-
return cs.includes("discovered");
|
|
142
|
-
case "crawled_not_indexed":
|
|
143
|
-
return cs.includes("crawled - currently not indexed");
|
|
144
|
-
default:
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function filterNameForState(state: string): string | null {
|
|
150
|
-
const s = state.toLowerCase();
|
|
151
|
-
if (s.includes("server error")) return "server_error";
|
|
152
|
-
if (s.includes("blocked")) return "blocked";
|
|
153
|
-
if (s.includes("redirect")) return "redirect";
|
|
154
|
-
if (s.includes("soft 404")) return "soft_404";
|
|
155
|
-
if (s.includes("duplicate")) return "duplicate";
|
|
156
|
-
if (s.includes("discovered - currently not indexed")) return "discovered";
|
|
157
|
-
if (s.includes("crawled - currently not indexed")) return "crawled_not_indexed";
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function formatSampleResults(
|
|
162
|
-
siteUrl: string,
|
|
163
|
-
sitemapUrl: string,
|
|
164
|
-
totalUrls: number,
|
|
165
|
-
results: InspectionSummary[],
|
|
166
|
-
): string {
|
|
167
|
-
const lines: string[] = [
|
|
168
|
-
`=== Sample Inspection: ${siteUrl} ===`,
|
|
169
|
-
`Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
|
|
170
|
-
`Sampled: ${results.length}`,
|
|
171
|
-
"",
|
|
172
|
-
];
|
|
173
|
-
|
|
174
|
-
// Summary
|
|
175
|
-
const verdictCounts: Record<string, number> = {};
|
|
176
|
-
const coverageCounts: Record<string, number> = {};
|
|
177
|
-
const fetchCounts: Record<string, number> = {};
|
|
178
|
-
let indexed = 0;
|
|
179
|
-
let errors = 0;
|
|
180
|
-
|
|
181
|
-
for (const r of results) {
|
|
182
|
-
if (r.error) {
|
|
183
|
-
errors++;
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
verdictCounts[r.verdict] = (verdictCounts[r.verdict] ?? 0) + 1;
|
|
187
|
-
coverageCounts[r.coverageState] = (coverageCounts[r.coverageState] ?? 0) + 1;
|
|
188
|
-
fetchCounts[r.pageFetchState] = (fetchCounts[r.pageFetchState] ?? 0) + 1;
|
|
189
|
-
if (r.verdict === "PASS") indexed++;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const inspected = results.length - errors;
|
|
193
|
-
lines.push("--- Summary ---", "");
|
|
194
|
-
lines.push(`Indexed: ${indexed}/${inspected}`);
|
|
195
|
-
|
|
196
|
-
if (indexed < inspected) {
|
|
197
|
-
lines.push(`Not indexed: ${inspected - indexed}/${inspected}`);
|
|
198
|
-
for (const [state, count] of Object.entries(coverageCounts)) {
|
|
199
|
-
if (state !== "Submitted and indexed" && state !== "Indexing allowed") {
|
|
200
|
-
lines.push(` ${state}: ${count}`);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Page fetch issues
|
|
206
|
-
const fetchIssues = Object.entries(fetchCounts).filter(([s]) => s !== "SUCCESSFUL");
|
|
207
|
-
if (fetchIssues.length > 0) {
|
|
208
|
-
lines.push("");
|
|
209
|
-
lines.push("Page fetch issues:");
|
|
210
|
-
for (const [state, count] of fetchIssues) {
|
|
211
|
-
lines.push(` ${humanizeState(state)}: ${count}`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (errors > 0) {
|
|
216
|
-
lines.push(`\nInspection errors: ${errors}`);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Individual results
|
|
220
|
-
lines.push("", "--- Details ---", "");
|
|
221
|
-
for (let i = 0; i < results.length; i++) {
|
|
222
|
-
const r = results[i];
|
|
223
|
-
lines.push(`${i + 1}. ${r.url}`);
|
|
224
|
-
if (r.error) {
|
|
225
|
-
lines.push(` Error: ${r.error}`);
|
|
226
|
-
} else {
|
|
227
|
-
lines.push(` Verdict: ${r.verdict}`);
|
|
228
|
-
lines.push(` Coverage: ${r.coverageState}`);
|
|
229
|
-
lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
|
|
230
|
-
if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
|
|
231
|
-
if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
|
|
232
|
-
if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
|
|
233
|
-
if (r.googleCanonical && r.googleCanonical !== r.url) {
|
|
234
|
-
lines.push(` Google canonical: ${r.googleCanonical}`);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
lines.push("");
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return lines.join("\n");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ── Coverage formatter ──
|
|
244
|
-
|
|
245
|
-
function formatCoverage(siteUrl: string, sitemapUrl: string, totalUrls: number, results: InspectionSummary[]): string {
|
|
246
|
-
const inspected = results.filter((r) => !r.error);
|
|
247
|
-
const errors = results.filter((r) => r.error);
|
|
248
|
-
const indexed = inspected.filter((r) => r.verdict === "PASS");
|
|
249
|
-
const notIndexed = inspected.filter((r) => r.verdict !== "PASS");
|
|
250
|
-
|
|
251
|
-
const lines: string[] = [
|
|
252
|
-
`=== Index Coverage: ${siteUrl} ===`,
|
|
253
|
-
`Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
|
|
254
|
-
`Inspected: ${results.length}`,
|
|
255
|
-
"",
|
|
256
|
-
];
|
|
257
|
-
|
|
258
|
-
const total = inspected.length;
|
|
259
|
-
if (total === 0) {
|
|
260
|
-
lines.push("No URLs could be inspected.");
|
|
261
|
-
if (errors.length > 0) {
|
|
262
|
-
lines.push("");
|
|
263
|
-
for (const e of errors) lines.push(` ${e.url}: ${e.error}`);
|
|
264
|
-
}
|
|
265
|
-
return lines.join("\n");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Group not-indexed by coverageState
|
|
269
|
-
const issueGroups: Record<string, InspectionSummary[]> = {};
|
|
270
|
-
for (const r of notIndexed) {
|
|
271
|
-
const key = r.coverageState;
|
|
272
|
-
if (!issueGroups[key]) issueGroups[key] = [];
|
|
273
|
-
issueGroups[key].push(r);
|
|
274
|
-
}
|
|
275
|
-
const sortedIssues = Object.entries(issueGroups).sort((a, b) => b[1].length - a[1].length);
|
|
276
|
-
|
|
277
|
-
// Breakdown
|
|
278
|
-
lines.push("--- Breakdown ---", "");
|
|
279
|
-
lines.push(`Indexed: ${indexed.length}/${total} (${Math.round((indexed.length / total) * 100)}%)`);
|
|
280
|
-
lines.push(`Not indexed: ${notIndexed.length}/${total} (${Math.round((notIndexed.length / total) * 100)}%)`);
|
|
281
|
-
for (const [state, urls] of sortedIssues) {
|
|
282
|
-
lines.push(` ${state}: ${urls.length}`);
|
|
283
|
-
}
|
|
284
|
-
if (errors.length > 0) {
|
|
285
|
-
lines.push(`Inspection errors: ${errors.length}`);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Extrapolated estimates
|
|
289
|
-
if (totalUrls > total) {
|
|
290
|
-
lines.push("", "--- Estimated Totals ---", "");
|
|
291
|
-
const indexedEst = Math.round((indexed.length / total) * totalUrls);
|
|
292
|
-
const notIndexedEst = Math.round((notIndexed.length / total) * totalUrls);
|
|
293
|
-
lines.push(`~${indexedEst.toLocaleString()} indexed (of ${totalUrls.toLocaleString()})`);
|
|
294
|
-
lines.push(`~${notIndexedEst.toLocaleString()} not indexed`);
|
|
295
|
-
for (const [state, urls] of sortedIssues) {
|
|
296
|
-
const est = Math.round((urls.length / total) * totalUrls);
|
|
297
|
-
lines.push(` ~${est.toLocaleString()} ${state}`);
|
|
298
|
-
}
|
|
299
|
-
lines.push("", `Estimates based on ${total}-URL sample — actual numbers may vary.`);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Example URLs per issue
|
|
303
|
-
if (sortedIssues.length > 0) {
|
|
304
|
-
lines.push("", "--- URLs by Issue ---", "");
|
|
305
|
-
for (const [state, urls] of sortedIssues) {
|
|
306
|
-
lines.push(`${state}:`);
|
|
307
|
-
for (const u of urls.slice(0, 3)) {
|
|
308
|
-
lines.push(` ${u.url}`);
|
|
309
|
-
if (u.lastCrawlTime) lines.push(` Last crawled: ${u.lastCrawlTime}`);
|
|
310
|
-
if (u.pageFetchState !== "SUCCESSFUL") lines.push(` Fetch: ${humanizeState(u.pageFetchState)}`);
|
|
311
|
-
}
|
|
312
|
-
if (urls.length > 3) lines.push(` ... and ${urls.length - 3} more in sample`);
|
|
313
|
-
lines.push("");
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Suggest drill-down
|
|
318
|
-
const filterSuggestions = sortedIssues.map(([state]) => filterNameForState(state)).filter(Boolean);
|
|
319
|
-
if (filterSuggestions.length > 0) {
|
|
320
|
-
lines.push(`Drill deeper: search(action='sample', filter='...') to investigate specific issues.`);
|
|
321
|
-
lines.push(` Available filters: ${filterSuggestions.join(", ")}`);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
return lines.join("\n");
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// ── Sitemaps formatters ──
|
|
328
|
-
|
|
329
|
-
function formatSites(sites: GscSite[]): string {
|
|
330
|
-
if (sites.length === 0) return "No Search Console properties found.";
|
|
331
|
-
|
|
332
|
-
const lines: string[] = [`=== GSC Properties (${sites.length}) ===`, ""];
|
|
333
|
-
for (const site of sites) {
|
|
334
|
-
lines.push(`${site.siteUrl} (${site.permissionLevel})`);
|
|
335
|
-
}
|
|
336
|
-
return lines.join("\n");
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function formatSite(site: GscSite): string {
|
|
340
|
-
return [`=== Site: ${site.siteUrl} ===`, "", `Permission: ${site.permissionLevel}`].join("\n");
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function formatSitemapDetail(sm: GscSitemap): string {
|
|
344
|
-
const lines: string[] = [`=== Sitemap: ${sm.path} ===`, ""];
|
|
345
|
-
if (sm.type) lines.push(`Type: ${sm.type}`);
|
|
346
|
-
if (sm.lastSubmitted) lines.push(`Submitted: ${sm.lastSubmitted}`);
|
|
347
|
-
if (sm.lastDownloaded) lines.push(`Downloaded: ${sm.lastDownloaded}`);
|
|
348
|
-
lines.push(`Pending: ${sm.isPending}`);
|
|
349
|
-
lines.push(`Index: ${sm.isSitemapsIndex}`);
|
|
350
|
-
if (sm.warnings) lines.push(`Warnings: ${sm.warnings}`);
|
|
351
|
-
if (sm.errors) lines.push(`Errors: ${sm.errors}`);
|
|
352
|
-
if (sm.contents && sm.contents.length > 0) {
|
|
353
|
-
lines.push("", "Contents:");
|
|
354
|
-
for (const c of sm.contents) {
|
|
355
|
-
lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted, ${c.indexed ?? "?"} indexed`);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return lines.join("\n");
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function formatSitemaps(siteUrl: string, sitemaps: GscSitemap[]): string {
|
|
362
|
-
if (sitemaps.length === 0) return `No sitemaps found for ${siteUrl}.`;
|
|
363
|
-
|
|
364
|
-
const lines: string[] = [`=== Sitemaps: ${siteUrl} (${sitemaps.length}) ===`, ""];
|
|
365
|
-
|
|
366
|
-
for (const sm of sitemaps) {
|
|
367
|
-
lines.push(`${sm.path}`);
|
|
368
|
-
if (sm.type) lines.push(` Type: ${sm.type}`);
|
|
369
|
-
if (sm.lastSubmitted) lines.push(` Submitted: ${sm.lastSubmitted}`);
|
|
370
|
-
if (sm.lastDownloaded) lines.push(` Downloaded: ${sm.lastDownloaded}`);
|
|
371
|
-
lines.push(` Pending: ${sm.isPending}`);
|
|
372
|
-
lines.push(` Index: ${sm.isSitemapsIndex}`);
|
|
373
|
-
if (sm.warnings) lines.push(` Warnings: ${sm.warnings}`);
|
|
374
|
-
if (sm.errors) lines.push(` Errors: ${sm.errors}`);
|
|
375
|
-
if (sm.contents) {
|
|
376
|
-
for (const c of sm.contents) {
|
|
377
|
-
lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted, ${c.indexed ?? "?"} indexed`);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
lines.push("");
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
return lines.join("\n").trimEnd();
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// ── Performance formatters ──
|
|
387
|
-
|
|
388
|
-
interface Totals {
|
|
389
|
-
clicks: number;
|
|
390
|
-
impressions: number;
|
|
391
|
-
ctr: number;
|
|
392
|
-
position: number;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function computeTotals(rows: SearchAnalyticsResponse["rows"]): Totals {
|
|
396
|
-
const r = rows ?? [];
|
|
397
|
-
const clicks = r.reduce((sum, row) => sum + row.clicks, 0);
|
|
398
|
-
const impressions = r.reduce((sum, row) => sum + row.impressions, 0);
|
|
399
|
-
const ctr = impressions > 0 ? clicks / impressions : 0;
|
|
400
|
-
const position = impressions > 0 ? r.reduce((sum, row) => sum + row.position * row.impressions, 0) / impressions : 0;
|
|
401
|
-
return { clicks, impressions, ctr, position };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function pctChange(current: number, previous: number): string {
|
|
405
|
-
if (previous === 0) return current > 0 ? `+${current} (new)` : "0%";
|
|
406
|
-
const change = ((current - previous) / previous) * 100;
|
|
407
|
-
return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function formatPerformance(
|
|
411
|
-
siteUrl: string,
|
|
412
|
-
result: SearchAnalyticsResponse,
|
|
413
|
-
dimensions: string[],
|
|
414
|
-
startDate: string,
|
|
415
|
-
endDate: string,
|
|
416
|
-
): string {
|
|
417
|
-
const rows = result.rows ?? [];
|
|
418
|
-
const lines: string[] = [
|
|
419
|
-
`=== Search Performance: ${siteUrl} ===`,
|
|
420
|
-
`Period: ${startDate} to ${endDate}`,
|
|
421
|
-
`Dimensions: ${dimensions.join(", ")}`,
|
|
422
|
-
`Aggregation: ${result.responseAggregationType ?? "auto"}`,
|
|
423
|
-
`Results: ${rows.length}`,
|
|
424
|
-
"",
|
|
425
|
-
];
|
|
426
|
-
|
|
427
|
-
if (result.metadata) {
|
|
428
|
-
if (result.metadata.first_incomplete_date)
|
|
429
|
-
lines.push(`Data incomplete from: ${result.metadata.first_incomplete_date}`);
|
|
430
|
-
if (result.metadata.first_incomplete_hour)
|
|
431
|
-
lines.push(`Hourly data incomplete from: ${result.metadata.first_incomplete_hour}`);
|
|
432
|
-
lines.push("");
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (rows.length === 0) {
|
|
436
|
-
lines.push("No data found for this period and filters.");
|
|
437
|
-
return lines.join("\n");
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const totals = computeTotals(rows);
|
|
441
|
-
|
|
442
|
-
if (totals.impressions < 10) {
|
|
443
|
-
lines.push("⚠ Low data volume — trends may not be meaningful.", "");
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
lines.push(
|
|
447
|
-
`--- Summary (${rows.length} rows returned) ---`,
|
|
448
|
-
"",
|
|
449
|
-
`Clicks: ${totals.clicks.toLocaleString()}`,
|
|
450
|
-
`Impressions: ${totals.impressions.toLocaleString()}`,
|
|
451
|
-
`Avg CTR: ${(totals.ctr * 100).toFixed(1)}%`,
|
|
452
|
-
`Avg Position: ${totals.position.toFixed(1)}`,
|
|
453
|
-
"",
|
|
454
|
-
"--- Top Results ---",
|
|
455
|
-
"",
|
|
456
|
-
);
|
|
457
|
-
|
|
458
|
-
const top = rows.slice(0, 25);
|
|
459
|
-
for (const row of top) {
|
|
460
|
-
const keys = row.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
461
|
-
lines.push(`${keys}`);
|
|
462
|
-
lines.push(
|
|
463
|
-
` Clicks: ${row.clicks} | Impressions: ${row.impressions} | CTR: ${(row.ctr * 100).toFixed(1)}% | Position: ${row.position.toFixed(1)}`,
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
if (rows.length > 25) {
|
|
468
|
-
lines.push("", `... and ${rows.length - 25} more rows`);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
return lines.join("\n");
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
function formatComparison(
|
|
475
|
-
siteUrl: string,
|
|
476
|
-
current: SearchAnalyticsResponse,
|
|
477
|
-
previous: SearchAnalyticsResponse,
|
|
478
|
-
dimensions: string[],
|
|
479
|
-
currentStart: string,
|
|
480
|
-
currentEnd: string,
|
|
481
|
-
previousStart: string,
|
|
482
|
-
previousEnd: string,
|
|
483
|
-
): string {
|
|
484
|
-
const curRows = current.rows ?? [];
|
|
485
|
-
const prevRows = previous.rows ?? [];
|
|
486
|
-
const cur = computeTotals(curRows);
|
|
487
|
-
const prev = computeTotals(prevRows);
|
|
488
|
-
|
|
489
|
-
const lines: string[] = [
|
|
490
|
-
`=== Search Performance: ${siteUrl} ===`,
|
|
491
|
-
`Current: ${currentStart} to ${currentEnd}`,
|
|
492
|
-
`Previous: ${previousStart} to ${previousEnd}`,
|
|
493
|
-
`Dimensions: ${dimensions.join(", ")}`,
|
|
494
|
-
"",
|
|
495
|
-
];
|
|
496
|
-
|
|
497
|
-
if (cur.impressions < 10 && prev.impressions < 10) {
|
|
498
|
-
lines.push("⚠ Low data volume — trends may not be meaningful.", "");
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
lines.push("--- Summary ---", "");
|
|
502
|
-
lines.push(" Current Previous Change");
|
|
503
|
-
lines.push(
|
|
504
|
-
`Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
|
|
505
|
-
);
|
|
506
|
-
lines.push(
|
|
507
|
-
`Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
|
|
508
|
-
);
|
|
509
|
-
const curCtr = `${(cur.ctr * 100).toFixed(1)}%`;
|
|
510
|
-
const prevCtr = `${(prev.ctr * 100).toFixed(1)}%`;
|
|
511
|
-
lines.push(`Avg CTR: ${curCtr.padEnd(12)} ${prevCtr.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`);
|
|
512
|
-
const posStatus =
|
|
513
|
-
prev.position === 0 && cur.position > 0
|
|
514
|
-
? "new"
|
|
515
|
-
: cur.position < prev.position
|
|
516
|
-
? "improved"
|
|
517
|
-
: cur.position > prev.position
|
|
518
|
-
? "regressed"
|
|
519
|
-
: "stable";
|
|
520
|
-
lines.push(
|
|
521
|
-
`Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${posStatus} (${(cur.position - prev.position).toFixed(1)})`,
|
|
522
|
-
);
|
|
523
|
-
lines.push("");
|
|
524
|
-
|
|
525
|
-
// Build lookup for previous period rows
|
|
526
|
-
const prevMap = new Map<string, (typeof prevRows)[0]>();
|
|
527
|
-
for (const row of prevRows) {
|
|
528
|
-
prevMap.set(row.keys.join("|"), row);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Find biggest movers (by click change)
|
|
532
|
-
const movers: Array<{ keys: string[]; curClicks: number; prevClicks: number; curPos: number; prevPos: number }> = [];
|
|
533
|
-
for (const row of curRows) {
|
|
534
|
-
const key = row.keys.join("|");
|
|
535
|
-
const prevRow = prevMap.get(key);
|
|
536
|
-
movers.push({
|
|
537
|
-
keys: row.keys,
|
|
538
|
-
curClicks: row.clicks,
|
|
539
|
-
prevClicks: prevRow?.clicks ?? 0,
|
|
540
|
-
curPos: row.position,
|
|
541
|
-
prevPos: prevRow?.position ?? 0,
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// Also include rows that disappeared (were in previous but not in current)
|
|
546
|
-
const curKeys = new Set(curRows.map((r) => r.keys.join("|")));
|
|
547
|
-
for (const row of prevRows) {
|
|
548
|
-
const key = row.keys.join("|");
|
|
549
|
-
if (!curKeys.has(key)) {
|
|
550
|
-
movers.push({
|
|
551
|
-
keys: row.keys,
|
|
552
|
-
curClicks: 0,
|
|
553
|
-
prevClicks: row.clicks,
|
|
554
|
-
curPos: 0,
|
|
555
|
-
prevPos: row.position,
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Sort by absolute click change descending
|
|
561
|
-
movers.sort((a, b) => Math.abs(b.curClicks - b.prevClicks) - Math.abs(a.curClicks - a.prevClicks));
|
|
562
|
-
|
|
563
|
-
const improved = movers.filter((m) => m.curClicks > m.prevClicks).slice(0, 10);
|
|
564
|
-
const regressed = movers.filter((m) => m.curClicks < m.prevClicks).slice(0, 10);
|
|
565
|
-
|
|
566
|
-
if (improved.length > 0) {
|
|
567
|
-
lines.push("--- Improved ---", "");
|
|
568
|
-
for (const m of improved) {
|
|
569
|
-
const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
570
|
-
const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
|
|
571
|
-
lines.push(`${keys}`);
|
|
572
|
-
lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
|
|
573
|
-
}
|
|
574
|
-
lines.push("");
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
lines.push("--- Regressed ---", "");
|
|
578
|
-
if (regressed.length > 0) {
|
|
579
|
-
for (const m of regressed) {
|
|
580
|
-
const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
|
|
581
|
-
const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
|
|
582
|
-
lines.push(`${keys}`);
|
|
583
|
-
lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
|
|
584
|
-
}
|
|
585
|
-
} else {
|
|
586
|
-
lines.push("(none)");
|
|
587
|
-
}
|
|
588
|
-
lines.push("");
|
|
589
|
-
|
|
590
|
-
return lines.join("\n");
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
function daysAgo(n: number): string {
|
|
594
|
-
// GSC dates are in Pacific Time. Use Intl to handle DST correctly.
|
|
595
|
-
const d = new Date();
|
|
596
|
-
d.setDate(d.getDate() - n);
|
|
597
|
-
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
598
|
-
timeZone: "America/Los_Angeles",
|
|
599
|
-
year: "numeric",
|
|
600
|
-
month: "2-digit",
|
|
601
|
-
day: "2-digit",
|
|
602
|
-
}).format(d);
|
|
603
|
-
return parts; // en-CA formats as YYYY-MM-DD
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// ── Keyword gap analysis ──
|
|
607
|
-
|
|
608
|
-
function extractPageText(html: string): string {
|
|
609
|
-
// Strip scripts, styles, and HTML tags to get visible text content
|
|
610
|
-
let text = html;
|
|
611
|
-
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ");
|
|
612
|
-
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ");
|
|
613
|
-
text = text.replace(/<[^>]+>/g, " ");
|
|
614
|
-
// Decode common HTML entities
|
|
615
|
-
text = text
|
|
616
|
-
.replace(/&/g, "&")
|
|
617
|
-
.replace(/</g, "<")
|
|
618
|
-
.replace(/>/g, ">")
|
|
619
|
-
.replace(/"/g, '"')
|
|
620
|
-
.replace(/'/g, "'")
|
|
621
|
-
.replace(/ /g, " ");
|
|
622
|
-
// Normalize whitespace
|
|
623
|
-
text = text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
624
|
-
return text;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function formatGapAnalysis(
|
|
628
|
-
pageUrl: string,
|
|
629
|
-
siteUrl: string,
|
|
630
|
-
queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>,
|
|
631
|
-
pageText: string,
|
|
632
|
-
): string {
|
|
633
|
-
const lines: string[] = [`=== Keyword Gap Analysis: ${pageUrl} ===`, `Property: ${siteUrl}`, ""];
|
|
634
|
-
|
|
635
|
-
if (queries.length === 0) {
|
|
636
|
-
lines.push("No search queries found for this page.");
|
|
637
|
-
return lines.join("\n");
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const onPage: typeof queries = [];
|
|
641
|
-
const gaps: typeof queries = [];
|
|
642
|
-
|
|
643
|
-
for (const q of queries) {
|
|
644
|
-
const words = q.query.toLowerCase().split(/\s+/);
|
|
645
|
-
const allPresent = words.every((w) => pageText.includes(w));
|
|
646
|
-
if (allPresent) {
|
|
647
|
-
onPage.push(q);
|
|
648
|
-
} else {
|
|
649
|
-
gaps.push(q);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
lines.push(`Queries analyzed: ${queries.length}`);
|
|
654
|
-
lines.push(`On page: ${onPage.length}`);
|
|
655
|
-
lines.push(`Gaps (not on page): ${gaps.length}`);
|
|
656
|
-
lines.push("");
|
|
657
|
-
|
|
658
|
-
if (gaps.length > 0) {
|
|
659
|
-
lines.push("--- Keyword Gaps (queries with impressions but missing from page) ---", "");
|
|
660
|
-
gaps.sort((a, b) => b.impressions - a.impressions);
|
|
661
|
-
for (const q of gaps.slice(0, 20)) {
|
|
662
|
-
const ctr = (q.ctr * 100).toFixed(1);
|
|
663
|
-
lines.push(
|
|
664
|
-
` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
if (gaps.length > 20) lines.push(` ... and ${gaps.length - 20} more`);
|
|
668
|
-
lines.push("");
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (onPage.length > 0) {
|
|
672
|
-
lines.push("--- Already Targeted (query found on page) ---", "");
|
|
673
|
-
onPage.sort((a, b) => b.impressions - a.impressions);
|
|
674
|
-
for (const q of onPage.slice(0, 10)) {
|
|
675
|
-
const ctr = (q.ctr * 100).toFixed(1);
|
|
676
|
-
lines.push(
|
|
677
|
-
` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
|
|
678
|
-
);
|
|
679
|
-
}
|
|
680
|
-
if (onPage.length > 10) lines.push(` ... and ${onPage.length - 10} more`);
|
|
681
|
-
lines.push("");
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
const totalGapImpressions = gaps.reduce((sum, q) => sum + q.impressions, 0);
|
|
685
|
-
const totalImpressions = queries.reduce((sum, q) => sum + q.impressions, 0);
|
|
686
|
-
if (totalImpressions > 0) {
|
|
687
|
-
const gapPct = Math.round((totalGapImpressions / totalImpressions) * 100);
|
|
688
|
-
lines.push(`${gapPct}% of impressions come from queries not on the page — potential content opportunity.`);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
return lines.join("\n");
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// ── Tool registration ──
|
|
695
|
-
|
|
696
|
-
export function registerSearchTool(server: McpServer): void {
|
|
697
|
-
server.tool(
|
|
698
|
-
"search",
|
|
699
|
-
"Query Google Search Console. Inspect URL indexing, get coverage breakdown by issue type, sample-inspect with filters, list properties and sitemaps, analyze search traffic, or find keyword gaps.",
|
|
700
|
-
{
|
|
701
|
-
action: z
|
|
702
|
-
.enum([
|
|
703
|
-
"inspect",
|
|
704
|
-
"sample",
|
|
705
|
-
"coverage",
|
|
706
|
-
"sitemaps",
|
|
707
|
-
"analytics",
|
|
708
|
-
"gaps",
|
|
709
|
-
"list_sites",
|
|
710
|
-
"get_site",
|
|
711
|
-
"get_sitemap",
|
|
712
|
-
])
|
|
713
|
-
.optional()
|
|
714
|
-
.describe("Action to perform. Auto-detected from params when unambiguous."),
|
|
715
|
-
site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
|
|
716
|
-
url: z.string().url().optional().describe("URL to inspect in Google's index."),
|
|
717
|
-
sitemap_url: z.string().url().optional().describe("Sitemap URL for sample/coverage inspection or get_sitemap."),
|
|
718
|
-
sample_size: z
|
|
719
|
-
.number()
|
|
720
|
-
.min(1)
|
|
721
|
-
.max(50)
|
|
722
|
-
.optional()
|
|
723
|
-
.describe("URLs to inspect (1-50). Default: 5 for sample, 20 for coverage."),
|
|
724
|
-
sample_strategy: z
|
|
725
|
-
.enum(["random", "first", "spread"])
|
|
726
|
-
.optional()
|
|
727
|
-
.describe("Sampling strategy. Default: 'spread' for coverage, 'random' for sample."),
|
|
728
|
-
filter: z
|
|
729
|
-
.enum([
|
|
730
|
-
"not_indexed",
|
|
731
|
-
"server_error",
|
|
732
|
-
"redirect",
|
|
733
|
-
"soft_404",
|
|
734
|
-
"blocked",
|
|
735
|
-
"duplicate",
|
|
736
|
-
"discovered",
|
|
737
|
-
"crawled_not_indexed",
|
|
738
|
-
])
|
|
739
|
-
.optional()
|
|
740
|
-
.describe("Filter sample results by coverage issue type. Inspects more URLs internally to find matches."),
|
|
741
|
-
start_date: z.string().optional().describe("Start date (YYYY-MM-DD) for analytics. Default: 28 days ago."),
|
|
742
|
-
end_date: z.string().optional().describe("End date (YYYY-MM-DD) for analytics. Default: 3 days ago."),
|
|
743
|
-
dimensions: z
|
|
744
|
-
.array(z.enum(["query", "page", "country", "device", "date", "searchAppearance", "hour"]))
|
|
745
|
-
.optional()
|
|
746
|
-
.describe("Analytics dimensions. Default: auto."),
|
|
747
|
-
search_type: z
|
|
748
|
-
.enum(["web", "image", "video", "news", "discover", "googleNews"])
|
|
749
|
-
.optional()
|
|
750
|
-
.describe("Search type for analytics."),
|
|
751
|
-
data_state: z.enum(["all", "final", "hourly_all"]).optional().describe("Data freshness for analytics."),
|
|
752
|
-
aggregation_type: z
|
|
753
|
-
.enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"])
|
|
754
|
-
.optional()
|
|
755
|
-
.describe("Aggregation mode for analytics."),
|
|
756
|
-
filters: z
|
|
757
|
-
.array(
|
|
758
|
-
z.object({
|
|
759
|
-
dimension: z.enum(["query", "page", "country", "device", "searchAppearance"]),
|
|
760
|
-
operator: z.enum(["equals", "contains", "notEquals", "notContains", "includingRegex", "excludingRegex"]),
|
|
761
|
-
expression: z.string(),
|
|
762
|
-
}),
|
|
763
|
-
)
|
|
764
|
-
.optional()
|
|
765
|
-
.describe("Dimension filters for analytics."),
|
|
766
|
-
row_limit: z.number().optional().describe("Max rows for analytics (1-25000). Default: 1000."),
|
|
767
|
-
start_row: z.number().optional().describe("Pagination offset for analytics."),
|
|
768
|
-
compare: z.boolean().optional().describe("Compare current vs previous period for analytics."),
|
|
769
|
-
},
|
|
770
|
-
async (params) => {
|
|
771
|
-
const {
|
|
772
|
-
site_url,
|
|
773
|
-
url,
|
|
774
|
-
sitemap_url,
|
|
775
|
-
sample_size,
|
|
776
|
-
sample_strategy,
|
|
777
|
-
filter,
|
|
778
|
-
start_date,
|
|
779
|
-
end_date,
|
|
780
|
-
dimensions,
|
|
781
|
-
search_type,
|
|
782
|
-
data_state,
|
|
783
|
-
aggregation_type,
|
|
784
|
-
filters,
|
|
785
|
-
row_limit,
|
|
786
|
-
start_row,
|
|
787
|
-
compare,
|
|
788
|
-
} = params;
|
|
789
|
-
|
|
790
|
-
// ── Route to action ──
|
|
791
|
-
const hasAnalyticsParams = !!(start_date || end_date || dimensions || filters || compare);
|
|
792
|
-
|
|
793
|
-
const resolvedAction =
|
|
794
|
-
params.action ??
|
|
795
|
-
(url && site_url && !hasAnalyticsParams
|
|
796
|
-
? "inspect"
|
|
797
|
-
: sample_size || filter
|
|
798
|
-
? "sample"
|
|
799
|
-
: hasAnalyticsParams
|
|
800
|
-
? "analytics"
|
|
801
|
-
: sitemap_url && site_url
|
|
802
|
-
? "get_sitemap"
|
|
803
|
-
: site_url
|
|
804
|
-
? "sitemaps"
|
|
805
|
-
: "list_sites");
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
// ── inspect ──
|
|
809
|
-
if (resolvedAction === "inspect") {
|
|
810
|
-
if (!url) return textResult("Error: url is required for inspect.");
|
|
811
|
-
if (!site_url) return textResult("Error: site_url is required for inspect.");
|
|
812
|
-
|
|
813
|
-
try {
|
|
814
|
-
const result = await inspectUrl(url, site_url);
|
|
815
|
-
return textResult(formatInspection(url, site_url, result));
|
|
816
|
-
} catch (err) {
|
|
817
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
818
|
-
if (msg.includes("PERMISSION_DENIED")) {
|
|
819
|
-
const domain = new URL(url).hostname;
|
|
820
|
-
return textResult(
|
|
821
|
-
[
|
|
822
|
-
`Error: Cannot inspect "${url}" — permission denied for property "${site_url}".`,
|
|
823
|
-
"",
|
|
824
|
-
"Possible causes:",
|
|
825
|
-
` 1. ${domain} is not a verified property in Google Search Console`,
|
|
826
|
-
` 2. The authenticated account does not have access to "${site_url}"`,
|
|
827
|
-
` 3. The property format is wrong — try "sc-domain:${domain}" or "https://${domain}/"`,
|
|
828
|
-
"",
|
|
829
|
-
"To verify a property: https://search.google.com/search-console",
|
|
830
|
-
"To check your auth: use the setup tool to verify credentials",
|
|
831
|
-
].join("\n"),
|
|
832
|
-
);
|
|
833
|
-
}
|
|
834
|
-
return textResult(`Error inspecting URL: ${msg}`);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// ── sample ──
|
|
839
|
-
if (resolvedAction === "sample") {
|
|
840
|
-
if (!site_url) return textResult("Error: site_url is required for sample.");
|
|
841
|
-
|
|
842
|
-
const count = sample_size ?? 5;
|
|
843
|
-
const strategy = sample_strategy ?? "random";
|
|
844
|
-
|
|
845
|
-
// Discover sitemap URL if not provided
|
|
846
|
-
let resolvedSitemapUrl = sitemap_url;
|
|
847
|
-
if (!resolvedSitemapUrl) {
|
|
848
|
-
const sitemaps = await listSitemaps(site_url);
|
|
849
|
-
if (sitemaps.length === 0) {
|
|
850
|
-
return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
|
|
851
|
-
}
|
|
852
|
-
const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
|
|
853
|
-
resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// Fetch and parse sitemap
|
|
857
|
-
let parsed = await fetchSitemap(resolvedSitemapUrl);
|
|
858
|
-
|
|
859
|
-
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
860
|
-
const childUrl = parsed.childSitemaps[0];
|
|
861
|
-
parsed = await fetchSitemap(childUrl);
|
|
862
|
-
resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
if (parsed.urls.length === 0) {
|
|
866
|
-
return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
if (filter) {
|
|
870
|
-
// Filtered sample: inspect more URLs, return only matches
|
|
871
|
-
const scanLimit = Math.min(count * 5, 50, parsed.urls.length);
|
|
872
|
-
const pool = sampleUrls(parsed.urls, scanLimit, strategy);
|
|
873
|
-
const matched: InspectionSummary[] = [];
|
|
874
|
-
let scanned = 0;
|
|
875
|
-
|
|
876
|
-
for (const u of pool) {
|
|
877
|
-
const result = await inspectSingle(u, site_url);
|
|
878
|
-
scanned++;
|
|
879
|
-
if (matchesCoverageFilter(result, filter)) {
|
|
880
|
-
matched.push(result);
|
|
881
|
-
if (matched.length >= count) break;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
if (matched.length === 0) {
|
|
886
|
-
return textResult(
|
|
887
|
-
[
|
|
888
|
-
`=== Filtered Sample: ${site_url} ===`,
|
|
889
|
-
`Sitemap: ${resolvedSitemapUrl} (${parsed.urls.length.toLocaleString()} URLs)`,
|
|
890
|
-
`Filter: ${filter}`,
|
|
891
|
-
`Scanned: ${scanned} | Matched: 0`,
|
|
892
|
-
"",
|
|
893
|
-
`No URLs matching "${filter}" found in ${scanned} inspected URLs.`,
|
|
894
|
-
"Try a larger sample_size or a different filter.",
|
|
895
|
-
].join("\n"),
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
const lines: string[] = [
|
|
900
|
-
`=== Filtered Sample: ${site_url} ===`,
|
|
901
|
-
`Sitemap: ${resolvedSitemapUrl} (${parsed.urls.length.toLocaleString()} URLs)`,
|
|
902
|
-
`Filter: ${filter}`,
|
|
903
|
-
`Scanned: ${scanned} | Matched: ${matched.length}`,
|
|
904
|
-
"",
|
|
905
|
-
"--- Matching URLs ---",
|
|
906
|
-
"",
|
|
907
|
-
];
|
|
908
|
-
|
|
909
|
-
for (let i = 0; i < matched.length; i++) {
|
|
910
|
-
const r = matched[i];
|
|
911
|
-
lines.push(`${i + 1}. ${r.url}`);
|
|
912
|
-
lines.push(` Coverage: ${r.coverageState}`);
|
|
913
|
-
lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
|
|
914
|
-
if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
|
|
915
|
-
if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
|
|
916
|
-
if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
|
|
917
|
-
if (r.googleCanonical && r.googleCanonical !== r.url) {
|
|
918
|
-
lines.push(` Google canonical: ${r.googleCanonical}`);
|
|
919
|
-
}
|
|
920
|
-
lines.push("");
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
if (matched.length < count && scanned >= scanLimit) {
|
|
924
|
-
lines.push(
|
|
925
|
-
`Found ${matched.length}/${count} requested — scanned ${scanned} URLs (limit ${scanLimit}). Increase sample_size for a wider scan.`,
|
|
926
|
-
);
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
return textResult(lines.join("\n"));
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// Unfiltered sample (original behavior)
|
|
933
|
-
const sampled = sampleUrls(parsed.urls, count, strategy);
|
|
934
|
-
|
|
935
|
-
const results: InspectionSummary[] = [];
|
|
936
|
-
for (const u of sampled) {
|
|
937
|
-
results.push(await inspectSingle(u, site_url));
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
return textResult(formatSampleResults(site_url, resolvedSitemapUrl, parsed.urls.length, results));
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
// ── coverage ──
|
|
944
|
-
if (resolvedAction === "coverage") {
|
|
945
|
-
if (!site_url) return textResult("Error: site_url is required for coverage.");
|
|
946
|
-
|
|
947
|
-
const count = sample_size ?? 20;
|
|
948
|
-
const strategy = sample_strategy ?? "spread";
|
|
949
|
-
|
|
950
|
-
// Discover sitemap URL if not provided
|
|
951
|
-
let resolvedSitemapUrl = sitemap_url;
|
|
952
|
-
if (!resolvedSitemapUrl) {
|
|
953
|
-
const sitemaps = await listSitemaps(site_url);
|
|
954
|
-
if (sitemaps.length === 0) {
|
|
955
|
-
return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
|
|
956
|
-
}
|
|
957
|
-
const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
|
|
958
|
-
resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
let parsed = await fetchSitemap(resolvedSitemapUrl);
|
|
962
|
-
|
|
963
|
-
if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
|
|
964
|
-
const childUrl = parsed.childSitemaps[0];
|
|
965
|
-
parsed = await fetchSitemap(childUrl);
|
|
966
|
-
resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
if (parsed.urls.length === 0) {
|
|
970
|
-
return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
const sampled = sampleUrls(parsed.urls, count, strategy);
|
|
974
|
-
|
|
975
|
-
const results: InspectionSummary[] = [];
|
|
976
|
-
for (const u of sampled) {
|
|
977
|
-
results.push(await inspectSingle(u, site_url));
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
return textResult(formatCoverage(site_url, resolvedSitemapUrl, parsed.urls.length, results));
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// ── list_sites ──
|
|
984
|
-
if (resolvedAction === "list_sites") {
|
|
985
|
-
const sites = await listSites();
|
|
986
|
-
return textResult(formatSites(sites));
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// ── get_site ──
|
|
990
|
-
if (resolvedAction === "get_site") {
|
|
991
|
-
if (!site_url) return textResult("Error: site_url is required for get_site.");
|
|
992
|
-
const site = await getSite(site_url);
|
|
993
|
-
return textResult(formatSite(site));
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// ── get_sitemap ──
|
|
997
|
-
if (resolvedAction === "get_sitemap") {
|
|
998
|
-
if (!site_url) return textResult("Error: site_url is required for get_sitemap.");
|
|
999
|
-
if (!sitemap_url) return textResult("Error: sitemap_url is required for get_sitemap.");
|
|
1000
|
-
const sm = await getSitemap(site_url, sitemap_url);
|
|
1001
|
-
return textResult(formatSitemapDetail(sm));
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// ── sitemaps (list_sitemaps) ──
|
|
1005
|
-
if (resolvedAction === "sitemaps") {
|
|
1006
|
-
if (!site_url) return textResult("Error: site_url is required for sitemaps.");
|
|
1007
|
-
const sitemaps = await listSitemaps(site_url);
|
|
1008
|
-
return textResult(formatSitemaps(site_url, sitemaps));
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// ── analytics ──
|
|
1012
|
-
if (resolvedAction === "analytics") {
|
|
1013
|
-
if (!site_url) return textResult("Error: site_url is required for analytics.");
|
|
1014
|
-
|
|
1015
|
-
const startDate = start_date ?? daysAgo(28);
|
|
1016
|
-
const endDate = end_date ?? daysAgo(3);
|
|
1017
|
-
|
|
1018
|
-
if (start_date && Number.isNaN(new Date(start_date).getTime())) {
|
|
1019
|
-
return textResult(`Error: invalid start_date "${start_date}". Use YYYY-MM-DD format.`);
|
|
1020
|
-
}
|
|
1021
|
-
if (end_date && Number.isNaN(new Date(end_date).getTime())) {
|
|
1022
|
-
return textResult(`Error: invalid end_date "${end_date}". Use YYYY-MM-DD format.`);
|
|
1023
|
-
}
|
|
1024
|
-
const dims = dimensions ?? ["query", "page"];
|
|
1025
|
-
|
|
1026
|
-
const filterGroups =
|
|
1027
|
-
filters && filters.length > 0
|
|
1028
|
-
? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
|
|
1029
|
-
: undefined;
|
|
1030
|
-
|
|
1031
|
-
const queryOpts = {
|
|
1032
|
-
startDate,
|
|
1033
|
-
endDate,
|
|
1034
|
-
dimensions: dims,
|
|
1035
|
-
type: search_type,
|
|
1036
|
-
dataState: data_state,
|
|
1037
|
-
aggregationType: aggregation_type,
|
|
1038
|
-
rowLimit: row_limit ?? 1000,
|
|
1039
|
-
startRow: start_row,
|
|
1040
|
-
dimensionFilterGroups: filterGroups,
|
|
1041
|
-
};
|
|
1042
|
-
|
|
1043
|
-
if (compare) {
|
|
1044
|
-
// Calculate previous period of equal length
|
|
1045
|
-
const start = new Date(startDate);
|
|
1046
|
-
const end = new Date(endDate);
|
|
1047
|
-
const durationMs = end.getTime() - start.getTime();
|
|
1048
|
-
const prevEnd = new Date(start.getTime() - 1 * 24 * 60 * 60 * 1000); // day before current start
|
|
1049
|
-
const prevStart = new Date(prevEnd.getTime() - durationMs);
|
|
1050
|
-
const prevStartDate = prevStart.toISOString().split("T")[0];
|
|
1051
|
-
const prevEndDate = prevEnd.toISOString().split("T")[0];
|
|
1052
|
-
|
|
1053
|
-
const [current, previous] = await Promise.all([
|
|
1054
|
-
querySearchAnalytics(site_url, queryOpts),
|
|
1055
|
-
querySearchAnalytics(site_url, { ...queryOpts, startDate: prevStartDate, endDate: prevEndDate }),
|
|
1056
|
-
]);
|
|
1057
|
-
|
|
1058
|
-
return textResult(
|
|
1059
|
-
formatComparison(site_url, current, previous, dims, startDate, endDate, prevStartDate, prevEndDate),
|
|
1060
|
-
);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
const result = await querySearchAnalytics(site_url, queryOpts);
|
|
1064
|
-
return textResult(formatPerformance(site_url, result, dims, startDate, endDate));
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// ── gaps ──
|
|
1068
|
-
if (resolvedAction === "gaps") {
|
|
1069
|
-
if (!url) return textResult("Error: url is required for gap analysis.");
|
|
1070
|
-
if (!site_url) return textResult("Error: site_url is required for gap analysis.");
|
|
1071
|
-
|
|
1072
|
-
// Fetch page content and GSC queries in parallel
|
|
1073
|
-
const [pageRes, analyticsRes] = await Promise.all([
|
|
1074
|
-
fetch(url, {
|
|
1075
|
-
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
|
|
1076
|
-
redirect: "follow",
|
|
1077
|
-
}),
|
|
1078
|
-
querySearchAnalytics(site_url, {
|
|
1079
|
-
startDate: start_date ?? daysAgo(28),
|
|
1080
|
-
endDate: end_date ?? daysAgo(3),
|
|
1081
|
-
dimensions: ["query"],
|
|
1082
|
-
rowLimit: row_limit ?? 500,
|
|
1083
|
-
dimensionFilterGroups: [
|
|
1084
|
-
{
|
|
1085
|
-
groupType: "and" as const,
|
|
1086
|
-
filters: [{ dimension: "page", operator: "equals", expression: url } as SearchAnalyticsFilter],
|
|
1087
|
-
},
|
|
1088
|
-
],
|
|
1089
|
-
}),
|
|
1090
|
-
]);
|
|
1091
|
-
|
|
1092
|
-
if (!pageRes.ok) return textResult(`Error fetching ${url}: HTTP ${pageRes.status}`);
|
|
1093
|
-
const html = await pageRes.text();
|
|
1094
|
-
const pageText = extractPageText(html);
|
|
1095
|
-
|
|
1096
|
-
const queries = (analyticsRes.rows ?? []).map((row) => ({
|
|
1097
|
-
query: row.keys[0],
|
|
1098
|
-
clicks: row.clicks,
|
|
1099
|
-
impressions: row.impressions,
|
|
1100
|
-
ctr: row.ctr,
|
|
1101
|
-
position: row.position,
|
|
1102
|
-
}));
|
|
1103
|
-
|
|
1104
|
-
return textResult(formatGapAnalysis(url, site_url, queries, pageText));
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
return textResult(`Error: Unknown action "${resolvedAction}".`);
|
|
1108
|
-
} catch (err) {
|
|
1109
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1110
|
-
return textResult(`Error: ${msg}`);
|
|
1111
|
-
}
|
|
1112
|
-
},
|
|
1113
|
-
);
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
function textResult(text: string) {
|
|
1117
|
-
return { content: [{ type: "text" as const, text }] };
|
|
1118
|
-
}
|