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.
Files changed (70) hide show
  1. package/README.md +17 -83
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/snapshots.md +123 -0
  5. package/docs/usage.md +130 -0
  6. package/package.json +26 -29
  7. package/src/api/bing.ts +83 -0
  8. package/src/api/compare-snapshots.ts +377 -0
  9. package/src/api/discover.ts +39 -0
  10. package/src/api/doctor.ts +35 -0
  11. package/src/api/evidence-schema.ts +78 -0
  12. package/src/api/evidence.ts +99 -0
  13. package/src/api/execute.ts +147 -0
  14. package/src/api/index.ts +5 -0
  15. package/src/api/reports.ts +90 -0
  16. package/src/api/schema.ts +207 -0
  17. package/src/api/snapshot.ts +183 -0
  18. package/src/api/ui-findings.ts +68 -0
  19. package/src/cli.ts +182 -0
  20. package/src/http.ts +39 -0
  21. package/src/index.ts +8 -25
  22. package/src/mcp-server.ts +27 -0
  23. package/src/mcp.ts +5 -0
  24. package/src/providers/bing.ts +48 -0
  25. package/src/{lib → providers}/crux.ts +4 -9
  26. package/src/providers/ga.ts +114 -0
  27. package/src/providers/google-tokens.ts +86 -0
  28. package/src/providers/gsc-auth.ts +93 -0
  29. package/src/{lib → providers}/gsc.ts +27 -24
  30. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  31. package/src/shared/dates.ts +32 -0
  32. package/src/shared/http.ts +63 -0
  33. package/src/tools/ai.ts +19 -27
  34. package/src/tools/audit.ts +18 -98
  35. package/src/tools/observe.ts +28 -0
  36. package/src/tools/page/analyze.ts +194 -0
  37. package/src/tools/page/batch.ts +163 -0
  38. package/src/tools/page/contrast.ts +128 -0
  39. package/src/tools/page/links.ts +200 -0
  40. package/src/tools/page/metadata.ts +225 -0
  41. package/src/tools/page/structured-data.ts +288 -0
  42. package/src/tools/page/tool.ts +48 -0
  43. package/src/tools/search/actions.ts +56 -0
  44. package/src/tools/search/analytics.ts +266 -0
  45. package/src/tools/search/coverage.ts +247 -0
  46. package/src/tools/search/gaps.ts +129 -0
  47. package/src/tools/search/inspection.ts +160 -0
  48. package/src/tools/search/result.ts +3 -0
  49. package/src/tools/search/sample.ts +110 -0
  50. package/src/tools/search/schema.ts +62 -0
  51. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  52. package/src/tools/search/sites.ts +86 -0
  53. package/src/tools/search/tool.ts +11 -0
  54. package/src/tools/setup.ts +1 -1
  55. package/src/tools/speed/analyze.ts +191 -0
  56. package/src/tools/speed/batch.ts +265 -0
  57. package/src/tools/speed/crux.ts +176 -0
  58. package/src/tools/speed/pagespeed.ts +273 -0
  59. package/src/tools/speed/schema.ts +39 -0
  60. package/src/tools/speed/tool.ts +11 -0
  61. package/src/web/fetch.ts +31 -0
  62. package/src/web/images.ts +62 -0
  63. package/src/web/page-observation.ts +69 -0
  64. package/src/{lib → web}/robots.ts +20 -12
  65. package/src/web/sitemap-inventory.ts +64 -0
  66. package/src/web/sitemap-parser.ts +59 -0
  67. package/src/lib/auth.ts +0 -187
  68. package/src/tools/page.ts +0 -1241
  69. package/src/tools/search.ts +0 -1118
  70. package/src/tools/speed.ts +0 -956
@@ -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(/&amp;/g, "&")
617
- .replace(/&lt;/g, "<")
618
- .replace(/&gt;/g, ">")
619
- .replace(/&quot;/g, '"')
620
- .replace(/&#39;/g, "'")
621
- .replace(/&nbsp;/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
- }