@gscdump/cli 1.5.4 → 2.0.1

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 CHANGED
@@ -47,14 +47,14 @@ gscdump mcp
47
47
  | `sites [--owner-only] [--with-sitemaps]` | List available GSC sites |
48
48
  | `sites add <url>` / `sites delete <url> [--yes]` | Register / remove a property in Search Console (add registers in unverified state) |
49
49
  | `sites verify-token <url> [--method]` / `sites verify <url> [--method]` | Get a verification token, then trigger ownership verification (META/FILE/DNS_TXT/DNS_CNAME/ANALYTICS/TAG_MANAGER) |
50
- | `sitemaps` | Sitemap CRUD (`list [--pending] [--errored]`, `get`, `submit`, `delete`) |
50
+ | `sitemaps` | GSC CRUD, explicit live probes, and hosted canonical reads (`current`, `history`, `membership`, `lastmod`, `export`) |
51
51
  | `inspect <url>` / `inspect batch [--concurrency]` | URL inspection (single URL or batch from file/stdin); renders index status, rich results, AMP, mobile usability |
52
52
  | `indexing` | Notify Google about URL changes (`submit`, `remove`, `status`, `batch [--concurrency] [--yes]`); supports `--retries` |
53
53
  | `sync` | Sync GSC data to the local Parquet store; `--retry-failed`, `--dry-run` |
54
54
  | `query` | Run a search analytics query (local store by default; `--live` hits GSC API). Filters: `--query`, `--page`, `--country`, `--device`, `--search-appearance`, `--type`, `--data-state`, `--aggregation-type`. `--explain` previews the request body; `--output -` writes to stdout. |
55
55
  | `dump` | Export from the store to a directory (`--format parquet\|json\|ndjson\|csv`, `--tables`, `--all-sites`) |
56
56
  | `analyze <tool>` | Run an SEO analyzer against the store (`--live` for row-based against fresh API) |
57
- | `entities` | Snapshot URL inspections / sitemaps / indexing metadata into the local entity store |
57
+ | `entities` | Snapshot URL inspections and indexing metadata into the local entity store |
58
58
  | `store stats` | Show row/byte counts per table and on-disk footprint |
59
59
  | `store compact` | Roll daily partitions older than N days into monthly files (`--dry-run`) |
60
60
  | `store gc` | Delete orphaned objects past the grace window (`--dry-run`) |
@@ -4,7 +4,7 @@ import process from "node:process";
4
4
  import { defineCommand } from "citty";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { Buffer } from "node:buffer";
7
- import { createIndexingMetadataStore, createInspectionStore, createSitemapReadStore, createSitemapStore } from "@gscdump/engine/entities";
7
+ import { createIndexingMetadataStore, createInspectionStore } from "@gscdump/engine/entities";
8
8
  const INSPECTION_QPD_PER_PROPERTY = 2e3;
9
9
  const INDEXING_NOT_FOUND_RE = /\b404\b|NOT_FOUND/i;
10
10
  const INSPECTION_HISTORY_LOOKBACK_MONTHS = 24;
@@ -27,390 +27,250 @@ async function readUrlList(opts) {
27
27
  for await (const chunk of process.stdin) chunks.push(chunk);
28
28
  return Buffer.concat(chunks).toString("utf8").split("\n").map((l) => l.trim()).filter(Boolean);
29
29
  }
