@gscdump/cli 2.1.0 → 2.2.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 CHANGED
@@ -48,7 +48,7 @@ gscdump mcp
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
50
  | `sitemaps` | GSC CRUD, explicit live probes, and hosted canonical reads (`current`, `history`, `membership`, `lastmod`, `export`) |
51
- | `inspect <url>` / `inspect batch [--concurrency]` | URL inspection (single URL or batch from file/stdin); renders index status, rich results, AMP, mobile usability |
51
+ | `inspect <url>` / `inspect batch [--concurrency]` | URL inspection (single URL or batch from file/stdin); renders index status, rich results, and AMP |
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. |
@@ -17,7 +17,8 @@ async function hasLocalData(store, siteUrl) {
17
17
  })).length > 0;
18
18
  }
19
19
  async function listLocalSites(dataDir, userId = "local") {
20
- return readdir(join(dataDir, `u_${userId}`), { withFileTypes: true }).then((entries) => entries.filter((e) => e.isDirectory() && (e.name.startsWith("d_") || e.name.startsWith("h_"))).map((e) => decodeSiteId(e.name))).catch(() => []);
20
+ const tenantDir = join(dataDir, `u_${userId}`);
21
+ return readdir(tenantDir, { withFileTypes: true }).then((entries) => entries.filter((e) => e.isDirectory() && (e.name.startsWith("d_") || e.name.startsWith("h_"))).map((e) => decodeSiteId(e.name))).catch(() => []);
21
22
  }
22
23
  function pickLocalSite(siteUrls, hint) {
23
24
  if (siteUrls.length === 0) return null;
package/dist/auth.mjs CHANGED
@@ -257,7 +257,7 @@ async function getAuthCodeViaLoopback(authUrl) {
257
257
  server.on("error", (err) => settle(() => reject(err)));
258
258
  timeoutId = setTimeout(() => {
259
259
  settle(() => reject(/* @__PURE__ */ new Error("Authorization timed out. If Google showed \"redirect_uri_mismatch\", your OAuth client must be type \"Desktop application\" (create one at https://console.cloud.google.com/apis/credentials and run `gscdump init --force`).")));
260
- }, 300 * 1e3);
260
+ }, 3e5);
261
261
  });
262
262
  }
263
263
  async function authenticate(credentials, interactive, opts = {}) {
package/dist/cli.mjs CHANGED
@@ -28,8 +28,9 @@ function prepareCliArgs(input) {
28
28
  stderrIsTTY: Boolean(process.stderr.isTTY)
29
29
  });
30
30
  const profile = pluckArgValue(rawArgs, "--profile");
31
+ const configDir = pluckArgValue(rawArgs, "--config-dir") ?? env.configDir ?? null;
31
32
  applyProfileFromCli({
32
- configDir: pluckArgValue(rawArgs, "--config-dir") ?? env.configDir ?? null,
33
+ configDir,
33
34
  profile,
34
35
  envProfile: env.profile
35
36
  });
@@ -304,30 +304,31 @@ function renderResults(results, total, format) {
304
304
  logger.success(`${results.length} results`);
305
305
  if (total > results.length) logger.info(`Total: ${total} (showing ${results.length})`);
306
306
  }
307
+ const listCommand = defineCommand({
308
+ meta: {
309
+ name: "list",
310
+ description: "List available analyzer ids"
311
+ },
312
+ args: { json: {
313
+ type: "boolean",
314
+ default: false,
315
+ description: "Output as JSON"
316
+ } },
317
+ async run({ args }) {
318
+ if (args.json) {
319
+ console.log(JSON.stringify(ANALYSIS_TOOLS, null, 2));
320
+ return;
321
+ }
322
+ for (const id of ANALYSIS_TOOLS) console.log(id);
323
+ }
324
+ });
307
325
  const analyzeCommand = defineCommand({
308
326
  meta: {
309
327
  name: "analyze",
310
328
  description: "SEO analysis tools"
311
329
  },
312
330
  subCommands: {
313
- list: defineCommand({
314
- meta: {
315
- name: "list",
316
- description: "List available analyzer ids"
317
- },
318
- args: { json: {
319
- type: "boolean",
320
- default: false,
321
- description: "Output as JSON"
322
- } },
323
- async run({ args }) {
324
- if (args.json) {
325
- console.log(JSON.stringify(ANALYSIS_TOOLS, null, 2));
326
- return;
327
- }
328
- for (const id of ANALYSIS_TOOLS) console.log(id);
329
- }
330
- }),
331
+ list: listCommand,
331
332
  ...Object.fromEntries(ANALYSIS_TOOLS.map((tool) => [tool, makeToolCommand(tool)]))
332
333
  }
333
334
  });
