@gscdump/cli 4.4.0 → 4.6.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.
@@ -7,7 +7,7 @@ import { coverageWarning, renderAnalysis } from "../render/analysis.mjs";
7
7
  import { defineCommand } from "citty";
8
8
  import { DEFAULT_FETCH_BUDGET, MAX_FETCH_BUDGET } from "@gscdump/engine/analysis-types";
9
9
  import { unwrapResult } from "gscdump/result";
10
- import { MOVERS_SORT_METRICS } from "@gscdump/analysis";
10
+ import { MOVERS_SORT_METRICS, TRAJECTORY_DEFAULT_DAYS } from "@gscdump/analysis";
11
11
  import { defaultAnalyzerRegistry } from "@gscdump/analysis/registry";
12
12
  const ANALYSIS_TOOLS = defaultAnalyzerRegistry.listAnalyzerIds();
13
13
  const COMPARISON_ARGS = {
@@ -85,6 +85,16 @@ function buildParams(tool, args) {
85
85
  }
86
86
  function withWindow(tool, params, args, anchor) {
87
87
  const optional = (key) => args[key] ? String(args[key]) : void 0;
88
+ if (tool === "trajectory" && !optional("period") && !optional("start")) {
89
+ const endDate = optional("end") ?? anchor;
90
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate) || Number.isNaN(Date.parse(`${endDate}T00:00:00Z`))) throw new Error(`Invalid --end "${endDate}". Use YYYY-MM-DD.`);
91
+ const start = /* @__PURE__ */ new Date(Date.parse(`${endDate}T00:00:00Z`) - (TRAJECTORY_DEFAULT_DAYS - 1) * 864e5);
92
+ return {
93
+ ...params,
94
+ startDate: start.toISOString().slice(0, 10),
95
+ endDate
96
+ };
97
+ }
88
98
  const window = unwrapResult(parseWindowFlags({
89
99
  period: optional("period"),
90
100
  start: optional("start"),
@@ -37,6 +37,8 @@ const HOSTED_COMMANDS = [
37
37
  "sitemaps lastmod",
38
38
  "sitemaps export",
39
39
  "indexing urls",
40
+ "indexing summary",
41
+ "indexing watch",
40
42
  "bing login",
41
43
  "bing sites",
42
44
  "bing status",
@@ -0,0 +1,120 @@
1
+ import { terminalOutputOptions } from "../render/terminal.mjs";
2
+ import { applyOutputMode, logger, parseIntegerOption } from "../utils.mjs";
3
+ import { renderTable } from "../render/layout.mjs";
4
+ import { HOSTED_ARGS, resolveHostedSite } from "../hosted-site.mjs";
5
+ import { defineCommand } from "citty";
6
+ import { COVERAGE_LADDER } from "@gscdump/contracts";
7
+ const MAX_DAYS = 90;
8
+ function ladderRow(point) {
9
+ const states = point.coverageStates;
10
+ if (!states || states._tag === "not_counted") return {
11
+ date: point.date,
12
+ unknown_to_google: "not counted",
13
+ discovered_not_indexed: "",
14
+ crawled_not_indexed: "",
15
+ indexed: "",
16
+ capturedAt: ""
17
+ };
18
+ return {
19
+ date: point.date,
20
+ ...Object.fromEntries(COVERAGE_LADDER.map((tag) => [tag, states.counts[tag]])),
21
+ capturedAt: states.capturedAt.slice(0, 16).replace("T", " ")
22
+ };
23
+ }
24
+ function captureLines(capture) {
25
+ if (!capture) return ["This host does not report when it counted the verdicts."];
26
+ if (capture._tag === "empty") return ["No URL Inspection verdicts are stored for this Site."];
27
+ const scope = {
28
+ sitemap_members: "URLs in the Site's live sitemaps",
29
+ inspected_urls: "every inspected URL, because sitemap membership was not available",
30
+ unrecorded: "an unrecorded scope"
31
+ }[capture.scope];
32
+ const lines = [
33
+ `Counted ${capture.capturedAt} from stored URL Inspection verdicts (${capture.source}).`,
34
+ `The counts cover ${scope}.`,
35
+ `Verdicts inspected between ${capture.oldestVerdictAt ?? "unknown"} and ${capture.newestVerdictAt ?? "unknown"}.`
36
+ ];
37
+ if (capture.freshness._tag === "measured") {
38
+ const { verdicts, olderThan7dPercent, olderThan30dPercent } = capture.freshness;
39
+ lines.push(`Of ${verdicts} verdicts, ${olderThan7dPercent}% are older than 7 days and ${olderThan30dPercent}% are older than 30 days.`);
40
+ }
41
+ return lines;
42
+ }
43
+ const indexingSummaryCommand = defineCommand({
44
+ meta: {
45
+ name: "summary",
46
+ description: "Show the coverage ladder per day from stored URL Inspection verdicts, with capture time (hosted)"
47
+ },
48
+ args: {
49
+ ...HOSTED_ARGS,
50
+ days: {
51
+ type: "string",
52
+ default: "28",
53
+ description: `Trend days, 1 to ${MAX_DAYS}`
54
+ },
55
+ json: {
56
+ type: "boolean",
57
+ default: false,
58
+ description: "Output as JSON"
59
+ },
60
+ quiet: {
61
+ type: "boolean",
62
+ alias: "q",
63
+ default: false,
64
+ description: "Suppress info output"
65
+ }
66
+ },
67
+ async run({ args }) {
68
+ const days = parseIntegerOption(args.days, "--days") ?? 28;
69
+ if (days < 1 || days > MAX_DAYS) throw new Error(`--days must be between 1 and ${MAX_DAYS}.`);
70
+ const { json } = applyOutputMode(args);
71
+ const { client, site } = await resolveHostedSite(args, {
72
+ name: "indexing summary",
73
+ localAlternative: "run `gscdump inspect --site <site>` and read the saved results with `gscdump entities`"
74
+ });
75
+ const { data } = await client.getSiteIndexing({
76
+ params: { siteId: site.siteId },
77
+ query: { days }
78
+ });
79
+ if (json) {
80
+ console.log(JSON.stringify(data, null, 2));
81
+ return;
82
+ }
83
+ const options = terminalOutputOptions();
84
+ if (data.trend.length === 0) logger.info(`No daily coverage counts are stored for ${site.siteUrl}.`);
85
+ else for (const line of renderTable(data.trend.map(ladderRow), [
86
+ {
87
+ key: "date",
88
+ label: "Date",
89
+ numeric: true
90
+ },
91
+ {
92
+ key: "unknown_to_google",
93
+ label: "Unknown",
94
+ numeric: true
95
+ },
96
+ {
97
+ key: "discovered_not_indexed",
98
+ label: "Discovered",
99
+ numeric: true
100
+ },
101
+ {
102
+ key: "crawled_not_indexed",
103
+ label: "Crawled",
104
+ numeric: true
105
+ },
106
+ {
107
+ key: "indexed",
108
+ label: "Indexed",
109
+ numeric: true
110
+ },
111
+ {
112
+ key: "capturedAt",
113
+ label: "Counted (UTC)"
114
+ }
115
+ ], options)) console.log(line);
116
+ for (const line of captureLines(data.capture)) console.log(line);
117
+ logger.info("These counts are stored URL Inspection verdicts. They are not Google's live index or the Search Console Page indexing report.");
118
+ }
119
+ });
120
+ export { indexingSummaryCommand };
@@ -0,0 +1,196 @@
1
+ import { terminalOutputOptions } from "../render/terminal.mjs";
2
+ import { applyOutputMode, logger, readUrlList } from "../utils.mjs";
3
+ import { renderTable } from "../render/layout.mjs";
4
+ import { HOSTED_ARGS, resolveHostedSite } from "../hosted-site.mjs";
5
+ import { defineCommand } from "citty";
6
+ import { WATCHED_URL_LIMIT } from "@gscdump/contracts";
7
+ const OUTPUT = {
8
+ json: {
9
+ type: "boolean",
10
+ default: false,
11
+ description: "Output as JSON"
12
+ },
13
+ quiet: {
14
+ type: "boolean",
15
+ alias: "q",
16
+ default: false,
17
+ description: "Suppress info output"
18
+ }
19
+ };
20
+ const URL_ARGS = {
21
+ urls: {
22
+ type: "positional",
23
+ required: false,
24
+ description: "URLs on the Site host (or use --file or stdin)"
25
+ },
26
+ file: {
27
+ type: "string",
28
+ alias: "f",
29
+ description: "File with URLs (one per line)"
30
+ }
31
+ };
32
+ const LOCAL_ALTERNATIVE = "run `gscdump inspect --site <site> <url...>` on your own schedule";
33
+ function watchedRow(entry) {
34
+ const latest = entry.checkpoints[0];
35
+ return {
36
+ url: entry.url,
37
+ coverageState: latest?.coverageState ?? "awaiting inspection",
38
+ checkedAt: latest?.checkedAt.slice(0, 10) ?? "never",
39
+ dueAt: entry.dueAt.slice(0, 10)
40
+ };
41
+ }
42
+ const SKIP_HINTS = { inspection_disabled: "gscdump never inspects this Site, so a Watched URL gets no Checkpoint. To use Watched URLs, turn on URL Inspection in the Site settings on gscdump.com." };
43
+ function printChange(verb, result, siteUrl) {
44
+ for (const url of result.changed) console.log(`${verb}: ${url}`);
45
+ for (const url of result.unchanged) console.log(`${verb === "Added" ? "Already watched" : "Not watched"}: ${url}`);
46
+ for (const { url, reason } of result.skipped) console.log(`Skipped (${reason}): ${url}`);
47
+ for (const hint of new Set(result.skipped.map(({ reason }) => SKIP_HINTS[reason]).filter(Boolean))) console.log(hint);
48
+ logger.info(`${siteUrl} has ${result.total} of ${result.limit} Watched URLs.`);
49
+ }
50
+ async function readUrls(args) {
51
+ const urls = await readUrlList({
52
+ file: args.file,
53
+ positionals: args._
54
+ });
55
+ if (urls.length === 0) throw new Error("No URLs provided. Pass URLs as arguments, --file, or stdin.");
56
+ const relative = urls.find((url) => !URL.canParse(url));
57
+ if (relative) throw new Error(`Not an absolute URL: ${relative}. Pass full URLs such as https://example.com/guide.`);
58
+ return [...new Set(urls)];
59
+ }
60
+ async function changeInChunks(urls, send) {
61
+ const merged = {
62
+ changed: [],
63
+ unchanged: [],
64
+ skipped: [],
65
+ total: 0,
66
+ limit: WATCHED_URL_LIMIT
67
+ };
68
+ for (let start = 0; start < urls.length; start += WATCHED_URL_LIMIT) {
69
+ const result = await send(urls.slice(start, start + WATCHED_URL_LIMIT));
70
+ merged.changed.push(...result.changed);
71
+ merged.unchanged.push(...result.unchanged);
72
+ merged.skipped.push(...result.skipped);
73
+ merged.total = result.total;
74
+ merged.limit = result.limit;
75
+ }
76
+ return merged;
77
+ }
78
+ const listCommand = defineCommand({
79
+ meta: {
80
+ name: "list",
81
+ description: "List Watched URLs with the latest Checkpoint of each (hosted)"
82
+ },
83
+ args: {
84
+ ...HOSTED_ARGS,
85
+ ...OUTPUT
86
+ },
87
+ async run({ args }) {
88
+ const { json } = applyOutputMode(args);
89
+ const { client, site } = await resolveHostedSite(args, {
90
+ name: "indexing watch list",
91
+ localAlternative: LOCAL_ALTERNATIVE
92
+ });
93
+ const { data } = await client.listSiteWatchedUrls({ params: { siteId: site.siteId } });
94
+ if (json) {
95
+ console.log(JSON.stringify(data, null, 2));
96
+ return;
97
+ }
98
+ if (data.watched.length === 0) {
99
+ logger.info(`${site.siteUrl} has no Watched URLs.`);
100
+ return;
101
+ }
102
+ for (const line of renderTable(data.watched.map(watchedRow), [
103
+ {
104
+ key: "url",
105
+ label: "URL"
106
+ },
107
+ {
108
+ key: "coverageState",
109
+ label: "Coverage state"
110
+ },
111
+ {
112
+ key: "checkedAt",
113
+ label: "Checked"
114
+ },
115
+ {
116
+ key: "dueAt",
117
+ label: "Due"
118
+ }
119
+ ], terminalOutputOptions())) console.log(line);
120
+ logger.info(`${data.watched.length} of ${data.limit} Watched URLs. gscdump inspects each one every ${data.cadenceDays} days.`);
121
+ }
122
+ });
123
+ const addCommand = defineCommand({
124
+ meta: {
125
+ name: "add",
126
+ description: "Add Watched URLs, inspected every 7 days before other URLs (hosted; spends URL Inspections)"
127
+ },
128
+ args: {
129
+ ...HOSTED_ARGS,
130
+ ...URL_ARGS,
131
+ ...OUTPUT
132
+ },
133
+ async run({ args }) {
134
+ const { json } = applyOutputMode(args);
135
+ const urls = await readUrls(args);
136
+ const { client, site } = await resolveHostedSite({
137
+ ...args,
138
+ _: []
139
+ }, {
140
+ name: "indexing watch add",
141
+ localAlternative: LOCAL_ALTERNATIVE
142
+ });
143
+ const data = await changeInChunks(urls, async (chunk) => (await client.addSiteWatchedUrls({
144
+ params: { siteId: site.siteId },
145
+ body: { urls: chunk }
146
+ })).data);
147
+ if (json) {
148
+ console.log(JSON.stringify(data, null, 2));
149
+ return;
150
+ }
151
+ printChange("Added", data, site.siteUrl);
152
+ }
153
+ });
154
+ const removeCommand = defineCommand({
155
+ meta: {
156
+ name: "remove",
157
+ description: "Remove Watched URLs and their Checkpoints (hosted)"
158
+ },
159
+ args: {
160
+ ...HOSTED_ARGS,
161
+ ...URL_ARGS,
162
+ ...OUTPUT
163
+ },
164
+ async run({ args }) {
165
+ const { json } = applyOutputMode(args);
166
+ const urls = await readUrls(args);
167
+ const { client, site } = await resolveHostedSite({
168
+ ...args,
169
+ _: []
170
+ }, {
171
+ name: "indexing watch remove",
172
+ localAlternative: LOCAL_ALTERNATIVE
173
+ });
174
+ const data = await changeInChunks(urls, async (chunk) => (await client.removeSiteWatchedUrls({
175
+ params: { siteId: site.siteId },
176
+ body: { urls: chunk }
177
+ })).data);
178
+ if (json) {
179
+ console.log(JSON.stringify(data, null, 2));
180
+ return;
181
+ }
182
+ printChange("Removed", data, site.siteUrl);
183
+ }
184
+ });
185
+ const indexingWatchCommand = defineCommand({
186
+ meta: {
187
+ name: "watch",
188
+ description: "Manage Watched URLs: a Site's URLs inspected on a fixed 7 day cadence (hosted)"
189
+ },
190
+ subCommands: {
191
+ list: listCommand,
192
+ add: addCommand,
193
+ remove: removeCommand
194
+ }
195
+ });
196
+ export { indexingWatchCommand };
@@ -2,7 +2,9 @@ import { indexingCommandMeta } from "../command-meta.mjs";
2
2
  import { OUTPUT_ARGS, applyOutputMode, logger, parseIntegerOption, readUrlList } from "../utils.mjs";
3
3
  import { createCommandContext } from "../context.mjs";
4
4
  import { loadSitemapUrls } from "../sitemap.mjs";
5
+ import { indexingSummaryCommand } from "./indexing-summary.mjs";
5
6
  import { indexingUrlsCommand } from "./indexing-urls.mjs";
7
+ import { indexingWatchCommand } from "./indexing-watch.mjs";
6
8
  import process from "node:process";
7
9
  import { defineCommand } from "citty";
8
10
  import { batchRequestIndexing, getIndexingMetadata, requestIndexing, runSequentialBatch } from "gscdump/indexing";
@@ -318,7 +320,9 @@ const indexingCommand = defineCommand({
318
320
  "batch": batchCommand,
319
321
  "batch-status": batchStatusCommand,
320
322
  "quota": quotaCommand,
321
- "urls": indexingUrlsCommand
323
+ "urls": indexingUrlsCommand,
324
+ "summary": indexingSummaryCommand,
325
+ "watch": indexingWatchCommand
322
326
  }
323
327
  });
324
328
  export { indexingCommand };
package/dist/package.mjs CHANGED
@@ -1,2 +1,2 @@
1
- var version = "4.4.0";
1
+ var version = "4.6.0";
2
2
  export { version };
@@ -29,6 +29,28 @@ function coverageWarning(coverage) {
29
29
  if (coverage?.kind !== "truncated") return void 0;
30
30
  return `! Partial data: a fetch stopped at ${coverage.fetched.toLocaleString("en-US")} rows. To read more rows, pass --fetch-budget (max ${MAX_FETCH_BUDGET}).`;
31
31
  }
32
+ function renderTrajectory(result, options) {
33
+ const window = (value) => {
34
+ const w = asRecord(value);
35
+ return w.startDate === void 0 ? "n/a" : `${w.startDate} to ${w.endDate}: ${formatMetric("clicks", finite(w.clicks) ?? 0)} clicks, ${formatMetric("impressions", finite(w.impressions) ?? 0)} impressions`;
36
+ };
37
+ const classification = asRecord(result.classification);
38
+ const basis = asRecord(result.basis);
39
+ const ratio = finite(result.latestToPeakRatio);
40
+ const lines = [
41
+ `Shape: ${String(classification._tag)}`,
42
+ `First data: ${String(result.firstDataDate ?? "n/a")}`,
43
+ `Peak 7 days: ${window(result.peak)}`,
44
+ `Latest 7 days: ${window(result.latest)}`,
45
+ `Latest to peak (${String(basis._tag)}): ${ratio === null ? "n/a" : `${(ratio * 100).toFixed(1)}%`}`,
46
+ ...typeof basis.note === "string" ? [basis.note] : []
47
+ ];
48
+ for (const caveat of Array.isArray(result.caveats) ? result.caveats : []) {
49
+ const note = asRecord(caveat).note;
50
+ if (typeof note === "string") lines.push(`! ${note}`);
51
+ }
52
+ return lines.flatMap((line) => textLines(line, options));
53
+ }
32
54
  function renderAnalysis(result, context, options) {
33
55
  const rows = result.results;
34
56
  const meta = result.meta;
@@ -97,7 +119,8 @@ function renderAnalysis(result, context, options) {
97
119
  })), context.metric ?? "clicks", options));
