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.
- package/README.md +20 -82
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/measurement.md +178 -0
- package/docs/opportunities.md +78 -0
- package/docs/snapshots.md +130 -0
- package/docs/usage.md +135 -0
- package/package.json +26 -29
- package/src/api/assessment.ts +315 -0
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +167 -0
- package/src/api/discover.ts +39 -0
- package/src/api/doctor.ts +35 -0
- package/src/api/evidence-schema.ts +78 -0
- package/src/api/evidence.ts +99 -0
- package/src/api/execute.ts +160 -0
- package/src/api/ga-freshness.ts +20 -0
- package/src/api/ga-realtime.ts +40 -0
- package/src/api/index.ts +5 -0
- package/src/api/opportunities.ts +317 -0
- package/src/api/report-table.ts +216 -0
- package/src/api/reports.ts +108 -0
- package/src/api/schema.ts +271 -0
- package/src/api/snapshot.ts +202 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/assessment-text.ts +55 -0
- package/src/cli.ts +217 -0
- package/src/http.ts +39 -0
- package/src/index.ts +8 -25
- package/src/mcp-server.ts +27 -0
- package/src/mcp.ts +5 -0
- package/src/opportunities-text.ts +61 -0
- package/src/providers/bing.ts +48 -0
- package/src/{lib → providers}/crux.ts +4 -9
- package/src/providers/ga.ts +114 -0
- package/src/providers/google-tokens.ts +86 -0
- package/src/providers/gsc-auth.ts +93 -0
- package/src/{lib → providers}/gsc.ts +27 -24
- package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
- package/src/shared/dates.ts +32 -0
- package/src/shared/http.ts +63 -0
- package/src/tools/ai.ts +19 -27
- package/src/tools/audit.ts +18 -98
- package/src/tools/observe.ts +28 -0
- package/src/tools/page/analyze.ts +194 -0
- package/src/tools/page/batch.ts +163 -0
- package/src/tools/page/contrast.ts +128 -0
- package/src/tools/page/links.ts +200 -0
- package/src/tools/page/metadata.ts +225 -0
- package/src/tools/page/structured-data.ts +288 -0
- package/src/tools/page/tool.ts +48 -0
- package/src/tools/search/actions.ts +56 -0
- package/src/tools/search/analytics.ts +266 -0
- package/src/tools/search/coverage.ts +247 -0
- package/src/tools/search/gaps.ts +129 -0
- package/src/tools/search/inspection.ts +160 -0
- package/src/tools/search/result.ts +3 -0
- package/src/tools/search/sample.ts +110 -0
- package/src/tools/search/schema.ts +62 -0
- package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
- package/src/tools/search/sites.ts +86 -0
- package/src/tools/search/tool.ts +11 -0
- package/src/tools/setup.ts +1 -1
- package/src/tools/speed/analyze.ts +191 -0
- package/src/tools/speed/batch.ts +265 -0
- package/src/tools/speed/crux.ts +176 -0
- package/src/tools/speed/pagespeed.ts +273 -0
- package/src/tools/speed/schema.ts +39 -0
- package/src/tools/speed/tool.ts +11 -0
- package/src/web/fetch.ts +31 -0
- package/src/web/images.ts +62 -0
- package/src/web/page-observation.ts +69 -0
- package/src/{lib → web}/robots.ts +20 -12
- package/src/web/sitemap-inventory.ts +64 -0
- package/src/web/sitemap-parser.ts +59 -0
- package/src/lib/auth.ts +0 -187
- package/src/tools/page.ts +0 -1241
- package/src/tools/search.ts +0 -1118
- package/src/tools/speed.ts +0 -956
|
@@ -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(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, "'")
|
|
18
|
+
.replace(/ /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
|
+
}
|