@@ -40,6 +40,174 @@ const VALID_KEYS = [
40
40
  "serviceAccountPath"
41
41
  ];
42
42
  const NUMERIC_KEYS = /* @__PURE__ */ new Set(["defaultLimit"]);
43
+ const setCommand = defineCommand({
44
+ meta: {
45
+ name: "set",
46
+ description: "Set a config value"
47
+ },
48
+ args: {
49
+ key: {
50
+ type: "positional",
51
+ description: `Config key (${VALID_KEYS.join(", ")})`,
52
+ required: true
53
+ },
54
+ value: {
55
+ type: "positional",
56
+ description: "Value to set",
57
+ required: true
58
+ },
59
+ ...OUTPUT_ARGS
60
+ },
61
+ async run({ args }) {
62
+ applyOutputMode(args);
63
+ if (!VALID_KEYS.includes(args.key)) {
64
+ logger.error(`Invalid key: ${args.key}`);
65
+ logger.info(`Valid keys: ${VALID_KEYS.join(", ")}`);
66
+ process.exit(1);
67
+ }
68
+ const config = await loadConfig();
69
+ const value = NUMERIC_KEYS.has(args.key) ? Number(args.value) : args.value;
70
+ if (NUMERIC_KEYS.has(args.key) && !Number.isFinite(value)) {
71
+ logger.error(`Invalid numeric value for ${args.key}: ${args.value}`);
72
+ process.exit(1);
73
+ }
74
+ config[args.key] = value;
75
+ await saveConfig(config);
76
+ logger.success(`Set ${args.key} = ${value}`);
77
+ }
78
+ });
79
+ const unsetCommand = defineCommand({
80
+ meta: {
81
+ name: "unset",
82
+ description: "Remove a config value"
83
+ },
84
+ args: {
85
+ key: {
86
+ type: "positional",
87
+ description: `Config key to remove (${VALID_KEYS.join(", ")})`,
88
+ required: true
89
+ },
90
+ ...OUTPUT_ARGS
91
+ },
92
+ async run({ args }) {
93
+ applyOutputMode(args);
94
+ if (!VALID_KEYS.includes(args.key)) {
95
+ logger.error(`Invalid key: ${args.key}`);
96
+ logger.info(`Valid keys: ${VALID_KEYS.join(", ")}`);
97
+ process.exit(1);
98
+ }
99
+ const config = await loadConfig();
100
+ delete config[args.key];
101
+ await saveConfig(config);
102
+ logger.success(`Removed ${args.key}`);
103
+ }
104
+ });
105
+ const pathCommand = defineCommand({
106
+ meta: {
107
+ name: "path",
108
+ description: "Show config file path"
109
+ },
110
+ run() {
111
+ console.log(getConfigPath());
112
+ }
113
+ });
114
+ const validateCommand = defineCommand({
115
+ meta: {
116
+ name: "validate",
117
+ description: "Validate the saved config (defaultSite is verified, dataDir exists/writable)"
118
+ },
119
+ args: { ...OUTPUT_ARGS },
120
+ async run({ args }) {
121
+ const { json } = applyOutputMode(args);
122
+ const { resolveDataDir } = await import("../config.mjs");
123
+ const fs = await import("node:fs/promises");
124
+ const config = await loadConfig();
125
+ const issues = [];
126
+ const dataDir = resolveDataDir(config);
127
+ const dataDirDisplay = displayPath(dataDir);
128
+ const stat = await fs.stat(dataDir).catch(() => null);
129
+ if (stat && !stat.isDirectory()) issues.push({
130
+ key: "dataDir",
131
+ level: "fail",
132
+ message: `${dataDirDisplay} is not a directory`
133
+ });
134
+ else if (stat) {
135
+ const probe = `${dataDir}/.gscdump-config-probe`;
136
+ if (!await fs.writeFile(probe, "").then(() => fs.rm(probe)).then(() => true).catch(() => false)) issues.push({
137
+ key: "dataDir",
138
+ level: "fail",
139
+ message: `${dataDirDisplay} not writable`
140
+ });
141
+ } else issues.push({
142
+ key: "dataDir",
143
+ level: "warn",
144
+ message: `${dataDirDisplay} does not exist (will be created on first sync)`
145
+ });
146
+ if (config.defaultSite) {
147
+ if (!!config.clientId && !!config.clientSecret) {
148
+ const { createCommandContext } = await import("../context.mjs");
149
+ const ctx = await createCommandContext({ needsAuth: true }).catch(() => null);
150
+ if (ctx) {
151
+ const sites = await ctx.loadSites().catch(() => null);
152
+ if (sites && !sites.some((s) => s.siteUrl === config.defaultSite || s.siteUrl.includes(String(config.defaultSite)))) issues.push({
153
+ key: "defaultSite",
154
+ level: "fail",
155
+ message: `${config.defaultSite} is not in the verified site list`
156
+ });
157
+ }
158
+ } else issues.push({
159
+ key: "defaultSite",
160
+ level: "warn",
161
+ message: "set, but auth not configured — skipping verification"
162
+ });
163
+ }
164
+ if (config.defaultFormat && !["json", "csv"].includes(config.defaultFormat)) issues.push({
165
+ key: "defaultFormat",
166
+ level: "fail",
167
+ message: `unknown format: ${config.defaultFormat}`
168
+ });
169
+ const { SearchTypes } = await import("gscdump/query");
170
+ const allowedSearchTypes = Object.values(SearchTypes);
171
+ if (config.defaultSearchType && !allowedSearchTypes.includes(config.defaultSearchType)) issues.push({
172
+ key: "defaultSearchType",
173
+ level: "fail",
174
+ message: `unknown search type: ${config.defaultSearchType} (allowed: ${allowedSearchTypes.join(", ")})`
175
+ });
176
+ const allowedDataStates = [
177
+ "all",
178
+ "final",
179
+ "hourly_all"
180
+ ];
181
+ if (config.defaultDataState && !allowedDataStates.includes(config.defaultDataState)) issues.push({
182
+ key: "defaultDataState",
183
+ level: "fail",
184
+ message: `unknown data state: ${config.defaultDataState} (allowed: ${allowedDataStates.join(", ")})`
185
+ });
186
+ if (config.serviceAccountPath) {
187
+ if (!await fs.stat(config.serviceAccountPath).catch(() => null)) issues.push({
188
+ key: "serviceAccountPath",
189
+ level: "fail",
190
+ message: `${displayPath(config.serviceAccountPath)} does not exist`
191
+ });
192
+ }
193
+ if (json) {
194
+ console.log(JSON.stringify({
195
+ ok: !issues.some((i) => i.level === "fail"),
196
+ issues
197
+ }, null, 2));
198
+ return;
199
+ }
200
+ if (issues.length === 0) {
201
+ logger.success("Config OK");
202
+ return;
203
+ }
204
+ for (const i of issues) {
205
+ const prefix = i.level === "fail" ? "\x1B[31m✗\x1B[0m" : "\x1B[33m!\x1B[0m";
206
+ console.log(` ${prefix} ${i.key}: ${i.message}`);
207
+ }
208
+ if (issues.some((i) => i.level === "fail")) process.exit(1);
209
+ }
210
+ });
43
211
  const configCommand = defineCommand({
44
212
  meta: {
45
213
  name: "config",
@@ -47,172 +215,10 @@ const configCommand = defineCommand({
47
215
  },
48
216
  subCommands: {
49
217
  show: showCommand,
50
- set: defineCommand({
51
- meta: {
52
- name: "set",
53
- description: "Set a config value"
54
- },
55
- args: {
56
- key: {
57
- type: "positional",
58
- description: `Config key (${VALID_KEYS.join(", ")})`,
59
- required: true
60
- },
61
- value: {
62
- type: "positional",
63
- description: "Value to set",
64
- required: true
65
- },
66
- ...OUTPUT_ARGS
67
- },
68
- async run({ args }) {
69
- applyOutputMode(args);
70
- if (!VALID_KEYS.includes(args.key)) {
71
- logger.error(`Invalid key: ${args.key}`);
72
- logger.info(`Valid keys: ${VALID_KEYS.join(", ")}`);
73
- process.exit(1);
74
- }
75
- const config = await loadConfig();
76
- const value = NUMERIC_KEYS.has(args.key) ? Number(args.value) : args.value;
77
- if (NUMERIC_KEYS.has(args.key) && !Number.isFinite(value)) {
78
- logger.error(`Invalid numeric value for ${args.key}: ${args.value}`);
79
- process.exit(1);
80
- }
81
- config[args.key] = value;
82
- await saveConfig(config);
83
- logger.success(`Set ${args.key} = ${value}`);
84
- }
85
- }),
86
- unset: defineCommand({
87
- meta: {
88
- name: "unset",
89
- description: "Remove a config value"
90
- },
91
- args: {
92
- key: {
93
- type: "positional",
94
- description: `Config key to remove (${VALID_KEYS.join(", ")})`,
95
- required: true
96
- },
97
- ...OUTPUT_ARGS
98
- },
99
- async run({ args }) {
100
- applyOutputMode(args);
101
- if (!VALID_KEYS.includes(args.key)) {
102
- logger.error(`Invalid key: ${args.key}`);
103
- logger.info(`Valid keys: ${VALID_KEYS.join(", ")}`);
104
- process.exit(1);
105
- }
106
- const config = await loadConfig();
107
- delete config[args.key];
108
- await saveConfig(config);
109
- logger.success(`Removed ${args.key}`);
110
- }
111
- }),
112
- path: defineCommand({
113
- meta: {
114
- name: "path",
115
- description: "Show config file path"
116
- },
117
- run() {
118
- console.log(getConfigPath());
119
- }
120
- }),
121
- validate: defineCommand({
122
- meta: {
123
- name: "validate",
124
- description: "Validate the saved config (defaultSite is verified, dataDir exists/writable)"
125
- },
126
- args: { ...OUTPUT_ARGS },
127
- async run({ args }) {
128
- const { json } = applyOutputMode(args);
129
- const { resolveDataDir } = await import("../config.mjs");
130
- const fs = await import("node:fs/promises");
131
- const config = await loadConfig();
132
- const issues = [];
133
- const dataDir = resolveDataDir(config);
134
- const dataDirDisplay = displayPath(dataDir);
135
- const stat = await fs.stat(dataDir).catch(() => null);
136
- if (stat && !stat.isDirectory()) issues.push({
137
- key: "dataDir",
138
- level: "fail",
139
- message: `${dataDirDisplay} is not a directory`
140
- });
141
- else if (stat) {
142
- const probe = `${dataDir}/.gscdump-config-probe`;
143
- if (!await fs.writeFile(probe, "").then(() => fs.rm(probe)).then(() => true).catch(() => false)) issues.push({
144
- key: "dataDir",
145
- level: "fail",
146
- message: `${dataDirDisplay} not writable`
147
- });
148
- } else issues.push({
149
- key: "dataDir",
150
- level: "warn",
151
- message: `${dataDirDisplay} does not exist (will be created on first sync)`
152
- });
153
- if (config.defaultSite) if (!!config.clientId && !!config.clientSecret) {
154
- const { createCommandContext } = await import("../context.mjs");
155
- const ctx = await createCommandContext({ needsAuth: true }).catch(() => null);
156
- if (ctx) {
157
- const sites = await ctx.loadSites().catch(() => null);
158
- if (sites && !sites.some((s) => s.siteUrl === config.defaultSite || s.siteUrl.includes(String(config.defaultSite)))) issues.push({
159
- key: "defaultSite",
160
- level: "fail",
161
- message: `${config.defaultSite} is not in the verified site list`
162
- });
163
- }
164
- } else issues.push({
165
- key: "defaultSite",
166
- level: "warn",
167
- message: "set, but auth not configured — skipping verification"
168
- });
169
- if (config.defaultFormat && !["json", "csv"].includes(config.defaultFormat)) issues.push({
170
- key: "defaultFormat",
171
- level: "fail",
172
- message: `unknown format: ${config.defaultFormat}`
173
- });
174
- const { SearchTypes } = await import("gscdump/query");
175
- const allowedSearchTypes = Object.values(SearchTypes);
176
- if (config.defaultSearchType && !allowedSearchTypes.includes(config.defaultSearchType)) issues.push({
177
- key: "defaultSearchType",
178
- level: "fail",
179
- message: `unknown search type: ${config.defaultSearchType} (allowed: ${allowedSearchTypes.join(", ")})`
180
- });
181
- const allowedDataStates = [
182
- "all",
183
- "final",
184
- "hourly_all"
185
- ];
186
- if (config.defaultDataState && !allowedDataStates.includes(config.defaultDataState)) issues.push({
187
- key: "defaultDataState",
188
- level: "fail",
189
- message: `unknown data state: ${config.defaultDataState} (allowed: ${allowedDataStates.join(", ")})`
190
- });
191
- if (config.serviceAccountPath) {
192
- if (!await fs.stat(config.serviceAccountPath).catch(() => null)) issues.push({
193
- key: "serviceAccountPath",
194
- level: "fail",
195
- message: `${displayPath(config.serviceAccountPath)} does not exist`
196
- });
197
- }
198
- if (json) {
199
- console.log(JSON.stringify({
200
- ok: !issues.some((i) => i.level === "fail"),
201
- issues
202
- }, null, 2));
203
- return;
204
- }
205
- if (issues.length === 0) {
206
- logger.success("Config OK");
207
- return;
208
- }
209
- for (const i of issues) {
210
- const prefix = i.level === "fail" ? "\x1B[31m✗\x1B[0m" : "\x1B[33m!\x1B[0m";
211
- console.log(` ${prefix} ${i.key}: ${i.message}`);
212
- }
213
- if (issues.some((i) => i.level === "fail")) process.exit(1);
214
- }
215
- })
218
+ set: setCommand,
219
+ unset: unsetCommand,
220
+ path: pathCommand,
221
+ validate: validateCommand
216
222
  },
217
223
  async run({ args }) {
218
224
  if (!noSubcommandSelected("config", [
@@ -17,7 +17,7 @@ const REQUIRED_SCOPES = [
17
17
  "https://www.googleapis.com/auth/siteverification"
18
18
  ];
19
19
  const FETCH_TIMEOUT_MS = 5e3;
20
- const TIME_SKEW_WARN_MS = 5 * 6e4;
20
+ const TIME_SKEW_WARN_MS = 3e5;
21
21
  const WATERMARK_STALE_DAYS_WARN = 7;
22
22
  const RELEVANT_ENV_KEYS = [
23
23
  "GSC_ACCESS_TOKEN",
@@ -176,7 +176,8 @@ async function dumpRowFormat(store, entries, outDir, siteUrl, format) {
176
176
  let files = 0;
177
177
  let totalRows = 0;
178
178
  for (const [table, tableEntries] of byTable) {
179
- const rows = await readParquetRows(tableEntries.map((e) => path.join(store.dataDir, e.objectKey)), table);
179
+ const filePaths = tableEntries.map((e) => path.join(store.dataDir, e.objectKey));
180
+ const rows = await readParquetRows(filePaths, table);
180
181
  const ext = format === "csv" ? "csv" : format === "ndjson" ? "ndjson" : "json";
181
182
  const target = path.join(siteDir, `${table}.${ext}`);
182
183
  let body;