pagesight 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
@@ -326,7 +326,16 @@ export function registerAuditTool(server: McpServer): void {
326
326
  if (pagespeedResult.status === "fulfilled") {
327
327
  addPagespeedFindings(pagespeedResult.value, findings);
328
328
  } else {
329
- errors.push(`PageSpeed: ${pagespeedResult.reason}`);
329
+ const psiErr = String(pagespeedResult.reason);
330
+ const statusMatch = psiErr.match(/\((\d{3})\)/);
331
+ const status = statusMatch ? Number(statusMatch[1]) : 0;
332
+ if (status === 403) {
333
+ errors.push("PageSpeed: SKIPPED (API key not authorized — enable PageSpeed Insights API in Google Cloud)");
334
+ } else if (status === 429) {
335
+ errors.push("PageSpeed: SKIPPED (rate limited — try again later or set GOOGLE_API_KEY)");
336
+ } else {
337
+ errors.push(`PageSpeed: ${psiErr.replace(/:\s*\{[\s\S]*$/, "")}`);
338
+ }
330
339
  }
331
340
 
332
341
  // Process robots
@@ -105,6 +105,59 @@ function humanizeState(state: string): string {
105
105
  return map[state] ?? state;
106
106
  }
107
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
+
108
161
  function formatSampleResults(
109
162
  siteUrl: string,
110
163
  sitemapUrl: string,
@@ -187,6 +240,90 @@ function formatSampleResults(
187
240
  return lines.join("\n");
188
241
  }
189
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
+
190
327
  // ── Sitemaps formatters ──
191
328
 
192
329
  function formatSites(sites: GscSite[]): string {
@@ -559,20 +696,48 @@ function formatGapAnalysis(
559
696
  export function registerSearchTool(server: McpServer): void {
560
697
  server.tool(
561
698
  "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).",
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.",
563
700
  {
564
701
  action: z
565
- .enum(["inspect", "sample", "sitemaps", "analytics", "gaps", "list_sites", "get_site", "get_sitemap"])
702
+ .enum([
703
+ "inspect",
704
+ "sample",
705
+ "coverage",
706
+ "sitemaps",
707
+ "analytics",
708
+ "gaps",
709
+ "list_sites",
710
+ "get_site",
711
+ "get_sitemap",
712
+ ])
566
713
  .optional()
567
714
  .describe("Action to perform. Auto-detected from params when unambiguous."),
568
715
  site_url: z.string().optional().describe("GSC property (e.g., 'sc-domain:example.com')."),
569
716
  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."),
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."),
572
724
  sample_strategy: z
573
725
  .enum(["random", "first", "spread"])
574
726
  .optional()
575
- .describe("Sampling strategy. Default: 'random'."),
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."),
576
741
  start_date: z.string().optional().describe("Start date (YYYY-MM-DD) for analytics. Default: 28 days ago."),
577
742
  end_date: z.string().optional().describe("End date (YYYY-MM-DD) for analytics. Default: 3 days ago."),
578
743
  dimensions: z
@@ -609,6 +774,7 @@ export function registerSearchTool(server: McpServer): void {
609
774
  sitemap_url,
610
775
  sample_size,
611
776
  sample_strategy,
777
+ filter,
612
778
  start_date,
613
779
  end_date,
614
780
  dimensions,
@@ -628,7 +794,7 @@ export function registerSearchTool(server: McpServer): void {
628
794
  params.action ??
629
795
  (url && site_url && !hasAnalyticsParams
630
796
  ? "inspect"
631
- : sample_size
797
+ : sample_size || filter
632
798
  ? "sample"
633
799
  : hasAnalyticsParams
634
800
  ? "analytics"
@@ -683,7 +849,6 @@ export function registerSearchTool(server: McpServer): void {
683
849
  if (sitemaps.length === 0) {
684
850
  return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
685
851
  }
686
- // Pick the first non-index sitemap, or the first one
687
852
  const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
688
853
  resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
689
854
  }
@@ -691,7 +856,6 @@ export function registerSearchTool(server: McpServer): void {
691
856
  // Fetch and parse sitemap
692
857
  let parsed = await fetchSitemap(resolvedSitemapUrl);
693
858
 
694
- // If it's a sitemap index, fetch the first child
695
859
  if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
696
860
  const childUrl = parsed.childSitemaps[0];
697
861
  parsed = await fetchSitemap(childUrl);
@@ -702,10 +866,72 @@ export function registerSearchTool(server: McpServer): void {
702
866
  return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
703
867
  }
704
868
 
705
- // Sample URLs
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)
706
933
  const sampled = sampleUrls(parsed.urls, count, strategy);
707
934
 
708
- // Inspect each URL sequentially (API rate limits)
709
935
  const results: InspectionSummary[] = [];
710
936
  for (const u of sampled) {
711
937
  results.push(await inspectSingle(u, site_url));
@@ -714,6 +940,46 @@ export function registerSearchTool(server: McpServer): void {
714
940
  return textResult(formatSampleResults(site_url, resolvedSitemapUrl, parsed.urls.length, results));
715
941
  }
716
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
+
717
983
  // ── list_sites ──
718
984
  if (resolvedAction === "list_sites") {
719
985
  const sites = await listSites();
@@ -17,7 +17,33 @@ export function registerSetupTool(server: McpServer): void {
17
17
  async ({ action, client_id, client_secret, code }) => {
18
18
  if (action === "status") {
19
19
  const method = getAuthMethod();
20
- if (method === "none") {
20
+ if (method !== "none") {
21
+ return {
22
+ content: [
23
+ {
24
+ type: "text",
25
+ text: `=== Pagesight Auth Status ===\n\nStatus: CONFIGURED\nMethod: ${method}`,
26
+ },
27
+ ],
28
+ };
29
+ }
30
+
31
+ // Check for partial OAuth config
32
+ const hasClientId = !!process.env.GSC_CLIENT_ID;
33
+ const hasClientSecret = !!process.env.GSC_CLIENT_SECRET;
34
+ const hasPartial = hasClientId || hasClientSecret;
35
+
36
+ if (hasPartial) {
37
+ const present = [hasClientId && "GSC_CLIENT_ID", hasClientSecret && "GSC_CLIENT_SECRET"]
38
+ .filter(Boolean)
39
+ .join(", ");
40
+ const missing = [
41
+ !hasClientId && "GSC_CLIENT_ID",
42
+ !hasClientSecret && "GSC_CLIENT_SECRET",
43
+ "GSC_REFRESH_TOKEN",
44
+ ]
45
+ .filter(Boolean)
46
+ .join(", ");
21
47
  return {
22
48
  content: [
23
49
  {
@@ -25,34 +51,47 @@ export function registerSetupTool(server: McpServer): void {
25
51
  text: [
26
52
  "=== Pagesight Auth Status ===",
27
53
  "",
28
- "Status: NOT CONFIGURED",
54
+ "Status: PARTIAL",
55
+ ` Present: ${present}`,
56
+ ` Missing: ${missing}`,
29
57
  "",
30
- "To use Pagesight, configure one of:",
31
- "",
32
- "Option 1: OAuth 2.0 (recommended for personal use)",
33
- " 1. Create a Google Cloud project",
34
- " 2. Enable 'Google Search Console API'",
35
- " 3. Create OAuth 2.0 credentials (Desktop app)",
36
- " 4. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
37
- " 5. Visit the URL, authorize, copy the code",
38
- " 6. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
39
- " 7. Set env vars: GSC_CLIENT_ID, GSC_CLIENT_SECRET, GSC_REFRESH_TOKEN",
40
- "",
41
- "Option 2: Service Account",
42
- " 1. Create a service account in Google Cloud",
43
- " 2. Download the JSON key file",
44
- " 3. Add the service account email as a user in Search Console",
45
- " 4. Set env var: GSC_SERVICE_ACCOUNT_KEY=/path/to/key.json",
58
+ "Complete OAuth setup:",
59
+ " 1. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
60
+ " 2. Visit the URL, authorize, copy the code",
61
+ " 3. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
62
+ " 4. Set GSC_REFRESH_TOKEN from the response",
46
63
  ].join("\n"),
47
64
  },
48
65
  ],
49
66
  };
50
67
  }
68
+
51
69
  return {
52
70
  content: [
53
71
  {
54
72
  type: "text",
55
- text: `=== Pagesight Auth Status ===\n\nStatus: CONFIGURED\nMethod: ${method}`,
73
+ text: [
74
+ "=== Pagesight Auth Status ===",
75
+ "",
76
+ "Status: NOT CONFIGURED",
77
+ "",
78
+ "To use Pagesight, configure one of:",
79
+ "",
80
+ "Option 1: OAuth 2.0 (recommended for personal use)",
81
+ " 1. Create a Google Cloud project",
82
+ " 2. Enable 'Google Search Console API'",
83
+ " 3. Create OAuth 2.0 credentials (Desktop app)",
84
+ " 4. Call: setup(action='get_auth_url', client_id='YOUR_ID')",
85
+ " 5. Visit the URL, authorize, copy the code",
86
+ " 6. Call: setup(action='exchange_code', client_id='YOUR_ID', client_secret='YOUR_SECRET', code='THE_CODE')",
87
+ " 7. Set env vars: GSC_CLIENT_ID, GSC_CLIENT_SECRET, GSC_REFRESH_TOKEN",
88
+ "",
89
+ "Option 2: Service Account",
90
+ " 1. Create a service account in Google Cloud",
91
+ " 2. Download the JSON key file",
92
+ " 3. Add the service account email as a user in Search Console",
93
+ " 4. Set env var: GSC_SERVICE_ACCOUNT_KEY=/path/to/key.json",
94
+ ].join("\n"),
56
95
  },
57
96
  ],
58
97
  };