pagesight 0.17.0 → 0.19.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 (79) hide show
  1. package/README.md +20 -82
  2. package/docs/credentials.md +54 -0
  3. package/docs/diagnostics.md +85 -0
  4. package/docs/measurement.md +178 -0
  5. package/docs/opportunities.md +78 -0
  6. package/docs/snapshots.md +130 -0
  7. package/docs/usage.md +135 -0
  8. package/package.json +26 -29
  9. package/src/api/assessment.ts +315 -0
  10. package/src/api/bing.ts +83 -0
  11. package/src/api/compare-snapshots.ts +167 -0
  12. package/src/api/discover.ts +39 -0
  13. package/src/api/doctor.ts +35 -0
  14. package/src/api/evidence-schema.ts +78 -0
  15. package/src/api/evidence.ts +99 -0
  16. package/src/api/execute.ts +160 -0
  17. package/src/api/ga-freshness.ts +20 -0
  18. package/src/api/ga-realtime.ts +40 -0
  19. package/src/api/index.ts +5 -0
  20. package/src/api/opportunities.ts +317 -0
  21. package/src/api/report-table.ts +216 -0
  22. package/src/api/reports.ts +108 -0
  23. package/src/api/schema.ts +271 -0
  24. package/src/api/snapshot.ts +202 -0
  25. package/src/api/ui-findings.ts +68 -0
  26. package/src/assessment-text.ts +55 -0
  27. package/src/cli.ts +217 -0
  28. package/src/http.ts +39 -0
  29. package/src/index.ts +8 -25
  30. package/src/mcp-server.ts +27 -0
  31. package/src/mcp.ts +5 -0
  32. package/src/opportunities-text.ts +61 -0
  33. package/src/providers/bing.ts +48 -0
  34. package/src/{lib → providers}/crux.ts +4 -9
  35. package/src/providers/ga.ts +114 -0
  36. package/src/providers/google-tokens.ts +86 -0
  37. package/src/providers/gsc-auth.ts +93 -0
  38. package/src/{lib → providers}/gsc.ts +27 -24
  39. package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
  40. package/src/shared/dates.ts +32 -0
  41. package/src/shared/http.ts +63 -0
  42. package/src/tools/ai.ts +19 -27
  43. package/src/tools/audit.ts +18 -98
  44. package/src/tools/observe.ts +28 -0
  45. package/src/tools/page/analyze.ts +194 -0
  46. package/src/tools/page/batch.ts +163 -0
  47. package/src/tools/page/contrast.ts +128 -0
  48. package/src/tools/page/links.ts +200 -0
  49. package/src/tools/page/metadata.ts +225 -0
  50. package/src/tools/page/structured-data.ts +288 -0
  51. package/src/tools/page/tool.ts +48 -0
  52. package/src/tools/search/actions.ts +56 -0
  53. package/src/tools/search/analytics.ts +266 -0
  54. package/src/tools/search/coverage.ts +247 -0
  55. package/src/tools/search/gaps.ts +129 -0
  56. package/src/tools/search/inspection.ts +160 -0
  57. package/src/tools/search/result.ts +3 -0
  58. package/src/tools/search/sample.ts +110 -0
  59. package/src/tools/search/schema.ts +62 -0
  60. package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
  61. package/src/tools/search/sites.ts +86 -0
  62. package/src/tools/search/tool.ts +11 -0
  63. package/src/tools/setup.ts +1 -1
  64. package/src/tools/speed/analyze.ts +191 -0
  65. package/src/tools/speed/batch.ts +265 -0
  66. package/src/tools/speed/crux.ts +176 -0
  67. package/src/tools/speed/pagespeed.ts +273 -0
  68. package/src/tools/speed/schema.ts +39 -0
  69. package/src/tools/speed/tool.ts +11 -0
  70. package/src/web/fetch.ts +31 -0
  71. package/src/web/images.ts +62 -0
  72. package/src/web/page-observation.ts +69 -0
  73. package/src/{lib → web}/robots.ts +20 -12
  74. package/src/web/sitemap-inventory.ts +64 -0
  75. package/src/web/sitemap-parser.ts +59 -0
  76. package/src/lib/auth.ts +0 -187
  77. package/src/tools/page.ts +0 -1241
  78. package/src/tools/search.ts +0 -1118
  79. package/src/tools/speed.ts +0 -956