30
- const inspectSubCommand = defineCommand({
31
- meta: {
32
- name: "inspect",
33
- description: "Run URL Inspection for a list of URLs and persist results to the local entity store"
34
- },
35
- args: {
36
- site: {
37
- type: "string",
38
- alias: "s",
39
- description: "Site URL (e.g., sc-domain:example.com); defaults to config.defaultSite or prompt"
40
- },
41
- file: {
42
- type: "string",
43
- alias: "f",
44
- description: "Path to a file with one URL per line. If omitted, reads from stdin."
45
- },
46
- limit: {
47
- type: "string",
48
- description: `Max URLs to inspect this run (default: ${INSPECTION_QPD_PER_PROPERTY}, the per-property GSC daily quota)`
49
- },
50
- concurrency: {
51
- type: "string",
52
- alias: "c",
53
- default: "4",
54
- description: "Concurrent in-flight inspect calls (default: 4)"
55
- },
56
- ...OUTPUT_ARGS
57
- },
58
- async run({ args }) {
59
- const { json, quiet } = applyOutputMode(args);
60
- const ctx = await createCommandContext({
61
- needsAuth: true,
62
- needsStore: true
63
- });
64
- const client = ctx.client;
65
- const store = ctx.store;
66
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
67
- const limit = args.limit ? Number.parseInt(String(args.limit), 10) : INSPECTION_QPD_PER_PROPERTY;
68
- const concurrency = Math.max(1, Number.parseInt(String(args.concurrency), 10) || 4);
69
- const urls = (await readUrlList({ file: args.file ? String(args.file) : void 0 })).slice(0, limit);
70
- if (urls.length === 0) {
71
- logger.warn("No URLs to inspect.");
72
- return;
73
- }
74
- if (urls.length === limit && limit < INSPECTION_QPD_PER_PROPERTY) logger.info(`Capping at --limit ${limit}`);
75
- if (urls.length === INSPECTION_QPD_PER_PROPERTY) logger.info(`Hit per-property daily inspection quota (${INSPECTION_QPD_PER_PROPERTY}); remaining URLs will be queued for tomorrow.`);
76
- const inspector = createInspectionStore({ dataSource: store.dataSource });
77
- let completed = 0;
78
- let failed = 0;
79
- const records = [];
80
- const failures = [];
81
- await runWithConcurrency(urls, concurrency, async (url) => {
82
- const result = await client.inspect(siteUrl, url).catch((err) => err);
83
- if (result instanceof Error) {
84
- failed++;
85
- failures.push({
86
- url,
87
- error: result.message
88
- });
89
- } else {
90
- const ix = result.inspectionResult;
91
- const indexStatus = ix?.indexStatusResult;
92
- records.push({
93
- url,
94
- inspectedAt: (/* @__PURE__ */ new Date()).toISOString(),
95
- indexStatus: indexStatus?.verdict ?? void 0,
96
- lastCrawlTime: indexStatus?.lastCrawlTime ?? void 0,
97
- googleCanonical: indexStatus?.googleCanonical ?? void 0,
98
- userCanonical: indexStatus?.userCanonical ?? void 0,
99
- coverageState: indexStatus?.coverageState ?? void 0,
100
- robotsTxtState: indexStatus?.robotsTxtState ?? void 0,
101
- indexingState: indexStatus?.indexingState ?? void 0,
102
- pageFetchState: indexStatus?.pageFetchState ?? void 0,
103
- mobileUsabilityVerdict: ix?.mobileUsabilityResult?.verdict ?? void 0,
104
- richResultsVerdict: ix?.richResultsResult?.verdict ?? void 0,
105
- raw: ix
106
- });
107
- }
108
- completed++;
109
- if (!quiet) process.stdout.write(`\r${progressBar(completed, urls.length, `${url.slice(0, 60)}`)}`);
110
- });
111
- if (!quiet) process.stdout.write("\n");
112
- await inspector.appendHistory({
113
- userId: store.userId,
114
- siteId: store.siteIdFor(siteUrl)
115
- }, records);
116
- if (json) console.log(JSON.stringify({
117
- site: siteUrl,
118
- inspected: records.length,
119
- failed,
120
- failures,
121
- records
122
- }, null, 2));
123
- else if (!quiet) {
124
- logger.success(`Inspected ${records.length}/${urls.length} URL(s)`);
125
- if (failed > 0) {
126
- logger.warn(`${failed} failed:`);
127
- for (const f of failures.slice(0, 5)) console.log(` ${f.url}: ${f.error}`);
128
- if (failures.length > 5) console.log(` ... and ${failures.length - 5} more`);
129
- }
130
- }
131
- if (failed > 0) process.exit(1);
132
- }
133
- });
134
- const showSubCommand = defineCommand({
135
- meta: {
136
- name: "show",
137
- description: "Print the latest inspection record for a URL from the local entity store"
138
- },
139
- args: {
140
- ...OUTPUT_ARGS,
141
- site: {
142
- type: "string",
143
- alias: "s",
144
- description: "Site URL (defaults to config.defaultSite or prompt)"
145
- },
146
- url: {
147
- type: "positional",
148
- required: true,
149
- description: "URL to look up"
150
- }
151
- },
152
- async run({ args }) {
153
- const { json } = applyOutputMode(args);
154
- const ctx = await createCommandContext({
155
- needsAuth: true,
156
- needsStore: true
157
- });
158
- const store = ctx.store;
159
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
160
- const record = await findLatestInspection(createInspectionStore({ dataSource: store.dataSource }), {
161
- userId: store.userId,
162
- siteId: store.siteIdFor(siteUrl)
163
- }, String(args.url));
164
- if (!record) {
165
- logger.warn(`No inspection record for ${args.url}`);
166
- process.exit(1);
167
- }
168
- if (json) {
169
- console.log(JSON.stringify(record, null, 2));
170
- return;
171
- }
172
- console.log();
173
- console.log(` \x1B[1m${record.url}\x1B[0m`);
174
- console.log(` Inspected: ${record.inspectedAt}`);
175
- if (record.indexStatus) console.log(` Index: ${record.indexStatus}`);
176
- if (record.lastCrawlTime) console.log(` Last crawl: ${record.lastCrawlTime}`);
177
- if (record.googleCanonical) console.log(` Canonical: ${record.googleCanonical}`);
178
- if (record.coverageState) console.log(` Coverage: ${record.coverageState}`);
179
- if (record.mobileUsabilityVerdict) console.log(` Mobile: ${record.mobileUsabilityVerdict}`);
180
- if (record.richResultsVerdict) console.log(` Rich results: ${record.richResultsVerdict}`);
181
- console.log();
182
- }
183
- });
184
- const sitemapsSnapshotSubCommand = defineCommand({
185
- meta: {
186
- name: "snapshot",
187
- description: "Fetch current sitemap state from GSC and persist to the local entity store"
188
- },
189
- args: {
190
- ...OUTPUT_ARGS,
191
- site: {
192
- type: "string",
193
- alias: "s",
194
- description: "Site URL (e.g., sc-domain:example.com); defaults to config.defaultSite or prompt"
195
- }
196
- },
197
- async run({ args }) {
198
- const { json, quiet } = applyOutputMode(args);
199
- const ctx = await createCommandContext({
200
- needsAuth: true,
201
- needsStore: true
202
- });
203
- const client = ctx.client;
204
- const store = ctx.store;
205
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
206
- const apiSitemaps = await client.sitemaps.list(siteUrl);
207
- const capturedAt = (/* @__PURE__ */ new Date()).toISOString();
208
- const records = apiSitemaps.filter((s) => typeof s.path === "string").map((s) => ({
209
- path: s.path,
210
- capturedAt,
211
- lastDownloaded: s.lastDownloaded ?? void 0,
212
- lastSubmitted: s.lastSubmitted ?? void 0,
213
- type: s.type ?? void 0,
214
- isPending: s.isPending ?? void 0,
215
- isSitemapsIndex: s.isSitemapsIndex ?? void 0,
216
- errors: s.errors ?? void 0,
217
- warnings: s.warnings ?? void 0,
218
- contents: s.contents?.map((c) => ({
219
- type: c.type ?? void 0,
220
- submitted: c.submitted ?? void 0,
221
- indexed: c.indexed ?? void 0
222
- })),
223
- raw: s
224
- }));
225
- await createSitemapStore({
226
- dataSource: store.dataSource,
227
- withMutation: store.withSitemapMutation
228
- }).writeSnapshot({
229
- userId: store.userId,
230
- siteId: store.siteIdFor(siteUrl)
231
- }, records);
232
- if (json) {
233
- console.log(JSON.stringify({
234
- site: siteUrl,
235
- capturedAt,
236
- records
237
- }, null, 2));
238
- return;
239
- }
240
- if (!quiet) {
241
- logger.success(`Captured ${records.length} sitemap(s) for ${siteUrl}`);
242
- for (const r of records) {
243
- const errors = r.errors && r.errors !== "0" ? ` \x1B[31merr=${r.errors}\x1B[0m` : "";
244
- const warnings = r.warnings && r.warnings !== "0" ? ` \x1B[33mwarn=${r.warnings}\x1B[0m` : "";
245
- const downloaded = r.lastDownloaded ? ` last=${r.lastDownloaded}` : "";
246
- console.log(` ${r.path}${downloaded}${errors}${warnings}`);
247
- }
248
- }
249
- }
250
- });
251
- const sitemapsShowSubCommand = defineCommand({
252
- meta: {
253
- name: "show",
254
- description: "Print the latest captured sitemap state for a feedpath"
255
- },
256
- args: {
257
- ...OUTPUT_ARGS,
258
- site: {
259
- type: "string",
260
- alias: "s",
261
- description: "Site URL (defaults to config.defaultSite or prompt)"
262
- },
263
- path: {
264
- type: "positional",
265
- required: true,
266
- description: "Sitemap path (feedpath)"
267
- }
268
- },
269
- async run({ args }) {
270
- const { json } = applyOutputMode(args);
271
- const ctx = await createCommandContext({
272
- needsAuth: true,
273
- needsStore: true
274
- });
275
- const store = ctx.store;
276
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
277
- const record = await createSitemapReadStore({ dataSource: store.dataSource }).getLatest({
278
- userId: store.userId,
279
- siteId: store.siteIdFor(siteUrl)
280
- }, String(args.path));
281
- if (!record) {
282
- logger.warn(`No sitemap record for ${args.path}`);
283
- process.exit(1);
284
- }
285
- if (json) {
286
- console.log(JSON.stringify(record, null, 2));
287
- return;
288
- }
289
- console.log();
290
- console.log(` \x1B[1m${record.path}\x1B[0m`);
291
- console.log(` Captured: ${record.capturedAt}`);
292
- if (record.lastDownloaded) console.log(` Downloaded: ${record.lastDownloaded}`);
293
- if (record.lastSubmitted) console.log(` Submitted: ${record.lastSubmitted}`);
294
- if (record.type) console.log(` Type: ${record.type}`);
295
- if (record.errors) console.log(` Errors: ${record.errors}`);
296
- if (record.warnings) console.log(` Warnings: ${record.warnings}`);
297
- if (record.contents?.length) {
298
- console.log(` Contents:`);
299
- for (const c of record.contents) {
300
- const bits = [
301
- c.type,
302
- c.submitted && `submitted=${c.submitted}`,
303
- c.indexed && `indexed=${c.indexed}`
304
- ].filter(Boolean).join(" ");
305
- console.log(` ${bits}`);
306
- }
307
- }
308
- console.log();
309
- }
310
- });
311
- const indexingSubCommand = defineCommand({
30
+ const entitiesCommand = defineCommand({
312
31
  meta: {
313
- name: "indexing",
314
- description: "Snapshot Indexing API metadata per URL"
32
+ name: "entities",
33
+ description: "Manage local entity snapshots (URL inspections and indexing metadata)"
315
34
  },
316
- subCommands: { snapshot: defineCommand({
317
- meta: {
318
- name: "snapshot",
319
- description: "Fetch Indexing API metadata (latest update/remove per URL) and persist to the local entity store"
320
- },
321
- args: {
322
- site: {
323
- type: "string",
324
- alias: "s",
325
- description: "Site URL (e.g., sc-domain:example.com); defaults to config.defaultSite or prompt"
326
- },
327
- file: {
328
- type: "string",
329
- alias: "f",
330
- description: "Path to a file with one URL per line. If omitted, reads from stdin."
35
+ subCommands: {
36
+ inspect: defineCommand({
37
+ meta: {
38
+ name: "inspect",
39
+ description: "Run URL Inspection for a list of URLs and persist results to the local entity store"
331
40
  },
332
- concurrency: {
333
- type: "string",
334
- alias: "c",
335
- default: "4",
336
- description: "Concurrent in-flight getMetadata calls (default: 4)"
41
+ args: {
42
+ site: {
43
+ type: "string",
44
+ alias: "s",
45
+ description: "Site URL (e.g., sc-domain:example.com); defaults to config.defaultSite or prompt"
46
+ },
47
+ file: {
48
+ type: "string",
49
+ alias: "f",
50
+ description: "Path to a file with one URL per line. If omitted, reads from stdin."
51
+ },
52
+ limit: {
53
+ type: "string",
54
+ description: `Max URLs to inspect this run (default: ${INSPECTION_QPD_PER_PROPERTY}, the per-property GSC daily quota)`
55
+ },
56
+ concurrency: {
57
+ type: "string",
58
+ alias: "c",
59
+ default: "4",
60
+ description: "Concurrent in-flight inspect calls (default: 4)"
61
+ },
62
+ ...OUTPUT_ARGS
337
63
  },
338
- ...OUTPUT_ARGS
339
- },
340
- async run({ args }) {
341
- const { quiet } = applyOutputMode(args);
342
- const ctx = await createCommandContext({
343
- needsAuth: true,
344
- needsStore: true
345
- });
346
- const client = ctx.client;
347
- const store = ctx.store;
348
- const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
349
- const concurrency = Math.max(1, Number.parseInt(String(args.concurrency), 10) || 4);
350
- const urls = await readUrlList({ file: args.file ? String(args.file) : void 0 });
351
- if (urls.length === 0) {
352
- logger.warn("No URLs to fetch metadata for.");
353
- return;
354
- }
355
- const records = [];
356
- const failures = [];
357
- let completed = 0;
358
- await runWithConcurrency(urls, concurrency, async (url) => {
359
- const result = await client.indexing.getMetadata(url).catch((err) => err);
360
- if (result instanceof Error) if (INDEXING_NOT_FOUND_RE.test(result.message)) records.push({
361
- url,
362
- capturedAt: (/* @__PURE__ */ new Date()).toISOString()
363
- });
364
- else failures.push({
365
- url,
366
- error: result.message
64
+ async run({ args }) {
65
+ const { json, quiet } = applyOutputMode(args);
66
+ const ctx = await createCommandContext({
67
+ needsAuth: true,
68
+ needsStore: true
367
69
  });
368
- else records.push({
369
- url,
370
- capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
371
- latestUpdateAt: result.latestUpdate?.notifyTime ?? void 0,
372
- latestRemoveAt: result.latestRemove?.notifyTime ?? void 0,
373
- raw: result
70
+ const client = ctx.client;
71
+ const store = ctx.store;
72
+ const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
73
+ const limit = args.limit ? Number.parseInt(String(args.limit), 10) : INSPECTION_QPD_PER_PROPERTY;
74
+ const concurrency = Math.max(1, Number.parseInt(String(args.concurrency), 10) || 4);
75
+ const urls = (await readUrlList({ file: args.file ? String(args.file) : void 0 })).slice(0, limit);
76
+ if (urls.length === 0) {
77
+ logger.warn("No URLs to inspect.");
78
+ return;
79
+ }
80
+ if (urls.length === limit && limit < INSPECTION_QPD_PER_PROPERTY) logger.info(`Capping at --limit ${limit}`);
81
+ if (urls.length === INSPECTION_QPD_PER_PROPERTY) logger.info(`Hit per-property daily inspection quota (${INSPECTION_QPD_PER_PROPERTY}); remaining URLs will be queued for tomorrow.`);
82
+ const inspector = createInspectionStore({ dataSource: store.dataSource });
83
+ let completed = 0;
84
+ let failed = 0;
85
+ const records = [];
86
+ const failures = [];
87
+ await runWithConcurrency(urls, concurrency, async (url) => {
88
+ const result = await client.inspect(siteUrl, url).catch((err) => err);
89
+ if (result instanceof Error) {
90
+ failed++;
91
+ failures.push({
92
+ url,
93
+ error: result.message
94
+ });
95
+ } else {
96
+ const ix = result.inspectionResult;
97
+ const indexStatus = ix?.indexStatusResult;
98
+ records.push({
99
+ url,
100
+ inspectedAt: (/* @__PURE__ */ new Date()).toISOString(),
101
+ indexStatus: indexStatus?.verdict ?? void 0,
102
+ lastCrawlTime: indexStatus?.lastCrawlTime ?? void 0,
103
+ googleCanonical: indexStatus?.googleCanonical ?? void 0,
104
+ userCanonical: indexStatus?.userCanonical ?? void 0,
105
+ coverageState: indexStatus?.coverageState ?? void 0,
106
+ robotsTxtState: indexStatus?.robotsTxtState ?? void 0,
107
+ indexingState: indexStatus?.indexingState ?? void 0,
108
+ pageFetchState: indexStatus?.pageFetchState ?? void 0,
109
+ mobileUsabilityVerdict: ix?.mobileUsabilityResult?.verdict ?? void 0,
110
+ richResultsVerdict: ix?.richResultsResult?.verdict ?? void 0,
111
+ raw: ix
112
+ });
113
+ }
114
+ completed++;
115
+ if (!quiet) process.stdout.write(`\r${progressBar(completed, urls.length, `${url.slice(0, 60)}`)}`);
374
116
  });
375
- completed++;
376
- if (!quiet) process.stdout.write(`\r${progressBar(completed, urls.length, url.slice(0, 60))}`);
377
- });
378
- if (!quiet) process.stdout.write("\n");
379
- await createIndexingMetadataStore({ dataSource: store.dataSource }).writeBatch({
380
- userId: store.userId,
381
- siteId: store.siteIdFor(siteUrl)
382
- }, records);
383
- if (!quiet) {
384
- logger.success(`Captured metadata for ${records.length}/${urls.length} URL(s)`);
385
- if (failures.length > 0) {
386
- logger.warn(`${failures.length} failed:`);
387
- for (const f of failures.slice(0, 5)) console.log(` ${f.url}: ${f.error}`);
388
- if (failures.length > 5) console.log(` ... and ${failures.length - 5} more`);
117
+ if (!quiet) process.stdout.write("\n");
118
+ await inspector.appendHistory({
119
+ userId: store.userId,
120
+ siteId: store.siteIdFor(siteUrl)
121
+ }, records);
122
+ if (json) console.log(JSON.stringify({
123
+ site: siteUrl,
124
+ inspected: records.length,
125
+ failed,
126
+ failures,
127
+ records
128
+ }, null, 2));
129
+ else if (!quiet) {
130
+ logger.success(`Inspected ${records.length}/${urls.length} URL(s)`);
131
+ if (failed > 0) {
132
+ logger.warn(`${failed} failed:`);
133
+ for (const f of failures.slice(0, 5)) console.log(` ${f.url}: ${f.error}`);
134
+ if (failures.length > 5) console.log(` ... and ${failures.length - 5} more`);
135
+ }
389
136
  }
137
+ if (failed > 0) process.exit(1);
390
138
  }
391
- if (failures.length > 0) process.exit(1);
392
- }
393
- }) }
394
- });
395
- const entitiesCommand = defineCommand({
396
- meta: {
397
- name: "entities",
398
- description: "Manage local entity snapshots (URL inspections, sitemaps, indexing metadata)"
399
- },
400
- subCommands: {
401
- inspect: inspectSubCommand,
402
- show: showSubCommand,
403
- sitemaps: defineCommand({
139
+ }),
140
+ show: defineCommand({
404
141
  meta: {
405
- name: "sitemaps",
406
- description: "Snapshot and inspect sitemap state per site"
142
+ name: "show",
143
+ description: "Print the latest inspection record for a URL from the local entity store"
144
+ },
145
+ args: {
146
+ ...OUTPUT_ARGS,
147
+ site: {
148
+ type: "string",
149
+ alias: "s",
150
+ description: "Site URL (defaults to config.defaultSite or prompt)"
151
+ },
152
+ url: {
153
+ type: "positional",
154
+ required: true,
155
+ description: "URL to look up"
156
+ }
407
157
  },
408
- subCommands: {
409
- snapshot: sitemapsSnapshotSubCommand,
410
- show: sitemapsShowSubCommand
158
+ async run({ args }) {
159
+ const { json } = applyOutputMode(args);
160
+ const ctx = await createCommandContext({
161
+ needsAuth: true,
162
+ needsStore: true
163
+ });
164
+ const store = ctx.store;
165
+ const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
166
+ const record = await findLatestInspection(createInspectionStore({ dataSource: store.dataSource }), {
167
+ userId: store.userId,
168
+ siteId: store.siteIdFor(siteUrl)
169
+ }, String(args.url));
170
+ if (!record) {
171
+ logger.warn(`No inspection record for ${args.url}`);
172
+ process.exit(1);
173
+ }
174
+ if (json) {
175
+ console.log(JSON.stringify(record, null, 2));
176
+ return;
177
+ }
178
+ console.log();
179
+ console.log(` \x1B[1m${record.url}\x1B[0m`);
180
+ console.log(` Inspected: ${record.inspectedAt}`);
181
+ if (record.indexStatus) console.log(` Index: ${record.indexStatus}`);
182
+ if (record.lastCrawlTime) console.log(` Last crawl: ${record.lastCrawlTime}`);
183
+ if (record.googleCanonical) console.log(` Canonical: ${record.googleCanonical}`);
184
+ if (record.coverageState) console.log(` Coverage: ${record.coverageState}`);
185
+ if (record.mobileUsabilityVerdict) console.log(` Mobile: ${record.mobileUsabilityVerdict}`);
186
+ if (record.richResultsVerdict) console.log(` Rich results: ${record.richResultsVerdict}`);
187
+ console.log();
411
188
  }
412
189
  }),
413
- indexing: indexingSubCommand
190
+ indexing: defineCommand({
191
+ meta: {
192
+ name: "indexing",
193
+ description: "Snapshot Indexing API metadata per URL"
194
+ },
195
+ subCommands: { snapshot: defineCommand({
196
+ meta: {
197
+ name: "snapshot",
198
+ description: "Fetch Indexing API metadata (latest update/remove per URL) and persist to the local entity store"
199
+ },
200
+ args: {
201
+ site: {
202
+ type: "string",
203
+ alias: "s",
204
+ description: "Site URL (e.g., sc-domain:example.com); defaults to config.defaultSite or prompt"
205
+ },
206
+ file: {
207
+ type: "string",
208
+ alias: "f",
209
+ description: "Path to a file with one URL per line. If omitted, reads from stdin."
210
+ },
211
+ concurrency: {
212
+ type: "string",
213
+ alias: "c",
214
+ default: "4",
215
+ description: "Concurrent in-flight getMetadata calls (default: 4)"
216
+ },
217
+ ...OUTPUT_ARGS
218
+ },
219
+ async run({ args }) {
220
+ const { quiet } = applyOutputMode(args);
221
+ const ctx = await createCommandContext({
222
+ needsAuth: true,
223
+ needsStore: true
224
+ });
225
+ const client = ctx.client;
226
+ const store = ctx.store;
227
+ const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
228
+ const concurrency = Math.max(1, Number.parseInt(String(args.concurrency), 10) || 4);
229
+ const urls = await readUrlList({ file: args.file ? String(args.file) : void 0 });
230
+ if (urls.length === 0) {
231
+ logger.warn("No URLs to fetch metadata for.");
232
+ return;
233
+ }
234
+ const records = [];
235
+ const failures = [];
236
+ let completed = 0;
237
+ await runWithConcurrency(urls, concurrency, async (url) => {
238
+ const result = await client.indexing.getMetadata(url).catch((err) => err);
239
+ if (result instanceof Error) if (INDEXING_NOT_FOUND_RE.test(result.message)) records.push({
240
+ url,
241
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
242
+ });
243
+ else failures.push({
244
+ url,
245
+ error: result.message
246
+ });
247
+ else records.push({
248
+ url,
249
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
250
+ latestUpdateAt: result.latestUpdate?.notifyTime ?? void 0,
251
+ latestRemoveAt: result.latestRemove?.notifyTime ?? void 0,
252
+ raw: result
253
+ });
254
+ completed++;
255
+ if (!quiet) process.stdout.write(`\r${progressBar(completed, urls.length, url.slice(0, 60))}`);
256
+ });
257
+ if (!quiet) process.stdout.write("\n");
258
+ await createIndexingMetadataStore({ dataSource: store.dataSource }).writeBatch({
259
+ userId: store.userId,
260
+ siteId: store.siteIdFor(siteUrl)
261
+ }, records);
262
+ if (!quiet) {
263
+ logger.success(`Captured metadata for ${records.length}/${urls.length} URL(s)`);
264
+ if (failures.length > 0) {
265
+ logger.warn(`${failures.length} failed:`);
266
+ for (const f of failures.slice(0, 5)) console.log(` ${f.url}: ${f.error}`);
267
+ if (failures.length > 5) console.log(` ... and ${failures.length - 5} more`);
268
+ }
269
+ }
270
+ if (failures.length > 0) process.exit(1);
271
+ }
272
+ }) }
273
+ })
414
274
  }
415
275
  });
416
276
  export { entitiesCommand };
@@ -1,11 +1,32 @@
1
+ import { resolveCliEnvironment } from "../environment.mjs";
1
2
  import { OUTPUT_ARGS, applyOutputMode, logger, noSubcommandSelected } from "../utils.mjs";
2
3
  import { createCommandContext } from "../context.mjs";
3
4
  import { gscErrorHandler } from "../error-handler.mjs";
4
- import { loadSitemapUrls } from "../sitemap.mjs";
5
+ import { discoverLiveSitemap, loadSitemapUrls } from "../sitemap.mjs";
5
6
  import process from "node:process";
6
7
  import { defineCommand } from "citty";
7
8
  import { fetchSitemap } from "gscdump";
8
- import { discoverSitemapResult } from "gscdump/sitemap";
9
+ import { createGscdumpV1Client } from "@gscdump/sdk/v1";
10
+ const HOSTED_ARGS = {
11
+ "api-root": {
12
+ type: "string",
13
+ description: "Hosted API root; defaults to GSCDUMP_API_ROOT or https://gscdump.com/api/_gscdump"
14
+ },
15
+ "api-key": {
16
+ type: "string",
17
+ description: "Hosted API key; defaults to GSCDUMP_API_KEY"
18
+ }
19
+ };
20
+ function hostedClient(args) {
21
+ const environment = resolveCliEnvironment().values;
22
+ const apiRoot = String(args["api-root"] || environment.GSCDUMP_API_ROOT || "https://gscdump.com/api/_gscdump");
23
+ const apiKey = String(args["api-key"] || environment.GSCDUMP_API_KEY || "");
24
+ if (!apiKey) throw new Error("Hosted sitemap reads require --api-key or GSCDUMP_API_KEY");
25
+ return createGscdumpV1Client({
26
+ apiRoot,
27
+ credential: apiKey
28
+ });
29
+ }
9
30
  const listCommand = defineCommand({
10
31
  meta: {
11
32
  name: "list",
@@ -197,7 +218,7 @@ const sitemapsCommand = defineCommand({
197
218
  async run({ args }) {
198
219
  const { json } = applyOutputMode(args);
199
220
  const domain = String(args.domain).replace(/^https?:\/\//, "").replace(/\/.*$/, "");
200
- const discovery = await discoverSitemapResult(domain);
221
+ const discovery = await discoverLiveSitemap(domain);
201
222
  const url = discovery._tag === "found" ? discovery.url : null;
202
223
  if (json) {
203
224
  console.log(JSON.stringify({
@@ -208,7 +229,7 @@ const sitemapsCommand = defineCommand({
208
229
  return;
209
230
  }
210
231
  if (discovery._tag === "incomplete") {
211
- logger.error(`Sitemap discovery was incomplete: ${discovery.failures[0]?.detail ?? "unknown failure"}`);
232
+ logger.error(`Sitemap discovery was incomplete: ${discovery.failures[0] ?? "unknown failure"}`);
212
233
  process.exit(1);
213
234
  }
214
235
  if (!url) {
@@ -266,6 +287,191 @@ const sitemapsCommand = defineCommand({
266
287
  if (!complete) logger.warn("Sitemap walk was truncated or a child document failed");
267
288
  for (const u of urls) console.log(u);
268
289
  }
290
+ }),
291
+ current: defineCommand({
292
+ meta: {
293
+ name: "current",
294
+ description: "Read the hosted canonical sitemap generation and feed list"
295
+ },
296
+ args: {
297
+ ...OUTPUT_ARGS,
298
+ ...HOSTED_ARGS,
299
+ "site-id": {
300
+ type: "positional",
301
+ required: true,
302
+ description: "Hosted gscdump site id"
303
+ }
304
+ },
305
+ async run({ args }) {
306
+ const { json } = applyOutputMode(args);
307
+ const result = await hostedClient(args).getSiteSitemaps({ params: { siteId: String(args["site-id"]) } });
308
+ if (json) {
309
+ console.log(JSON.stringify(result.data, null, 2));
310
+ return;
311
+ }
312
+ const generation = result.data.generation;
313
+ console.log(generation ? `Generation ${generation.id}, observed ${new Date(generation.observedAt).toISOString()}` : "No canonical generation");
314
+ for (const sitemap of result.data.sitemaps) console.log(`${sitemap.path}\t${sitemap.urlCount}`);
315
+ }
316
+ }),
317
+ history: defineCommand({
318
+ meta: {
319
+ name: "history",
320
+ description: "Read hosted canonical sitemap membership and lastmod changes"
321
+ },
322
+ args: {
323
+ ...OUTPUT_ARGS,
324
+ ...HOSTED_ARGS,
325
+ "site-id": {
326
+ type: "positional",
327
+ required: true,
328
+ description: "Hosted gscdump site id"
329
+ },
330
+ "days": {
331
+ type: "string",
332
+ description: "History window in days"
333
+ }
334
+ },
335
+ async run({ args }) {
336
+ const { json } = applyOutputMode(args);
337
+ const days = args.days ? Number.parseInt(String(args.days), 10) : void 0;
338
+ const result = await hostedClient(args).getSiteSitemapChanges({
339
+ params: { siteId: String(args["site-id"]) },
340
+ query: { ...days ? { days } : {} }
341
+ });
342
+ console.log(JSON.stringify(result.data, null, json ? 2 : 0));
343
+ }
344
+ }),
345
+ membership: defineCommand({
346
+ meta: {
347
+ name: "membership",
348
+ description: "Query exact hosted membership evidence for comma-separated URLs"
349
+ },
350
+ args: {
351
+ ...OUTPUT_ARGS,
352
+ ...HOSTED_ARGS,
353
+ "site-id": {
354
+ type: "positional",
355
+ required: true,
356
+ description: "Hosted gscdump site id"
357
+ },
358
+ "urls": {
359
+ type: "string",
360
+ required: true,
361
+ description: "Comma-separated exact URLs"
362
+ },
363
+ "generation": {
364
+ type: "string",
365
+ description: "Pin the query to a generation id"
366
+ }
367
+ },
368
+ async run({ args }) {
369
+ const { json } = applyOutputMode(args);
370
+ const urls = String(args.urls).split(",").map((url) => url.trim()).filter(Boolean);
371
+ const result = await hostedClient(args).querySitemapMembership({
372
+ params: { siteId: String(args["site-id"]) },
373
+ body: {
374
+ urls,
375
+ ...args.generation ? { generationId: String(args.generation) } : {}
376
+ }
377
+ });
378
+ if (json) {
379
+ console.log(JSON.stringify(result.data, null, 2));
380
+ return;
381
+ }
382
+ for (const evidence of result.data.evidence) {
383
+ const detail = evidence._tag === "present" ? `${evidence.feedpath}\t${evidence.lastmod ?? ""}` : evidence._tag === "absent" ? String(evidence.observedAt) : evidence.reason;
384
+ console.log(`${evidence._tag}\t${evidence.url}\t${detail}`);
385
+ }
386
+ }
387
+ }),
388
+ lastmod: defineCommand({
389
+ meta: {
390
+ name: "lastmod",
391
+ description: "List a generation-pinned hosted page of URL lastmod evidence"
392
+ },
393
+ args: {
394
+ ...OUTPUT_ARGS,
395
+ ...HOSTED_ARGS,
396
+ "site-id": {
397
+ type: "positional",
398
+ required: true,
399
+ description: "Hosted gscdump site id"
400
+ },
401
+ "generation": {
402
+ type: "string",
403
+ description: "Pin to a generation id"
404
+ },
405
+ "feedpath": {
406
+ type: "string",
407
+ description: "Restrict to one exact sitemap feed URL"
408
+ },
409
+ "cursor": {
410
+ type: "string",
411
+ description: "Opaque cursor from the prior page"
412
+ },
413
+ "limit": {
414
+ type: "string",
415
+ description: "Page size, maximum 10000"
416
+ }
417
+ },
418
+ async run({ args }) {
419
+ const { json } = applyOutputMode(args);
420
+ const result = await hostedClient(args).listSitemapUrls({
421
+ params: { siteId: String(args["site-id"]) },
422
+ query: {
423
+ ...args.generation ? { generationId: String(args.generation) } : {},
424
+ ...args.feedpath ? { feedpath: String(args.feedpath) } : {},
425
+ ...args.cursor ? { cursor: String(args.cursor) } : {},
426
+ ...args.limit ? { limit: Number.parseInt(String(args.limit), 10) } : {}
427
+ }
428
+ });
429
+ if (json) {
430
+ console.log(JSON.stringify(result.data, null, 2));
431
+ return;
432
+ }
433
+ for (const item of result.data.items) console.log(`${item.url}\t${item.lastmod ?? ""}\t${item.feedpath}`);
434
+ if (result.data.page.nextCursor) logger.info(`Next cursor: ${result.data.page.nextCursor}`);
435
+ }
436
+ }),
437
+ export: defineCommand({
438
+ meta: {
439
+ name: "export",
440
+ description: "Get a generation-pinned hosted bulk export descriptor"
441
+ },
442
+ args: {
443
+ ...OUTPUT_ARGS,
444
+ ...HOSTED_ARGS,
445
+ "site-id": {
446
+ type: "positional",
447
+ required: true,
448
+ description: "Hosted gscdump site id"
449
+ },
450
+ "generation": {
451
+ type: "string",
452
+ description: "Pin to a generation id"
453
+ },
454
+ "feedpath": {
455
+ type: "string",
456
+ description: "Restrict to one exact sitemap feed URL"
457
+ }
458
+ },
459
+ async run({ args }) {
460
+ const { json } = applyOutputMode(args);
461
+ const result = await hostedClient(args).getSitemapExport({
462
+ params: { siteId: String(args["site-id"]) },
463
+ query: {
464
+ ...args.generation ? { generationId: String(args.generation) } : {},
465
+ ...args.feedpath ? { feedpath: String(args.feedpath) } : {}
466
+ }
467
+ });
468
+ if (json) {
469
+ console.log(JSON.stringify(result.data, null, 2));
470
+ return;
471
+ }
472
+ if (result.data.export._tag === "unavailable") throw new Error(`Sitemap export unavailable: ${result.data.export.reason}`);
473
+ console.log(result.data.export.url);
474
+ }
269
475
  })
270
476
  },
271
477
  async run({ args }) {
@@ -275,7 +481,12 @@ const sitemapsCommand = defineCommand({
275
481
  "submit",
276
482
  "delete",
277
483
  "discover",
278
- "urls"
484
+ "urls",
485
+ "current",
486
+ "history",
487
+ "membership",
488
+ "lastmod",
489
+ "export"
279
490
  ])) return;
280
491
  await listCommand.run?.({
281
492
  args,
@@ -1,3 +1,4 @@
1
+ import { discoverLiveSitemap } from "../../sitemap.mjs";
1
2
  import { diagnostics } from "../handlers/diagnostics.mjs";
2
3
  import { batchInspectUrls as batchInspectUrls$1, batchRequestIndexing as batchRequestIndexing$1, getIndexingStatus, requestIndexing as requestIndexing$1 } from "../handlers/indexing.mjs";
3
4
  import { listReports, runReportHandler } from "../handlers/reports.mjs";
@@ -5,7 +6,6 @@ import { getSitemap, listSitesWithSitemaps } from "../handlers/sites.mjs";
5
6
  import "../handlers/index.mjs";
6
7
  import { batchInspectUrlsInput, batchRequestIndexingInput, getIndexingStatusInput, inspectUrlInput, listReportsInput, listSitemapsInput, listSitesInput, requestIndexingInput, runReportInput, sitemapInput } from "../types.mjs";
7
8
  import { addSite, deleteSite, getIndexingMetadata, getVerificationToken, getVerifiedSite, googleSearchConsole, listVerifiedSites, runSequentialBatch, unverifySite, verifySite } from "gscdump";
8
- import { discoverSitemapResult } from "gscdump/sitemap";
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { z } from "zod";
11
11
  async function runMcpSearchAnalyticsQuery(client, args) {
@@ -386,7 +386,7 @@ function createGscMcpServer(options) {
386
386
  inputSchema: z.object({ domain: z.string().describe("Domain (e.g., example.com)") }).shape
387
387
  }, async ({ domain }) => {
388
388
  const cleaned = String(domain).replace(/^https?:\/\//, "").replace(/\/.*$/, "");
389
- const discovery = await discoverSitemapResult(cleaned);
389
+ const discovery = await discoverLiveSitemap(cleaned);
390
390
  return { content: [{
391
391
  type: "text",
392
392
  text: JSON.stringify({
package/dist/package.mjs CHANGED
@@ -1,2 +1,2 @@
1
- var version = "1.5.4";
1
+ var version = "2.0.1";
2
2
  export { version };
package/dist/sitemap.mjs CHANGED
@@ -1,27 +1,89 @@
1
- import { walkSitemaps } from "gscdump/sitemap";
2
- function failureMessage(result) {
3
- if (result._tag === "not_found") return "No sitemap document was found";
4
- const first = result.failures[0];
5
- if (!first) return "Sitemap walk failed before reading a document";
6
- const error = first.error;
7
- if (error._tag === "network_error") return `${first.url}: ${error.error}`;
8
- if (error._tag === "http_error") return `${first.url}: HTTP ${error.status} ${error.statusText}`;
9
- if (error._tag === "not_found") return `${first.url}: HTTP ${error.status}`;
10
- return `${first.url}: ${error.error._tag}`;
1
+ import { createSitemapReader, parseRobotsSitemaps } from "sitemapd";
2
+ import { createFetchDocumentLoader } from "sitemapd/fetch";
3
+ const COMMON_PATHS = [
4
+ "/sitemap.xml",
5
+ "/sitemap_index.xml",
6
+ "/sitemap-index.xml",
7
+ "/sitemaps.xml"
8
+ ];
9
+ function publicHttpTarget(url) {
10
+ let parsed;
11
+ try {
12
+ parsed = new URL(url);
13
+ } catch {
14
+ return {
15
+ _tag: "deny",
16
+ reason: "invalid URL"
17
+ };
18
+ }
19
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? { _tag: "allow" } : {
20
+ _tag: "deny",
21
+ reason: "only HTTP(S) sitemap targets are supported"
22
+ };
23
+ }
24
+ function createLiveReader(fetch = globalThis.fetch) {
25
+ return createSitemapReader({
26
+ loadDocument: createFetchDocumentLoader({ fetch }),
27
+ authorizeTarget: ({ url }) => publicHttpTarget(url)
28
+ });
29
+ }
30
+ function readFailure(result) {
31
+ if (result._tag === "not_found") return `${result.url}: HTTP ${result.status}`;
32
+ return `${result.url}: ${result.code ?? result.reason}: ${result.detail}`;
11
33
  }
12
34
  async function loadSitemapUrls(url, options = {}) {
13
- const result = await walkSitemaps(url, options);
14
- if (result._tag !== "ok") return {
35
+ const result = await createLiveReader().walk(url, options);
36
+ if (result.documentsRead === 0) return {
15
37
  _tag: "error",
16
- message: failureMessage(result)
38
+ message: result.failures[0] ? readFailure(result.failures[0].result) : "No sitemap document was found"
17
39
  };
18
40
  return {
19
41
  _tag: "ok",
20
42
  value: {
21
43
  urls: result.entries.map((entry) => entry.loc),
22
- complete: result.complete,
44
+ complete: result._tag === "complete",
23
45
  documentsRead: result.documentsRead
24
46
  }
25
47
  };
26
48
  }
27
- export { loadSitemapUrls };
49
+ async function discoverLiveSitemap(input, fetch = globalThis.fetch) {
50
+ let origin;
51
+ try {
52
+ origin = new URL(/^https?:\/\//i.test(input) ? input : `https://${input}`).origin;
53
+ } catch {
54
+ return {
55
+ _tag: "incomplete",
56
+ failures: [`${input}: invalid site URL`]
57
+ };
58
+ }
59
+ const reader = createLiveReader(fetch);
60
+ const failures = [];
61
+ for (const path of COMMON_PATHS) {
62
+ const result = await reader.read(`${origin}${path}`);
63
+ if (result._tag === "ok") return {
64
+ _tag: "found",
65
+ url: result.url,
66
+ source: "common_path"
67
+ };
68
+ if (result._tag !== "not_found" && !(result._tag === "failure" && result.reason === "document")) failures.push(readFailure(result));
69
+ }
70
+ const robotsUrl = `${origin}/robots.txt`;
71
+ const robots = await fetch(robotsUrl, { redirect: "manual" }).then(async (response) => response.ok ? response.text() : "").catch((error) => {
72
+ failures.push(`${robotsUrl}: ${error instanceof Error ? error.message : String(error)}`);
73
+ return "";
74
+ });
75
+ for (const reference of parseRobotsSitemaps(robots, robotsUrl)) {
76
+ const result = await reader.read(reference.loc);
77
+ if (result._tag === "ok") return {
78
+ _tag: "found",
79
+ url: result.url,
80
+ source: "robots"
81
+ };
82
+ if (result._tag !== "not_found") failures.push(readFailure(result));
83
+ }
84
+ return failures.length > 0 ? {
85
+ _tag: "incomplete",
86
+ failures
87
+ } : { _tag: "not_found" };
88
+ }
89
+ export { discoverLiveSitemap, loadSitemapUrls };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/cli",
3
3
  "type": "module",
4
- "version": "1.5.4",
4
+ "version": "2.0.1",
5
5
  "description": "CLI for Google Search Console - dump, query, and run MCP server",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -49,11 +49,13 @@
49
49
  "google-auth-library": "^10.9.1",
50
50
  "ofetch": "^1.5.1",
51
51
  "open": "^11.0.0",
52
+ "sitemapd": "^0.1.0",
52
53
  "zod": "^4.4.3",
53
- "@gscdump/analysis": "^1.5.4",
54
- "@gscdump/engine": "^1.5.4",
55
- "gscdump": "^1.5.4",
56
- "@gscdump/engine-gsc-api": "^1.5.4"
54
+ "@gscdump/analysis": "^2.0.1",
55
+ "@gscdump/engine": "^2.0.1",
56
+ "@gscdump/engine-gsc-api": "^2.0.1",
57
+ "@gscdump/sdk": "^2.0.1",
58
+ "gscdump": "^2.0.1"
57
59
  },
58
60
  "devDependencies": {
59
61
  "vitest": "^4.1.10"