pagesight 0.16.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 +26 -97
  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 +59 -20
  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 -852
  70. package/src/tools/speed.ts +0 -956
@@ -1,852 +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
- function formatSampleResults(
109
- siteUrl: string,
110
- sitemapUrl: string,
111
- totalUrls: number,
112
- results: InspectionSummary[],
113
- ): string {
114
- const lines: string[] = [
115
- `=== Sample Inspection: ${siteUrl} ===`,
116
- `Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
117
- `Sampled: ${results.length}`,
118
- "",
119
- ];
120
-
121
- // Summary
122
- const verdictCounts: Record<string, number> = {};
123
- const coverageCounts: Record<string, number> = {};
124
- const fetchCounts: Record<string, number> = {};
125
- let indexed = 0;
126
- let errors = 0;
127
-
128
- for (const r of results) {
129
- if (r.error) {
130
- errors++;
131
- continue;
132
- }
133
- verdictCounts[r.verdict] = (verdictCounts[r.verdict] ?? 0) + 1;
134
- coverageCounts[r.coverageState] = (coverageCounts[r.coverageState] ?? 0) + 1;
135
- fetchCounts[r.pageFetchState] = (fetchCounts[r.pageFetchState] ?? 0) + 1;
136
- if (r.verdict === "PASS") indexed++;
137
- }
138
-
139
- const inspected = results.length - errors;
140
- lines.push("--- Summary ---", "");
141
- lines.push(`Indexed: ${indexed}/${inspected}`);
142
-
143
- if (indexed < inspected) {
144
- lines.push(`Not indexed: ${inspected - indexed}/${inspected}`);
145
- for (const [state, count] of Object.entries(coverageCounts)) {
146
- if (state !== "Submitted and indexed" && state !== "Indexing allowed") {
147
- lines.push(` ${state}: ${count}`);
148
- }
149
- }
150
- }
151
-
152
- // Page fetch issues
153
- const fetchIssues = Object.entries(fetchCounts).filter(([s]) => s !== "SUCCESSFUL");
154
- if (fetchIssues.length > 0) {
155
- lines.push("");
156
- lines.push("Page fetch issues:");
157
- for (const [state, count] of fetchIssues) {
158
- lines.push(` ${humanizeState(state)}: ${count}`);
159
- }
160
- }
161
-
162
- if (errors > 0) {
163
- lines.push(`\nInspection errors: ${errors}`);
164
- }
165
-
166
- // Individual results
167
- lines.push("", "--- Details ---", "");
168
- for (let i = 0; i < results.length; i++) {
169
- const r = results[i];
170
- lines.push(`${i + 1}. ${r.url}`);
171
- if (r.error) {
172
- lines.push(` Error: ${r.error}`);
173
- } else {
174
- lines.push(` Verdict: ${r.verdict}`);
175
- lines.push(` Coverage: ${r.coverageState}`);
176
- lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
177
- if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
178
- if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
179
- if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
180
- if (r.googleCanonical && r.googleCanonical !== r.url) {
181
- lines.push(` Google canonical: ${r.googleCanonical}`);
182
- }
183
- }
184
- lines.push("");
185
- }
186
-
187
- return lines.join("\n");
188
- }
189
-
190
- // ── Sitemaps formatters ──
191
-
192
- function formatSites(sites: GscSite[]): string {
193
- if (sites.length === 0) return "No Search Console properties found.";
194
-
195
- const lines: string[] = [`=== GSC Properties (${sites.length}) ===`, ""];
196
- for (const site of sites) {
197
- lines.push(`${site.siteUrl} (${site.permissionLevel})`);
198
- }
199
- return lines.join("\n");
200
- }
201
-
202
- function formatSite(site: GscSite): string {
203
- return [`=== Site: ${site.siteUrl} ===`, "", `Permission: ${site.permissionLevel}`].join("\n");
204
- }
205
-
206
- function formatSitemapDetail(sm: GscSitemap): string {
207
- const lines: string[] = [`=== Sitemap: ${sm.path} ===`, ""];
208
- if (sm.type) lines.push(`Type: ${sm.type}`);
209
- if (sm.lastSubmitted) lines.push(`Submitted: ${sm.lastSubmitted}`);
210
- if (sm.lastDownloaded) lines.push(`Downloaded: ${sm.lastDownloaded}`);
211
- lines.push(`Pending: ${sm.isPending}`);
212
- lines.push(`Index: ${sm.isSitemapsIndex}`);
213
- if (sm.warnings) lines.push(`Warnings: ${sm.warnings}`);
214
- if (sm.errors) lines.push(`Errors: ${sm.errors}`);
215
- if (sm.contents && sm.contents.length > 0) {
216
- lines.push("", "Contents:");
217
- for (const c of sm.contents) {
218
- lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted, ${c.indexed ?? "?"} indexed`);
219
- }
220
- }
221
- return lines.join("\n");
222
- }
223
-
224
- function formatSitemaps(siteUrl: string, sitemaps: GscSitemap[]): string {
225
- if (sitemaps.length === 0) return `No sitemaps found for ${siteUrl}.`;
226
-
227
- const lines: string[] = [`=== Sitemaps: ${siteUrl} (${sitemaps.length}) ===`, ""];
228
-
229
- for (const sm of sitemaps) {
230
- lines.push(`${sm.path}`);
231
- if (sm.type) lines.push(` Type: ${sm.type}`);
232
- if (sm.lastSubmitted) lines.push(` Submitted: ${sm.lastSubmitted}`);
233
- if (sm.lastDownloaded) lines.push(` Downloaded: ${sm.lastDownloaded}`);
234
- lines.push(` Pending: ${sm.isPending}`);
235
- lines.push(` Index: ${sm.isSitemapsIndex}`);
236
- if (sm.warnings) lines.push(` Warnings: ${sm.warnings}`);
237
- if (sm.errors) lines.push(` Errors: ${sm.errors}`);
238
- if (sm.contents) {
239
- for (const c of sm.contents) {
240
- lines.push(` ${c.type}: ${c.submitted ?? "?"} submitted, ${c.indexed ?? "?"} indexed`);
241
- }
242
- }
243
- lines.push("");
244
- }
245
-
246
- return lines.join("\n").trimEnd();
247
- }
248
-
249
- // ── Performance formatters ──
250
-
251
- interface Totals {
252
- clicks: number;
253
- impressions: number;
254
- ctr: number;
255
- position: number;
256
- }
257
-
258
- function computeTotals(rows: SearchAnalyticsResponse["rows"]): Totals {
259
- const r = rows ?? [];
260
- const clicks = r.reduce((sum, row) => sum + row.clicks, 0);
261
- const impressions = r.reduce((sum, row) => sum + row.impressions, 0);
262
- const ctr = impressions > 0 ? clicks / impressions : 0;
263
- const position = impressions > 0 ? r.reduce((sum, row) => sum + row.position * row.impressions, 0) / impressions : 0;
264
- return { clicks, impressions, ctr, position };
265
- }
266
-
267
- function pctChange(current: number, previous: number): string {
268
- if (previous === 0) return current > 0 ? `+${current} (new)` : "0%";
269
- const change = ((current - previous) / previous) * 100;
270
- return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
271
- }
272
-
273
- function formatPerformance(
274
- siteUrl: string,
275
- result: SearchAnalyticsResponse,
276
- dimensions: string[],
277
- startDate: string,
278
- endDate: string,
279
- ): string {
280
- const rows = result.rows ?? [];
281
- const lines: string[] = [
282
- `=== Search Performance: ${siteUrl} ===`,
283
- `Period: ${startDate} to ${endDate}`,
284
- `Dimensions: ${dimensions.join(", ")}`,
285
- `Aggregation: ${result.responseAggregationType ?? "auto"}`,
286
- `Results: ${rows.length}`,
287
- "",
288
- ];
289
-
290
- if (result.metadata) {
291
- if (result.metadata.first_incomplete_date)
292
- lines.push(`Data incomplete from: ${result.metadata.first_incomplete_date}`);
293
- if (result.metadata.first_incomplete_hour)
294
- lines.push(`Hourly data incomplete from: ${result.metadata.first_incomplete_hour}`);
295
- lines.push("");
296
- }
297
-
298
- if (rows.length === 0) {
299
- lines.push("No data found for this period and filters.");
300
- return lines.join("\n");
301
- }
302
-
303
- const totals = computeTotals(rows);
304
-
305
- if (totals.impressions < 10) {
306
- lines.push("⚠ Low data volume — trends may not be meaningful.", "");
307
- }
308
-
309
- lines.push(
310
- `--- Summary (${rows.length} rows returned) ---`,
311
- "",
312
- `Clicks: ${totals.clicks.toLocaleString()}`,
313
- `Impressions: ${totals.impressions.toLocaleString()}`,
314
- `Avg CTR: ${(totals.ctr * 100).toFixed(1)}%`,
315
- `Avg Position: ${totals.position.toFixed(1)}`,
316
- "",
317
- "--- Top Results ---",
318
- "",
319
- );
320
-
321
- const top = rows.slice(0, 25);
322
- for (const row of top) {
323
- const keys = row.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
324
- lines.push(`${keys}`);
325
- lines.push(
326
- ` Clicks: ${row.clicks} | Impressions: ${row.impressions} | CTR: ${(row.ctr * 100).toFixed(1)}% | Position: ${row.position.toFixed(1)}`,
327
- );
328
- }
329
-
330
- if (rows.length > 25) {
331
- lines.push("", `... and ${rows.length - 25} more rows`);
332
- }
333
-
334
- return lines.join("\n");
335
- }
336
-
337
- function formatComparison(
338
- siteUrl: string,
339
- current: SearchAnalyticsResponse,
340
- previous: SearchAnalyticsResponse,
341
- dimensions: string[],
342
- currentStart: string,
343
- currentEnd: string,
344
- previousStart: string,
345
- previousEnd: string,
346
- ): string {
347
- const curRows = current.rows ?? [];
348
- const prevRows = previous.rows ?? [];
349
- const cur = computeTotals(curRows);
350
- const prev = computeTotals(prevRows);
351
-
352
- const lines: string[] = [
353
- `=== Search Performance: ${siteUrl} ===`,
354
- `Current: ${currentStart} to ${currentEnd}`,
355
- `Previous: ${previousStart} to ${previousEnd}`,
356
- `Dimensions: ${dimensions.join(", ")}`,
357
- "",
358
- ];
359
-
360
- if (cur.impressions < 10 && prev.impressions < 10) {
361
- lines.push("⚠ Low data volume — trends may not be meaningful.", "");
362
- }
363
-
364
- lines.push("--- Summary ---", "");
365
- lines.push(" Current Previous Change");
366
- lines.push(
367
- `Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
368
- );
369
- lines.push(
370
- `Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
371
- );
372
- const curCtr = `${(cur.ctr * 100).toFixed(1)}%`;
373
- const prevCtr = `${(prev.ctr * 100).toFixed(1)}%`;
374
- lines.push(`Avg CTR: ${curCtr.padEnd(12)} ${prevCtr.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`);
375
- const posStatus =
376
- prev.position === 0 && cur.position > 0
377
- ? "new"
378
- : cur.position < prev.position
379
- ? "improved"
380
- : cur.position > prev.position
381
- ? "regressed"
382
- : "stable";
383
- lines.push(
384
- `Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${posStatus} (${(cur.position - prev.position).toFixed(1)})`,
385
- );
386
- lines.push("");
387
-
388
- // Build lookup for previous period rows
389
- const prevMap = new Map<string, (typeof prevRows)[0]>();
390
- for (const row of prevRows) {
391
- prevMap.set(row.keys.join("|"), row);
392
- }
393
-
394
- // Find biggest movers (by click change)
395
- const movers: Array<{ keys: string[]; curClicks: number; prevClicks: number; curPos: number; prevPos: number }> = [];
396
- for (const row of curRows) {
397
- const key = row.keys.join("|");
398
- const prevRow = prevMap.get(key);
399
- movers.push({
400
- keys: row.keys,
401
- curClicks: row.clicks,
402
- prevClicks: prevRow?.clicks ?? 0,
403
- curPos: row.position,
404
- prevPos: prevRow?.position ?? 0,
405
- });
406
- }
407
-
408
- // Also include rows that disappeared (were in previous but not in current)
409
- const curKeys = new Set(curRows.map((r) => r.keys.join("|")));
410
- for (const row of prevRows) {
411
- const key = row.keys.join("|");
412
- if (!curKeys.has(key)) {
413
- movers.push({
414
- keys: row.keys,
415
- curClicks: 0,
416
- prevClicks: row.clicks,
417
- curPos: 0,
418
- prevPos: row.position,
419
- });
420
- }
421
- }
422
-
423
- // Sort by absolute click change descending
424
- movers.sort((a, b) => Math.abs(b.curClicks - b.prevClicks) - Math.abs(a.curClicks - a.prevClicks));
425
-
426
- const improved = movers.filter((m) => m.curClicks > m.prevClicks).slice(0, 10);
427
- const regressed = movers.filter((m) => m.curClicks < m.prevClicks).slice(0, 10);
428
-
429
- if (improved.length > 0) {
430
- lines.push("--- Improved ---", "");
431
- for (const m of improved) {
432
- const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
433
- const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
434
- lines.push(`${keys}`);
435
- lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
436
- }
437
- lines.push("");
438
- }
439
-
440
- lines.push("--- Regressed ---", "");
441
- if (regressed.length > 0) {
442
- for (const m of regressed) {
443
- const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
444
- const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
445
- lines.push(`${keys}`);
446
- lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
447
- }
448
- } else {
449
- lines.push("(none)");
450
- }
451
- lines.push("");
452
-
453
- return lines.join("\n");
454
- }
455
-
456
- function daysAgo(n: number): string {
457
- // GSC dates are in Pacific Time. Use Intl to handle DST correctly.
458
- const d = new Date();
459
- d.setDate(d.getDate() - n);
460
- const parts = new Intl.DateTimeFormat("en-CA", {
461
- timeZone: "America/Los_Angeles",
462
- year: "numeric",
463
- month: "2-digit",
464
- day: "2-digit",
465
- }).format(d);
466
- return parts; // en-CA formats as YYYY-MM-DD
467
- }
468
-
469
- // ── Keyword gap analysis ──
470
-
471
- function extractPageText(html: string): string {
472
- // Strip scripts, styles, and HTML tags to get visible text content
473
- let text = html;
474
- text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ");
475
- text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ");
476
- text = text.replace(/<[^>]+>/g, " ");
477
- // Decode common HTML entities
478
- text = text
479
- .replace(/&amp;/g, "&")
480
- .replace(/&lt;/g, "<")
481
- .replace(/&gt;/g, ">")
482
- .replace(/&quot;/g, '"')
483
- .replace(/&#39;/g, "'")
484
- .replace(/&nbsp;/g, " ");
485
- // Normalize whitespace
486
- text = text.replace(/\s+/g, " ").trim().toLowerCase();
487
- return text;
488
- }
489
-
490
- function formatGapAnalysis(
491
- pageUrl: string,
492
- siteUrl: string,
493
- queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>,
494
- pageText: string,
495
- ): string {
496
- const lines: string[] = [`=== Keyword Gap Analysis: ${pageUrl} ===`, `Property: ${siteUrl}`, ""];
497
-
498
- if (queries.length === 0) {
499
- lines.push("No search queries found for this page.");
500
- return lines.join("\n");
501
- }
502
-
503
- const onPage: typeof queries = [];
504
- const gaps: typeof queries = [];
505
-
506
- for (const q of queries) {
507
- const words = q.query.toLowerCase().split(/\s+/);
508
- const allPresent = words.every((w) => pageText.includes(w));
509
- if (allPresent) {
510
- onPage.push(q);
511
- } else {
512
- gaps.push(q);
513
- }
514
- }
515
-
516
- lines.push(`Queries analyzed: ${queries.length}`);
517
- lines.push(`On page: ${onPage.length}`);
518
- lines.push(`Gaps (not on page): ${gaps.length}`);
519
- lines.push("");
520
-
521
- if (gaps.length > 0) {
522
- lines.push("--- Keyword Gaps (queries with impressions but missing from page) ---", "");
523
- gaps.sort((a, b) => b.impressions - a.impressions);
524
- for (const q of gaps.slice(0, 20)) {
525
- const ctr = (q.ctr * 100).toFixed(1);
526
- lines.push(
527
- ` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
528
- );
529
- }
530
- if (gaps.length > 20) lines.push(` ... and ${gaps.length - 20} more`);
531
- lines.push("");
532
- }
533
-
534
- if (onPage.length > 0) {
535
- lines.push("--- Already Targeted (query found on page) ---", "");
536
- onPage.sort((a, b) => b.impressions - a.impressions);
537
- for (const q of onPage.slice(0, 10)) {
538
- const ctr = (q.ctr * 100).toFixed(1);
539
- lines.push(
540
- ` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
541
- );
542
- }
543
- if (onPage.length > 10) lines.push(` ... and ${onPage.length - 10} more`);
544
- lines.push("");
545
- }
546
-
547
- const totalGapImpressions = gaps.reduce((sum, q) => sum + q.impressions, 0);
548
- const totalImpressions = queries.reduce((sum, q) => sum + q.impressions, 0);
549
- if (totalImpressions > 0) {
550
- const gapPct = Math.round((totalGapImpressions / totalImpressions) * 100);
551
- lines.push(`${gapPct}% of impressions come from queries not on the page — potential content opportunity.`);
552
- }
553
-
554
- return lines.join("\n");
555
- }
556
-
557
- // ── Tool registration ──
558
-
559
- export function registerSearchTool(server: McpServer): void {
560
- server.tool(
561
- "search",
562
- "Query Google Search Console. Inspect URL indexing status, sample-inspect sitemap URLs, list properties and sitemaps, analyze search traffic, or find keyword gaps (queries with impressions but missing from page content).",
563
- {
564
- action: z
565
- .enum(["inspect", "sample", "sitemaps", "analytics", "gaps", "list_sites", "get_site", "get_sitemap"])
566
- .optional()
567
- .describe("Action to perform. Auto-detected from params when unambiguous."),
568
- site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
569
- url: z.string().url().optional().describe("URL to inspect in Google's index."),
570
- sitemap_url: z.string().url().optional().describe("Sitemap URL for sample inspection or get_sitemap."),
571
- sample_size: z.number().min(1).max(10).optional().describe("URLs to sample-inspect (1-10). Default: 5."),
572
- sample_strategy: z
573
- .enum(["random", "first", "spread"])
574
- .optional()
575
- .describe("Sampling strategy. Default: 'random'."),
576
- start_date: z.string().optional().describe("Start date (YYYY-MM-DD) for analytics. Default: 28 days ago."),
577
- end_date: z.string().optional().describe("End date (YYYY-MM-DD) for analytics. Default: 3 days ago."),
578
- dimensions: z
579
- .array(z.enum(["query", "page", "country", "device", "date", "searchAppearance", "hour"]))
580
- .optional()
581
- .describe("Analytics dimensions. Default: auto."),
582
- search_type: z
583
- .enum(["web", "image", "video", "news", "discover", "googleNews"])
584
- .optional()
585
- .describe("Search type for analytics."),
586
- data_state: z.enum(["all", "final", "hourly_all"]).optional().describe("Data freshness for analytics."),
587
- aggregation_type: z
588
- .enum(["auto", "byPage", "byProperty", "byNewsShowcasePanel"])
589
- .optional()
590
- .describe("Aggregation mode for analytics."),
591
- filters: z
592
- .array(
593
- z.object({
594
- dimension: z.enum(["query", "page", "country", "device", "searchAppearance"]),
595
- operator: z.enum(["equals", "contains", "notEquals", "notContains", "includingRegex", "excludingRegex"]),
596
- expression: z.string(),
597
- }),
598
- )
599
- .optional()
600
- .describe("Dimension filters for analytics."),
601
- row_limit: z.number().optional().describe("Max rows for analytics (1-25000). Default: 1000."),
602
- start_row: z.number().optional().describe("Pagination offset for analytics."),
603
- compare: z.boolean().optional().describe("Compare current vs previous period for analytics."),
604
- },
605
- async (params) => {
606
- const {
607
- site_url,
608
- url,
609
- sitemap_url,
610
- sample_size,
611
- sample_strategy,
612
- start_date,
613
- end_date,
614
- dimensions,
615
- search_type,
616
- data_state,
617
- aggregation_type,
618
- filters,
619
- row_limit,
620
- start_row,
621
- compare,
622
- } = params;
623
-
624
- // ── Route to action ──
625
- const hasAnalyticsParams = !!(start_date || end_date || dimensions || filters || compare);
626
-
627
- const resolvedAction =
628
- params.action ??
629
- (url && site_url && !hasAnalyticsParams
630
- ? "inspect"
631
- : sample_size
632
- ? "sample"
633
- : hasAnalyticsParams
634
- ? "analytics"
635
- : sitemap_url && site_url
636
- ? "get_sitemap"
637
- : site_url
638
- ? "sitemaps"
639
- : "list_sites");
640
-
641
- try {
642
- // ── inspect ──
643
- if (resolvedAction === "inspect") {
644
- if (!url) return textResult("Error: url is required for inspect.");
645
- if (!site_url) return textResult("Error: site_url is required for inspect.");
646
-
647
- try {
648
- const result = await inspectUrl(url, site_url);
649
- return textResult(formatInspection(url, site_url, result));
650
- } catch (err) {
651
- const msg = err instanceof Error ? err.message : String(err);
652
- if (msg.includes("PERMISSION_DENIED")) {
653
- const domain = new URL(url).hostname;
654
- return textResult(
655
- [
656
- `Error: Cannot inspect "${url}" — permission denied for property "${site_url}".`,
657
- "",
658
- "Possible causes:",
659
- ` 1. ${domain} is not a verified property in Google Search Console`,
660
- ` 2. The authenticated account does not have access to "${site_url}"`,
661
- ` 3. The property format is wrong — try "sc-domain:${domain}" or "https://${domain}/"`,
662
- "",
663
- "To verify a property: https://search.google.com/search-console",
664
- "To check your auth: use the setup tool to verify credentials",
665
- ].join("\n"),
666
- );
667
- }
668
- return textResult(`Error inspecting URL: ${msg}`);
669
- }
670
- }
671
-
672
- // ── sample ──
673
- if (resolvedAction === "sample") {
674
- if (!site_url) return textResult("Error: site_url is required for sample.");
675
-
676
- const count = sample_size ?? 5;
677
- const strategy = sample_strategy ?? "random";
678
-
679
- // Discover sitemap URL if not provided
680
- let resolvedSitemapUrl = sitemap_url;
681
- if (!resolvedSitemapUrl) {
682
- const sitemaps = await listSitemaps(site_url);
683
- if (sitemaps.length === 0) {
684
- return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
685
- }
686
- // Pick the first non-index sitemap, or the first one
687
- const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
688
- resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
689
- }
690
-
691
- // Fetch and parse sitemap
692
- let parsed = await fetchSitemap(resolvedSitemapUrl);
693
-
694
- // If it's a sitemap index, fetch the first child
695
- if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
696
- const childUrl = parsed.childSitemaps[0];
697
- parsed = await fetchSitemap(childUrl);
698
- resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
699
- }
700
-
701
- if (parsed.urls.length === 0) {
702
- return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
703
- }
704
-
705
- // Sample URLs
706
- const sampled = sampleUrls(parsed.urls, count, strategy);
707
-
708
- // Inspect each URL sequentially (API rate limits)
709
- const results: InspectionSummary[] = [];
710
- for (const u of sampled) {
711
- results.push(await inspectSingle(u, site_url));
712
- }
713
-
714
- return textResult(formatSampleResults(site_url, resolvedSitemapUrl, parsed.urls.length, results));
715
- }
716
-
717
- // ── list_sites ──
718
- if (resolvedAction === "list_sites") {
719
- const sites = await listSites();
720
- return textResult(formatSites(sites));
721
- }
722
-
723
- // ── get_site ──
724
- if (resolvedAction === "get_site") {
725
- if (!site_url) return textResult("Error: site_url is required for get_site.");
726
- const site = await getSite(site_url);
727
- return textResult(formatSite(site));
728
- }
729
-
730
- // ── get_sitemap ──
731
- if (resolvedAction === "get_sitemap") {
732
- if (!site_url) return textResult("Error: site_url is required for get_sitemap.");
733
- if (!sitemap_url) return textResult("Error: sitemap_url is required for get_sitemap.");
734
- const sm = await getSitemap(site_url, sitemap_url);
735
- return textResult(formatSitemapDetail(sm));
736
- }
737
-
738
- // ── sitemaps (list_sitemaps) ──
739
- if (resolvedAction === "sitemaps") {
740
- if (!site_url) return textResult("Error: site_url is required for sitemaps.");
741
- const sitemaps = await listSitemaps(site_url);
742
- return textResult(formatSitemaps(site_url, sitemaps));
743
- }
744
-
745
- // ── analytics ──
746
- if (resolvedAction === "analytics") {
747
- if (!site_url) return textResult("Error: site_url is required for analytics.");
748
-
749
- const startDate = start_date ?? daysAgo(28);
750
- const endDate = end_date ?? daysAgo(3);
751
-
752
- if (start_date && Number.isNaN(new Date(start_date).getTime())) {
753
- return textResult(`Error: invalid start_date "${start_date}". Use YYYY-MM-DD format.`);
754
- }
755
- if (end_date && Number.isNaN(new Date(end_date).getTime())) {
756
- return textResult(`Error: invalid end_date "${end_date}". Use YYYY-MM-DD format.`);
757
- }
758
- const dims = dimensions ?? ["query", "page"];
759
-
760
- const filterGroups =
761
- filters && filters.length > 0
762
- ? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
763
- : undefined;
764
-
765
- const queryOpts = {
766
- startDate,
767
- endDate,
768
- dimensions: dims,
769
- type: search_type,
770
- dataState: data_state,
771
- aggregationType: aggregation_type,
772
- rowLimit: row_limit ?? 1000,
773
- startRow: start_row,
774
- dimensionFilterGroups: filterGroups,
775
- };
776
-
777
- if (compare) {
778
- // Calculate previous period of equal length
779
- const start = new Date(startDate);
780
- const end = new Date(endDate);
781
- const durationMs = end.getTime() - start.getTime();
782
- const prevEnd = new Date(start.getTime() - 1 * 24 * 60 * 60 * 1000); // day before current start
783
- const prevStart = new Date(prevEnd.getTime() - durationMs);
784
- const prevStartDate = prevStart.toISOString().split("T")[0];
785
- const prevEndDate = prevEnd.toISOString().split("T")[0];
786
-
787
- const [current, previous] = await Promise.all([
788
- querySearchAnalytics(site_url, queryOpts),
789
- querySearchAnalytics(site_url, { ...queryOpts, startDate: prevStartDate, endDate: prevEndDate }),
790
- ]);
791
-
792
- return textResult(
793
- formatComparison(site_url, current, previous, dims, startDate, endDate, prevStartDate, prevEndDate),
794
- );
795
- }
796
-
797
- const result = await querySearchAnalytics(site_url, queryOpts);
798
- return textResult(formatPerformance(site_url, result, dims, startDate, endDate));
799
- }
800
-
801
- // ── gaps ──
802
- if (resolvedAction === "gaps") {
803
- if (!url) return textResult("Error: url is required for gap analysis.");
804
- if (!site_url) return textResult("Error: site_url is required for gap analysis.");
805
-
806
- // Fetch page content and GSC queries in parallel
807
- const [pageRes, analyticsRes] = await Promise.all([
808
- fetch(url, {
809
- headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
810
- redirect: "follow",
811
- }),
812
- querySearchAnalytics(site_url, {
813
- startDate: start_date ?? daysAgo(28),
814
- endDate: end_date ?? daysAgo(3),
815
- dimensions: ["query"],
816
- rowLimit: row_limit ?? 500,
817
- dimensionFilterGroups: [
818
- {
819
- groupType: "and" as const,
820
- filters: [{ dimension: "page", operator: "equals", expression: url } as SearchAnalyticsFilter],
821
- },
822
- ],
823
- }),
824
- ]);
825
-
826
- if (!pageRes.ok) return textResult(`Error fetching ${url}: HTTP ${pageRes.status}`);
827
- const html = await pageRes.text();
828
- const pageText = extractPageText(html);
829
-
830
- const queries = (analyticsRes.rows ?? []).map((row) => ({
831
- query: row.keys[0],
832
- clicks: row.clicks,
833
- impressions: row.impressions,
834
- ctr: row.ctr,
835
- position: row.position,
836
- }));
837
-
838
- return textResult(formatGapAnalysis(url, site_url, queries, pageText));
839
- }
840
-
841
- return textResult(`Error: Unknown action "${resolvedAction}".`);
842
- } catch (err) {
843
- const msg = err instanceof Error ? err.message : String(err);
844
- return textResult(`Error: ${msg}`);
845
- }
846
- },
847
- );
848
- }
849
-
850
- function textResult(text: string) {
851
- return { content: [{ type: "text" as const, text }] };
852
- }