@@ -0,0 +1,266 @@
1
+ import { querySearchAnalytics, type SearchAnalyticsFilter, type SearchAnalyticsResponse } from "../../providers/gsc.js";
2
+ import { pacificDaysAgo } from "../../shared/dates.js";
3
+ import { type SearchOptions } from "./schema.js";
4
+ import { textResult } from "./result.js";
5
+ interface Totals {
6
+ clicks: number;
7
+ impressions: number;
8
+ ctr: number;
9
+ position: number;
10
+ }
11
+
12
+ function computeTotals(rows: SearchAnalyticsResponse["rows"]): Totals {
13
+ const r = rows ?? [];
14
+ const clicks = r.reduce((sum, row) => sum + row.clicks, 0);
15
+ const impressions = r.reduce((sum, row) => sum + row.impressions, 0);
16
+ const ctr = impressions > 0 ? clicks / impressions : 0;
17
+ const position = impressions > 0 ? r.reduce((sum, row) => sum + row.position * row.impressions, 0) / impressions : 0;
18
+ return { clicks, impressions, ctr, position };
19
+ }
20
+
21
+ function pctChange(current: number, previous: number): string {
22
+ if (previous === 0) return current > 0 ? `+${current} (new)` : "0%";
23
+ const change = ((current - previous) / previous) * 100;
24
+ return `${change > 0 ? "+" : ""}${change.toFixed(1)}%`;
25
+ }
26
+
27
+ function formatPerformance(
28
+ siteUrl: string,
29
+ result: SearchAnalyticsResponse,
30
+ dimensions: string[],
31
+ startDate: string,
32
+ endDate: string,
33
+ ): string {
34
+ const rows = result.rows ?? [];
35
+ const lines: string[] = [
36
+ `=== Search Performance: ${siteUrl} ===`,
37
+ `Period: ${startDate} to ${endDate}`,
38
+ `Dimensions: ${dimensions.join(", ")}`,
39
+ `Aggregation: ${result.responseAggregationType ?? "auto"}`,
40
+ `Results: ${rows.length}`,
41
+ "",
42
+ ];
43
+
44
+ if (result.metadata) {
45
+ if (result.metadata.first_incomplete_date)
46
+ lines.push(`Data incomplete from: ${result.metadata.first_incomplete_date}`);
47
+ if (result.metadata.first_incomplete_hour)
48
+ lines.push(`Hourly data incomplete from: ${result.metadata.first_incomplete_hour}`);
49
+ lines.push("");
50
+ }
51
+
52
+ if (rows.length === 0) {
53
+ lines.push("No data found for this period and filters.");
54
+ return lines.join("\n");
55
+ }
56
+
57
+ const totals = computeTotals(rows);
58
+
59
+ if (totals.impressions < 10) {
60
+ lines.push("⚠ Low data volume — trends may not be meaningful.", "");
61
+ }
62
+
63
+ lines.push(
64
+ `--- Returned-row summary (${rows.length} rows; not property totals) ---`,
65
+ "API top-row limits and query privacy exclusions may omit data.",
66
+ "",
67
+ `Clicks: ${totals.clicks.toLocaleString()}`,
68
+ `Impressions: ${totals.impressions.toLocaleString()}`,
69
+ `Avg CTR: ${(totals.ctr * 100).toFixed(1)}%`,
70
+ `Avg Position: ${totals.position.toFixed(1)}`,
71
+ "",
72
+ "--- Top Results ---",
73
+ "",
74
+ );
75
+
76
+ const top = rows.slice(0, 25);
77
+ for (const row of top) {
78
+ const keys = row.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
79
+ lines.push(`${keys}`);
80
+ lines.push(
81
+ ` Clicks: ${row.clicks} | Impressions: ${row.impressions} | CTR: ${(row.ctr * 100).toFixed(1)}% | Position: ${row.position.toFixed(1)}`,
82
+ );
83
+ }
84
+
85
+ if (rows.length > 25) {
86
+ lines.push("", `... and ${rows.length - 25} more rows`);
87
+ }
88
+
89
+ return lines.join("\n");
90
+ }
91
+
92
+ export function formatComparison(
93
+ siteUrl: string,
94
+ current: SearchAnalyticsResponse,
95
+ previous: SearchAnalyticsResponse,
96
+ dimensions: string[],
97
+ currentStart: string,
98
+ currentEnd: string,
99
+ previousStart: string,
100
+ previousEnd: string,
101
+ ): string {
102
+ const curRows = current.rows ?? [];
103
+ const prevRows = previous.rows ?? [];
104
+ const cur = computeTotals(curRows);
105
+ const prev = computeTotals(prevRows);
106
+
107
+ const lines: string[] = [
108
+ `=== Search Performance: ${siteUrl} ===`,
109
+ `Current: ${currentStart} to ${currentEnd}`,
110
+ `Previous: ${previousStart} to ${previousEnd}`,
111
+ `Dimensions: ${dimensions.join(", ")}`,
112
+ "",
113
+ ];
114
+
115
+ if (cur.impressions < 10 && prev.impressions < 10) {
116
+ lines.push("⚠ Low data volume — trends may not be meaningful.", "");
117
+ }
118
+
119
+ lines.push("--- Returned-row summary (not property totals) ---", "");
120
+ lines.push("Query privacy exclusions, top-row limits and aggregation may omit data.");
121
+ lines.push("Position changes can reflect a different mix of impressions, not a ranking change.", "");
122
+ lines.push(" Current Previous Change");
123
+ lines.push(
124
+ `Clicks: ${String(cur.clicks.toLocaleString()).padEnd(12)} ${String(prev.clicks.toLocaleString()).padEnd(12)} ${pctChange(cur.clicks, prev.clicks)}`,
125
+ );
126
+ lines.push(
127
+ `Impressions: ${String(cur.impressions.toLocaleString()).padEnd(12)} ${String(prev.impressions.toLocaleString()).padEnd(12)} ${pctChange(cur.impressions, prev.impressions)}`,
128
+ );
129
+ const curCtr = `${(cur.ctr * 100).toFixed(1)}%`;
130
+ const prevCtr = `${(prev.ctr * 100).toFixed(1)}%`;
131
+ lines.push(`Avg CTR: ${curCtr.padEnd(12)} ${prevCtr.padEnd(12)} ${((cur.ctr - prev.ctr) * 100).toFixed(1)}pp`);
132
+ const posStatus =
133
+ prev.position === 0 && cur.position > 0
134
+ ? "new"
135
+ : cur.position < prev.position
136
+ ? "improved"
137
+ : cur.position > prev.position
138
+ ? "regressed"
139
+ : "stable";
140
+ lines.push(
141
+ `Avg Position:${String(cur.position.toFixed(1)).padEnd(13)} ${String(prev.position.toFixed(1)).padEnd(12)} ${posStatus} (${(cur.position - prev.position).toFixed(1)})`,
142
+ );
143
+ lines.push("");
144
+
145
+ // Build lookup for previous period rows
146
+ const prevMap = new Map<string, (typeof prevRows)[0]>();
147
+ for (const row of prevRows) {
148
+ prevMap.set(JSON.stringify(row.keys), row);
149
+ }
150
+
151
+ // Find biggest movers (by click change)
152
+ const movers: Array<{ keys: string[]; curClicks: number; prevClicks: number; curPos: number; prevPos: number }> = [];
153
+ for (const row of curRows) {
154
+ const key = JSON.stringify(row.keys);
155
+ const prevRow = prevMap.get(key);
156
+ if (!prevRow) continue;
157
+ movers.push({
158
+ keys: row.keys,
159
+ curClicks: row.clicks,
160
+ prevClicks: prevRow.clicks,
161
+ curPos: row.position,
162
+ prevPos: prevRow.position,
163
+ });
164
+ }
165
+
166
+ lines.push("Movers include only rows observed in both periods. Missing rows are unknown, not zero.", "");
167
+
168
+ movers.sort((a, b) => Math.abs(b.curClicks - b.prevClicks) - Math.abs(a.curClicks - a.prevClicks));
169
+
170
+ const improved = movers.filter((m) => m.curClicks > m.prevClicks).slice(0, 10);
171
+ const regressed = movers.filter((m) => m.curClicks < m.prevClicks).slice(0, 10);
172
+
173
+ if (improved.length > 0) {
174
+ lines.push("--- Improved ---", "");
175
+ for (const m of improved) {
176
+ const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
177
+ const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
178
+ lines.push(`${keys}`);
179
+ lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
180
+ }
181
+ lines.push("");
182
+ }
183
+
184
+ lines.push("--- Regressed ---", "");
185
+ if (regressed.length > 0) {
186
+ for (const m of regressed) {
187
+ const keys = m.keys.map((k, i) => `${dimensions[i] ?? "key"}=${k}`).join(" | ");
188
+ const posChange = m.prevPos > 0 ? ` | Position: ${m.prevPos.toFixed(1)} → ${m.curPos.toFixed(1)}` : "";
189
+ lines.push(`${keys}`);
190
+ lines.push(` Clicks: ${m.prevClicks} → ${m.curClicks} (${pctChange(m.curClicks, m.prevClicks)})${posChange}`);
191
+ }
192
+ } else {
193
+ lines.push("(none)");
194
+ }
195
+ lines.push("");
196
+
197
+ return lines.join("\n");
198
+ }
199
+
200
+ export async function runAnalytics(options: SearchOptions) {
201
+ const {
202
+ site_url,
203
+ start_date,
204
+ end_date,
205
+ dimensions,
206
+ search_type,
207
+ data_state,
208
+ aggregation_type,
209
+ filters,
210
+ row_limit,
211
+ start_row,
212
+ compare,
213
+ } = options;
214
+ if (!site_url) return textResult("Error: site_url is required for analytics.");
215
+
216
+ const startDate = start_date ?? pacificDaysAgo(28);
217
+ const endDate = end_date ?? pacificDaysAgo(3);
218
+
219
+ if (start_date && Number.isNaN(new Date(start_date).getTime())) {
220
+ return textResult(`Error: invalid start_date "${start_date}". Use YYYY-MM-DD format.`);
221
+ }
222
+ if (end_date && Number.isNaN(new Date(end_date).getTime())) {
223
+ return textResult(`Error: invalid end_date "${end_date}". Use YYYY-MM-DD format.`);
224
+ }
225
+ const dims = dimensions ?? ["query", "page"];
226
+
227
+ const filterGroups =
228
+ filters && filters.length > 0
229
+ ? [{ groupType: "and" as const, filters: filters as SearchAnalyticsFilter[] }]
230
+ : undefined;
231
+
232
+ const queryOpts = {
233
+ startDate,
234
+ endDate,
235
+ dimensions: dims,
236
+ type: search_type,
237
+ dataState: data_state,
238
+ aggregationType: aggregation_type,
239
+ rowLimit: row_limit ?? 1000,
240
+ startRow: start_row,
241
+ dimensionFilterGroups: filterGroups,
242
+ };
243
+
244
+ if (compare) {
245
+ // Calculate previous period of equal length
246
+ const start = new Date(startDate);
247
+ const end = new Date(endDate);
248
+ const durationMs = end.getTime() - start.getTime();
249
+ const prevEnd = new Date(start.getTime() - 1 * 24 * 60 * 60 * 1000); // day before current start
250
+ const prevStart = new Date(prevEnd.getTime() - durationMs);
251
+ const prevStartDate = prevStart.toISOString().split("T")[0];
252
+ const prevEndDate = prevEnd.toISOString().split("T")[0];
253
+
254
+ const [current, previous] = await Promise.all([
255
+ querySearchAnalytics(site_url, queryOpts),
256
+ querySearchAnalytics(site_url, { ...queryOpts, startDate: prevStartDate, endDate: prevEndDate }),
257
+ ]);
258
+
259
+ return textResult(
260
+ formatComparison(site_url, current, previous, dims, startDate, endDate, prevStartDate, prevEndDate),
261
+ );
262
+ }
263
+
264
+ const result = await querySearchAnalytics(site_url, queryOpts);
265
+ return textResult(formatPerformance(site_url, result, dims, startDate, endDate));
266
+ }
@@ -0,0 +1,247 @@
1
+ import { listSitemaps } from "../../providers/gsc.js";
2
+ import { type SearchOptions } from "./schema.js";
3
+ import { humanizeState, type InspectionSummary, inspectSingle } from "./inspection.js";
4
+ import { fetchSitemap, sampleUrls } from "./sitemap-sampling.js";
5
+ import { textResult } from "./result.js";
6
+ const COVERAGE_FILTERS = [
7
+ "not_indexed",
8
+ "server_error",
9
+ "redirect",
10
+ "soft_404",
11
+ "blocked",
12
+ "duplicate",
13
+ "discovered",
14
+ "crawled_not_indexed",
15
+ ] as const;
16
+
17
+ type CoverageFilter = (typeof COVERAGE_FILTERS)[number];
18
+
19
+ export function matchesCoverageFilter(result: InspectionSummary, filter: CoverageFilter): boolean {
20
+ if (result.error) return false;
21
+ const cs = result.coverageState.toLowerCase();
22
+ const pf = result.pageFetchState.toLowerCase();
23
+ switch (filter) {
24
+ case "not_indexed":
25
+ return result.verdict !== "PASS";
26
+ case "server_error":
27
+ return cs.includes("server error") || pf === "server_error";
28
+ case "redirect":
29
+ return cs.includes("redirect") || pf.includes("redirect");
30
+ case "soft_404":
31
+ return cs.includes("soft 404") || pf === "soft_404";
32
+ case "blocked":
33
+ return cs.includes("blocked");
34
+ case "duplicate":
35
+ return cs.includes("duplicate");
36
+ case "discovered":
37
+ return cs.includes("discovered");
38
+ case "crawled_not_indexed":
39
+ return cs.includes("crawled - currently not indexed");
40
+ default:
41
+ return false;
42
+ }
43
+ }
44
+
45
+ function filterNameForState(state: string): string | null {
46
+ const s = state.toLowerCase();
47
+ if (s.includes("server error")) return "server_error";
48
+ if (s.includes("blocked")) return "blocked";
49
+ if (s.includes("redirect")) return "redirect";
50
+ if (s.includes("soft 404")) return "soft_404";
51
+ if (s.includes("duplicate")) return "duplicate";
52
+ if (s.includes("discovered - currently not indexed")) return "discovered";
53
+ if (s.includes("crawled - currently not indexed")) return "crawled_not_indexed";
54
+ return null;
55
+ }
56
+
57
+ export function formatSampleResults(
58
+ siteUrl: string,
59
+ sitemapUrl: string,
60
+ totalUrls: number,
61
+ results: InspectionSummary[],
62
+ ): string {
63
+ const lines: string[] = [
64
+ `=== Sample Inspection: ${siteUrl} ===`,
65
+ `Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
66
+ `Sampled: ${results.length}`,
67
+ "",
68
+ ];
69
+
70
+ // Summary
71
+ const verdictCounts: Record<string, number> = {};
72
+ const coverageCounts: Record<string, number> = {};
73
+ const fetchCounts: Record<string, number> = {};
74
+ let indexed = 0;
75
+ let errors = 0;
76
+
77
+ for (const r of results) {
78
+ if (r.error) {
79
+ errors++;
80
+ continue;
81
+ }
82
+ verdictCounts[r.verdict] = (verdictCounts[r.verdict] ?? 0) + 1;
83
+ coverageCounts[r.coverageState] = (coverageCounts[r.coverageState] ?? 0) + 1;
84
+ fetchCounts[r.pageFetchState] = (fetchCounts[r.pageFetchState] ?? 0) + 1;
85
+ if (r.verdict === "PASS") indexed++;
86
+ }
87
+
88
+ const inspected = results.length - errors;
89
+ lines.push("--- Inspected sample ---", "");
90
+ lines.push(`Indexed: ${indexed}/${inspected}`);
91
+
92
+ if (indexed < inspected) {
93
+ lines.push(`Not indexed: ${inspected - indexed}/${inspected}`);
94
+ for (const [state, count] of Object.entries(coverageCounts)) {
95
+ if (state !== "Submitted and indexed" && state !== "Indexing allowed") {
96
+ lines.push(` ${state}: ${count}`);
97
+ }
98
+ }
99
+ }
100
+
101
+ // Page fetch issues
102
+ const fetchIssues = Object.entries(fetchCounts).filter(([s]) => s !== "SUCCESSFUL");
103
+ if (fetchIssues.length > 0) {
104
+ lines.push("");
105
+ lines.push("Page fetch issues:");
106
+ for (const [state, count] of fetchIssues) {
107
+ lines.push(` ${humanizeState(state)}: ${count}`);
108
+ }
109
+ }
110
+
111
+ if (errors > 0) {
112
+ lines.push(`\nInspection errors: ${errors}`);
113
+ }
114
+
115
+ // Individual results
116
+ lines.push("", "--- Details ---", "");
117
+ for (let i = 0; i < results.length; i++) {
118
+ const r = results[i];
119
+ lines.push(`${i + 1}. ${r.url}`);
120
+ if (r.error) {
121
+ lines.push(` Error: ${r.error}`);
122
+ } else {
123
+ lines.push(` Verdict: ${r.verdict}`);
124
+ lines.push(` Coverage: ${r.coverageState}`);
125
+ lines.push(` Page fetch: ${humanizeState(r.pageFetchState)}`);
126
+ if (r.robotsTxtState !== "ALLOWED") lines.push(` Robots.txt: ${humanizeState(r.robotsTxtState)}`);
127
+ if (r.indexingState !== "INDEXING_ALLOWED") lines.push(` Indexing: ${humanizeState(r.indexingState)}`);
128
+ if (r.lastCrawlTime) lines.push(` Last crawled: ${r.lastCrawlTime}`);
129
+ if (r.googleCanonical && r.googleCanonical !== r.url) {
130
+ lines.push(` Google canonical: ${r.googleCanonical}`);
131
+ }
132
+ }
133
+ lines.push("");
134
+ }
135
+
136
+ return lines.join("\n");
137
+ }
138
+
139
+ function formatCoverage(siteUrl: string, sitemapUrl: string, totalUrls: number, results: InspectionSummary[]): string {
140
+ const inspected = results.filter((r) => !r.error);
141
+ const errors = results.filter((r) => r.error);
142
+ const indexed = inspected.filter((r) => r.verdict === "PASS");
143
+ const notIndexed = inspected.filter((r) => r.verdict !== "PASS");
144
+
145
+ const lines: string[] = [
146
+ `=== Index Coverage: ${siteUrl} ===`,
147
+ `Sitemap: ${sitemapUrl} (${totalUrls.toLocaleString()} URLs)`,
148
+ `Inspected: ${results.length}`,
149
+ "",
150
+ ];
151
+
152
+ const total = inspected.length;
153
+ if (total === 0) {
154
+ lines.push("No URLs could be inspected.");
155
+ if (errors.length > 0) {
156
+ lines.push("");
157
+ for (const e of errors) lines.push(` ${e.url}: ${e.error}`);
158
+ }
159
+ return lines.join("\n");
160
+ }
161
+
162
+ // Group not-indexed by coverageState
163
+ const issueGroups: Record<string, InspectionSummary[]> = {};
164
+ for (const r of notIndexed) {
165
+ const key = r.coverageState;
166
+ if (!issueGroups[key]) issueGroups[key] = [];
167
+ issueGroups[key].push(r);
168
+ }
169
+ const sortedIssues = Object.entries(issueGroups).sort((a, b) => b[1].length - a[1].length);
170
+
171
+ // Breakdown
172
+ lines.push("--- Breakdown ---", "");
173
+ lines.push(`Indexed: ${indexed.length}/${total} (${Math.round((indexed.length / total) * 100)}%)`);
174
+ lines.push(`Not indexed: ${notIndexed.length}/${total} (${Math.round((notIndexed.length / total) * 100)}%)`);
175
+ for (const [state, urls] of sortedIssues) {
176
+ lines.push(` ${state}: ${urls.length}`);
177
+ }
178
+ if (errors.length > 0) {
179
+ lines.push(`Inspection errors: ${errors.length}`);
180
+ }
181
+
182
+ lines.push("", "These verdicts describe only the inspected sample, not site-wide coverage.");
183
+
184
+ // Example URLs per issue
185
+ if (sortedIssues.length > 0) {
186
+ lines.push("", "--- URLs by Issue ---", "");
187
+ for (const [state, urls] of sortedIssues) {
188
+ lines.push(`${state}:`);
189
+ for (const u of urls.slice(0, 3)) {
190
+ lines.push(` ${u.url}`);
191
+ if (u.lastCrawlTime) lines.push(` Last crawled: ${u.lastCrawlTime}`);
192
+ if (u.pageFetchState !== "SUCCESSFUL") lines.push(` Fetch: ${humanizeState(u.pageFetchState)}`);
193
+ }
194
+ if (urls.length > 3) lines.push(` ... and ${urls.length - 3} more in sample`);
195
+ lines.push("");
196
+ }
197
+ }
198
+
199
+ // Suggest drill-down
200
+ const filterSuggestions = sortedIssues.map(([state]) => filterNameForState(state)).filter(Boolean);
201
+ if (filterSuggestions.length > 0) {
202
+ lines.push(`Drill deeper: search(action='sample', filter='...') to investigate specific issues.`);
203
+ lines.push(` Available filters: ${filterSuggestions.join(", ")}`);
204
+ }
205
+
206
+ return lines.join("\n");
207
+ }
208
+
209
+ export async function runCoverage(options: SearchOptions) {
210
+ const { site_url, sitemap_url, sample_size, sample_strategy } = options;
211
+ if (!site_url) return textResult("Error: site_url is required for coverage.");
212
+
213
+ const count = sample_size ?? 20;
214
+ const strategy = sample_strategy ?? "spread";
215
+
216
+ // Discover sitemap URL if not provided
217
+ let resolvedSitemapUrl = sitemap_url;
218
+ if (!resolvedSitemapUrl) {
219
+ const sitemaps = await listSitemaps(site_url);
220
+ if (sitemaps.length === 0) {
221
+ return textResult(`No sitemaps found for ${site_url} in GSC. Provide a sitemap_url directly.`);
222
+ }
223
+ const nonIndex = sitemaps.find((s) => !s.isSitemapsIndex);
224
+ resolvedSitemapUrl = (nonIndex ?? sitemaps[0]).path;
225
+ }
226
+
227
+ let parsed = await fetchSitemap(resolvedSitemapUrl);
228
+
229
+ if (parsed.isSitemapIndex && parsed.childSitemaps.length > 0) {
230
+ const childUrl = parsed.childSitemaps[0];
231
+ parsed = await fetchSitemap(childUrl);
232
+ resolvedSitemapUrl = `${resolvedSitemapUrl} → ${childUrl}`;
233
+ }
234
+
235
+ if (parsed.urls.length === 0) {
236
+ return textResult(`Sitemap ${resolvedSitemapUrl} contains no URLs.`);
237
+ }
238
+
239
+ const sampled = sampleUrls(parsed.urls, count, strategy);
240
+
241
+ const results: InspectionSummary[] = [];
242
+ for (const u of sampled) {
243
+ results.push(await inspectSingle(u, site_url));
244
+ }
245
+
246
+ return textResult(formatCoverage(site_url, resolvedSitemapUrl, parsed.urls.length, results));
247
+ }
@@ -0,0 +1,129 @@
1
+ import { querySearchAnalytics, type SearchAnalyticsFilter } from "../../providers/gsc.js";
2
+ import { pacificDaysAgo } from "../../shared/dates.js";
3
+ import { type SearchOptions } from "./schema.js";
4
+ import { textResult } from "./result.js";
5
+ function extractPageText(html: string): string {
6
+ // Extract text from HTML; this does not check rendered visibility.
7
+ let text = html;
8
+ text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, " ");
9
+ text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, " ");
10
+ text = text.replace(/<[^>]+>/g, " ");
11
+ // Decode common HTML entities
12
+ text = text
13
+ .replace(/&amp;/g, "&")
14
+ .replace(/&lt;/g, "<")
15
+ .replace(/&gt;/g, ">")
16
+ .replace(/&quot;/g, '"')
17
+ .replace(/&#39;/g, "'")
18
+ .replace(/&nbsp;/g, " ");
19
+ // Normalize whitespace
20
+ text = text.replace(/\s+/g, " ").trim().toLowerCase();
21
+ return text;
22
+ }
23
+
24
+ function formatGapAnalysis(
25
+ pageUrl: string,
26
+ siteUrl: string,
27
+ queries: Array<{ query: string; clicks: number; impressions: number; ctr: number; position: number }>,
28
+ pageText: string,
29
+ ): string {
30
+ const lines: string[] = [`=== Keyword Gap Analysis: ${pageUrl} ===`, `Property: ${siteUrl}`, ""];
31
+
32
+ if (queries.length === 0) {
33
+ lines.push("No search queries found for this page.");
34
+ return lines.join("\n");
35
+ }
36
+
37
+ const onPage: typeof queries = [];
38
+ const gaps: typeof queries = [];
39
+
40
+ for (const q of queries) {
41
+ const words = q.query.toLowerCase().split(/\s+/);
42
+ const allPresent = words.every((w) => pageText.includes(w));
43
+ if (allPresent) {
44
+ onPage.push(q);
45
+ } else {
46
+ gaps.push(q);
47
+ }
48
+ }
49
+
50
+ lines.push(`Queries analyzed: ${queries.length}`);
51
+ lines.push(`On page: ${onPage.length}`);
52
+ lines.push(`Gaps (not on page): ${gaps.length}`);
53
+ lines.push("");
54
+
55
+ if (gaps.length > 0) {
56
+ lines.push("--- Keyword Gaps (queries with impressions but missing from page) ---", "");
57
+ gaps.sort((a, b) => b.impressions - a.impressions);
58
+ for (const q of gaps.slice(0, 20)) {
59
+ const ctr = (q.ctr * 100).toFixed(1);
60
+ lines.push(
61
+ ` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
62
+ );
63
+ }
64
+ if (gaps.length > 20) lines.push(` ... and ${gaps.length - 20} more`);
65
+ lines.push("");
66
+ }
67
+
68
+ if (onPage.length > 0) {
69
+ lines.push("--- Already Targeted (query found on page) ---", "");
70
+ onPage.sort((a, b) => b.impressions - a.impressions);
71
+ for (const q of onPage.slice(0, 10)) {
72
+ const ctr = (q.ctr * 100).toFixed(1);
73
+ lines.push(
74
+ ` "${q.query}" — ${q.impressions} imp, ${q.clicks} clicks, CTR ${ctr}%, pos ${q.position.toFixed(1)}`,
75
+ );
76
+ }
77
+ if (onPage.length > 10) lines.push(` ... and ${onPage.length - 10} more`);
78
+ lines.push("");
79
+ }
80
+
81
+ const totalGapImpressions = gaps.reduce((sum, q) => sum + q.impressions, 0);
82
+ const totalImpressions = queries.reduce((sum, q) => sum + q.impressions, 0);
83
+ if (totalImpressions > 0) {
84
+ const gapPct = Math.round((totalGapImpressions / totalImpressions) * 100);
85
+ lines.push(`${gapPct}% of impressions come from queries not on the page — potential content opportunity.`);
86
+ }
87
+
88
+ return lines.join("\n");
89
+ }
90
+
91
+ export async function runGaps(options: SearchOptions) {
92
+ const { site_url, url, start_date, end_date, row_limit } = options;
93
+ if (!url) return textResult("Error: url is required for gap analysis.");
94
+ if (!site_url) return textResult("Error: site_url is required for gap analysis.");
95
+
96
+ // Fetch page content and GSC queries in parallel
97
+ const [pageRes, analyticsRes] = await Promise.all([
98
+ fetch(url, {
99
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)", Accept: "text/html" },
100
+ redirect: "follow",
101
+ }),
102
+ querySearchAnalytics(site_url, {
103
+ startDate: start_date ?? pacificDaysAgo(28),
104
+ endDate: end_date ?? pacificDaysAgo(3),
105
+ dimensions: ["query"],
106
+ rowLimit: row_limit ?? 500,
107
+ dimensionFilterGroups: [
108
+ {
109
+ groupType: "and" as const,
110
+ filters: [{ dimension: "page", operator: "equals", expression: url } as SearchAnalyticsFilter],
111
+ },
112
+ ],
113
+ }),
114
+ ]);
115
+
116
+ if (!pageRes.ok) return textResult(`Error fetching ${url}: HTTP ${pageRes.status}`);
117
+ const html = await pageRes.text();
118
+ const pageText = extractPageText(html);
119
+
120
+ const queries = (analyticsRes.rows ?? []).map((row) => ({
121
+ query: row.keys[0],
122
+ clicks: row.clicks,
123
+ impressions: row.impressions,
124
+ ctr: row.ctr,
125
+ position: row.position,
126
+ }));
127
+
128
+ return textResult(formatGapAnalysis(url, site_url, queries, pageText));
129
+ }