98
120
  if (meta.insufficientData === true || values.size < 12) lines.push(...textLines(`! ${values.size} of 12 months`, options, "warning"));
99
121
  if (months.some((month) => partial.has(month))) lines.push(...textLines("* partial month", options, "muted"));
100
- } else lines.push(...renderTable(rows, columnsFor(rows), options));
122
+ } else if (context.id === "trajectory") lines.push(...renderTrajectory(asRecord(rows[0]), options));
123
+ else lines.push(...renderTable(rows, columnsFor(rows), options));
101
124
  const total = finite(meta.total);
102
125
  if (total !== null && total > rows.length) lines.push("", ...textLines(`${rows.length} of ${formatMetric("clicks", total)} rows`, options, "muted"));
103
126
  return lines.join("\n");
package/dist/utils.mjs CHANGED
@@ -178,7 +178,7 @@ function toCSV(data, columns) {
178
178
  return [columns.join(","), ...data.map((row) => columns.map((col) => {
179
179
  const val = row[col];
180
180
  if (val === null || val === void 0) return "";
181
- const str = String(val);
181
+ const str = typeof val === "object" ? JSON.stringify(val) : String(val);
182
182
  return str.includes(",") || str.includes("\"") || str.includes("\n") ? `"${str.replace(/"/g, "\"\"")}"` : str;
183
183
  }).join(","))].join("\n");
184
184
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/cli",
3
3
  "type": "module",
4
- "version": "4.4.0",
4
+ "version": "4.6.0",
5
5
  "description": "CLI for Google Search Console and Bing with hosted or local authentication, data exports, and an MCP server",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -44,16 +44,16 @@
44
44
  "dependencies": {
45
45
  "@clack/prompts": "^1.8.1",
46
46
  "@duckdb/node-api": "1.5.5-r.5",
47
- "@gscdump/analysis": "^4.4.0",
48
- "@gscdump/contracts": "^4.4.0",
49
- "@gscdump/engine": "^4.4.0",
50
- "@gscdump/engine-gsc-api": "^4.4.0",
51
- "@gscdump/sdk": "^4.4.0",
47
+ "@gscdump/analysis": "^4.6.0",
48
+ "@gscdump/contracts": "^4.6.0",
49
+ "@gscdump/engine": "^4.6.0",
50
+ "@gscdump/engine-gsc-api": "^4.6.0",
51
+ "@gscdump/sdk": "^4.6.0",
52
52
  "@modelcontextprotocol/sdk": "^1.30.1",
53
53
  "citty": "^0.2.2",
54
54
  "consola": "^3.4.2",
55
55
  "google-auth-library": "^11.1.0",
56
- "gscdump": "^4.4.0",
56
+ "gscdump": "^4.6.0",
57
57
  "ofetch": "^1.5.1",
58
58
  "open": "^11.0.4",
59
59
  "proper-lockfile": "^4.1.2",
@@ -67,7 +67,8 @@ gscdump query --site example.com -d page -f json
67
67
  ```
68
68
 
69
69
  Hosted mode can run only these commands: `sites`, `query`, `sitemaps current`, `sitemaps history`, `sitemaps membership`,
70
- `sitemaps lastmod`, `sitemaps export`, `indexing urls`, and the `bing` commands `login --site`, `sites`, `status`, `dump`, `inspect`, and `verify`.
70
+ `sitemaps lastmod`, `sitemaps export`, `indexing urls`, `indexing summary`, `indexing watch list|add|remove`,
71
+ and the `bing` commands `login --site`, `sites`, `status`, `dump`, `inspect`, and `verify`.
71
72
  Every other command calls Google, so it needs Local mode: `sync`, `inspect`, `query --live`, `analyze --live`, `report --live`,
72
73
  `sites add|get|delete|verify*`, `sitemaps list|get|submit|delete`, `indexing submit|remove|status|batch`, `entities`, and `mcp`.
73
74
  In Hosted mode these commands stop with this error: `This command calls Google, so it needs Local mode.`
@@ -93,7 +94,7 @@ gscdump bing dump --site https://example.com/ --out ./bing-export
93
94
 
94
95
  Hosted Bing commands use the API's access rules.
95
96
  Hosted connection verification uses `bing verify --site s_SITE_ID`.
96
- Hosted sitemap reads and `indexing urls` need Hosted mode. Their `--site` takes a Site URL, such as `example.com`.
97
+ Hosted sitemap reads and the hosted `indexing` commands need Hosted mode. Their `--site` takes a Site URL, such as `example.com`.
97
98
 
98
99
  ## Data boundaries
99
100
 
@@ -236,6 +237,8 @@ Do not rewrite rows, estimate metrics, or add manually calculated totals.
236
237
  | `gscdump inspect <url...>` | URL Inspection with Indexing Evidence, saved to the Store |
237
238
  | `gscdump sitemaps` | List, submit, delete, and probe sitemaps |
238
239
  | `gscdump indexing` | Indexing API notifications and quota; hosted URL Inspection results |
240
+ | `gscdump indexing summary` | Hosted: the coverage ladder per day, with the time gscdump counted the verdicts |
241
+ | `gscdump indexing watch` | Hosted: list, add, and remove Watched URLs and read their Checkpoints |
239
242
  | `gscdump dump` | Export Store tables, inspections, sitemaps, and Bing data as Parquet, CSV, JSON, NDJSON, SQLite, or DuckDB |
240
243
  | `gscdump store` | Store stats, compaction, garbage collection, resets |
241
244
  | `gscdump entities` | Read saved inspections; snapshot Indexing API metadata |
@@ -393,6 +396,10 @@ gscdump analyze striking-distance --site example.com --json
393
396
  - SQL-only Analyzers need Store rows. `--live` runs row-based Analyzers
394
397
  against Google.
395
398
  - Results name candidates for review. They do not prove why traffic changed.
399
+ - On a traffic collapse, run `gscdump analyze trajectory --site example.com --json` first. It reads the whole
400
+ record, 486 days by default. Quote `classification._tag`, such as `launch-honeymoon-then-cliff` or `sudden-drop`,
401
+ with its dates, and quote `basis`. `basis._tag` is `clicks` when impressions cannot carry the read, for example
402
+ when the peak falls in the period Search Console over-counted impressions, which ended 2026-04-27.
396
403
 
397
404
  ## Inspect and index
398
405
 
@@ -421,6 +428,66 @@ gscdump indexing urls --site example.com --status not_indexed --all --format csv
421
428
  - Pages hold 100 rows by default and 500 at most. Use `--offset` for the next page, or `--all` for every page.
422
429
  - With local authentication, the command fails. Pipe `gscdump sitemaps urls <sitemap-url>` into `gscdump inspect --site <site>` instead.
423
430
 
431
+ ## Read the coverage ladder (hosted)
432
+
433
+ ```sh
434
+ gscdump indexing summary --site example.com --json
435
+ gscdump indexing summary --site example.com --days 90 --json
436
+ ```
437
+
438
+ The command reads saved URL Inspection verdicts. It spends no inspection quota.
439
+ Each day in `trend` has `coverageStates`:
440
+
441
+ - `counted` has `counts`, one URL count per coverage state, and `capturedAt`, the time gscdump counted them.
442
+ - `not_counted` means gscdump stored the day before it counted coverage states. That day has no ladder. Say so.
443
+
444
+ Read the 4 ladder states in `counts`, lowest rung first. Name each state with these words:
445
+
446
+ | Key | Name | Meaning |
447
+ | --- | --- | --- |
448
+ | `unknown_to_google` | unknown | Google has no record of the URL |
449
+ | `discovered_not_indexed` | discovered | Google queued the URL and has not crawled it |
450
+ | `crawled_not_indexed` | crawled | Google crawled the URL and left it out of the index |
451
+ | `indexed` | indexed | Google indexed the URL |
452
+
453
+ The other keys in `counts` are exclusions, such as `noindex`, `not_found`, and `redirect`.
454
+ `unrecognized` counts coverage text gscdump cannot map yet. `not_reported` counts results with no coverage text.
455
+ A newer host can add keys to `counts`. Treat a key you do not know as its own state. Treat a missing key as 0.
456
+
457
+ Rules for every answer:
458
+
459
+ - `capture.source` is `stored` for counts gscdump saved on its daily run, and `live` for counts made during the request.
460
+ - Quote `capturedAt` with every count. Also quote `capture.oldestVerdictAt` and the `capture.freshness` percents: `olderThan7dPercent` and `olderThan30dPercent`.
461
+ - If `capture._tag` is `empty`, gscdump holds no verdicts for the Site. Report no ladder.
462
+ - The counts are stored URL Inspection verdicts. They are not Google's live index.
463
+ They are also not the Search Console Page indexing report. The two can differ a lot, and the Page indexing report can list many more URLs.
464
+ - `olderThan7d` and `olderThan30d` count verdicts inspected more than 7 times 24 hours and 30 times 24 hours before `capturedAt`. They are not calendar days.
465
+ - A verdict can be months older than its capture. Unchanged URLs are rechecked less often over time. Say when most verdicts are old.
466
+ - A move between rungs shows in the trend. Compare two `counted` days and name the dates.
467
+ - `capture.scope` says which URLs the counts cover. `sitemap_members` means every counted URL is in a live sitemap.
468
+ `inspected_urls` means sitemap membership was not available, so the counts can include URLs outside every sitemap.
469
+ - If `unknown_to_google` URLs are already in a sitemap, the sitemap is not the gap. Do not tell the owner to add them to it.
470
+ Read the sitemap's last download with `gscdump sitemaps current --site SITE --json`. If Google downloaded it, say that Google read the sitemap and did not take the URLs.
471
+ Check one URL's membership with `gscdump sitemaps membership`.
472
+ - Name no cause beyond what the evidence shows.
473
+ - Cite the command behind each number.
474
+
475
+ ## Watch URLs (hosted)
476
+
477
+ ```sh
478
+ gscdump indexing watch add --site example.com https://example.com/guide https://example.com/pricing
479
+ gscdump indexing watch list --site example.com --json
480
+ gscdump indexing watch remove --site example.com https://example.com/pricing
481
+ ```
482
+
483
+ - A Site can hold 50 Watched URLs. `add` and `remove` send 50 URLs per request, so a longer list works. Every URL must be absolute. `add` fails before any request if one is relative.
484
+ - If `add` skips a URL with `inspection_disabled`, the Site has URL Inspection off. gscdump never inspects it, so the URL gets no Checkpoint. Tell the owner to turn on URL Inspection in the Site settings.
485
+ - gscdump inspects each Watched URL every 7 days, before other scheduled URLs. It never backs off.
486
+ - Each scheduled inspection spends the Site's daily URL Inspection budget and the URL Inspections meter. Get consent before `add`.
487
+ - Each scheduled inspection stores one Checkpoint. `watch list --json` returns Checkpoints newest first, with `checkedAt`, the `coverageState` key, and Google's text.
488
+ - Compare Checkpoints by date to see when a URL moves up the ladder. A new Watched URL has no Checkpoint until its first inspection.
489
+ - `remove` deletes the URL's Checkpoints. The saved URL Inspection results stay.
490
+
424
491
  ## Report a papercut
425
492
 
426
493
  If CLI behavior blocks or slows your work, report it once per distinct
@@ -453,7 +520,9 @@ the failure and continue. Never retry an uncertain submission.
453
520
  - **Get consent before a mutation.** `sites add`, `sites delete`,
454
521
  `sites verify`, `sitemaps submit`, `sitemaps delete`, `indexing submit`,
455
522
  `indexing remove`, `store reset`, and `store rm-site` change Google or
456
- delete local data. `--yes` is consent you borrow from the user.
523
+ delete local data. `indexing watch add` spends URL Inspections every week,
524
+ and `indexing watch remove` deletes Checkpoints. `--yes` is consent you
525
+ borrow from the user.
457
526
  - **Never loop unattended.** One `sync` per Site per task. Inspection batches
458
527
  spend a daily pool. Use `--dry-run` and `--explain` to plan first.
459
528
  - **Report the result as the CLI gave it.** Zero clicks with impressions means rows exist with no clicks.