@gscdump/cli 0.37.3 → 0.37.5

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.
@@ -0,0 +1,314 @@
1
+ import { OUTPUT_ARGS, applyOutputMode, logger, readUrlList } from "./utils.mjs";
2
+ import { createCommandContext } from "./context.mjs";
3
+ import { gscErrorHandler } from "./error-handler.mjs";
4
+ import process from "node:process";
5
+ import { defineCommand } from "citty";
6
+ import { batchRequestIndexing, fetchSitemapUrls, getIndexingMetadata, requestIndexing, runSequentialBatch } from "gscdump/api";
7
+ const RETRIES_ARG = { retries: {
8
+ type: "string",
9
+ description: "Override per-call retry count (default: 3)"
10
+ } };
11
+ function parseRetries(v) {
12
+ if (v == null || v === "") return void 0;
13
+ const n = Number.parseInt(String(v), 10);
14
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
15
+ }
16
+ async function resolveUrlSource(args) {
17
+ const fromSitemap = args["from-sitemap"];
18
+ if (fromSitemap) return fetchSitemapUrls(String(fromSitemap)).catch((e) => {
19
+ logger.error(`Sitemap fetch failed: ${e.message}`);
20
+ process.exit(1);
21
+ });
22
+ return readUrlList(args);
23
+ }
24
+ const submitCommand = defineCommand({
25
+ meta: {
26
+ name: "submit",
27
+ description: "Notify Google of a new or updated URL (URL_UPDATED)"
28
+ },
29
+ args: {
30
+ url: {
31
+ type: "positional",
32
+ required: true,
33
+ description: "URL to submit"
34
+ },
35
+ ...OUTPUT_ARGS,
36
+ ...RETRIES_ARG
37
+ },
38
+ async run({ args }) {
39
+ applyOutputMode(args);
40
+ const result = await requestIndexing((await createCommandContext({
41
+ needsAuth: true,
42
+ fetchOptions: { retry: parseRetries(args.retries) }
43
+ })).client, args.url, { type: "URL_UPDATED" }).catch(gscErrorHandler);
44
+ if (args.json) {
45
+ console.log(JSON.stringify(result, null, 2));
46
+ return;
47
+ }
48
+ logger.success(`Submitted: ${result.url}`);
49
+ if (result.notifyTime) console.log(` Notified: ${result.notifyTime}`);
50
+ }
51
+ });
52
+ const removeCommand = defineCommand({
53
+ meta: {
54
+ name: "remove",
55
+ description: "Notify Google a URL has been removed (URL_DELETED)"
56
+ },
57
+ args: {
58
+ url: {
59
+ type: "positional",
60
+ required: true,
61
+ description: "URL to mark removed"
62
+ },
63
+ ...OUTPUT_ARGS,
64
+ ...RETRIES_ARG
65
+ },
66
+ async run({ args }) {
67
+ applyOutputMode(args);
68
+ const result = await requestIndexing((await createCommandContext({
69
+ needsAuth: true,
70
+ fetchOptions: { retry: parseRetries(args.retries) }
71
+ })).client, args.url, { type: "URL_DELETED" }).catch(gscErrorHandler);
72
+ if (args.json) {
73
+ console.log(JSON.stringify(result, null, 2));
74
+ return;
75
+ }
76
+ logger.success(`Removed: ${result.url}`);
77
+ if (result.notifyTime) console.log(` Notified: ${result.notifyTime}`);
78
+ }
79
+ });
80
+ const statusCommand = defineCommand({
81
+ meta: {
82
+ name: "status",
83
+ description: "Show indexing notification metadata for a URL"
84
+ },
85
+ args: {
86
+ url: {
87
+ type: "positional",
88
+ required: true,
89
+ description: "URL to inspect"
90
+ },
91
+ json: {
92
+ type: "boolean",
93
+ default: false,
94
+ description: "Output as JSON"
95
+ },
96
+ quiet: {
97
+ type: "boolean",
98
+ alias: "q",
99
+ default: false,
100
+ description: "Suppress info/success output"
101
+ }
102
+ },
103
+ async run({ args }) {
104
+ applyOutputMode(args);
105
+ const meta = await getIndexingMetadata((await createCommandContext({ needsAuth: true })).client, args.url).catch(gscErrorHandler);
106
+ if (args.json) {
107
+ console.log(JSON.stringify(meta, null, 2));
108
+ return;
109
+ }
110
+ const fmtNotification = (n) => {
111
+ if (!n?.notifyTime) return "never";
112
+ return n.type ? `${n.notifyTime} (${n.type})` : n.notifyTime;
113
+ };
114
+ const update = meta.latestUpdate;
115
+ const remove = meta.latestRemove;
116
+ console.log();
117
+ console.log(` \x1B[1mURL:\x1B[0m ${meta.url}`);
118
+ console.log(` Last update notify: ${fmtNotification(update)}`);
119
+ console.log(` Last remove notify: ${fmtNotification(remove)}`);
120
+ console.log();
121
+ }
122
+ });
123
+ const INDEXING_DAILY_QUOTA = 200;
124
+ const INDEXING_PER_MINUTE_QUOTA = 600;
125
+ const quotaCommand = defineCommand({
126
+ meta: {
127
+ name: "quota",
128
+ description: "Show documented Indexing API quotas (no live counters; quota usage is not exposed by the API)"
129
+ },
130
+ args: { ...OUTPUT_ARGS },
131
+ async run({ args }) {
132
+ const { json } = applyOutputMode(args);
133
+ const payload = {
134
+ perDay: INDEXING_DAILY_QUOTA,
135
+ perMinute: INDEXING_PER_MINUTE_QUOTA,
136
+ note: "Documented defaults. Google does not expose live counters; track yours by counting submit calls.",
137
+ docs: "https://developers.google.com/search/apis/indexing-api/v3/quota-pricing"
138
+ };
139
+ if (json) {
140
+ console.log(JSON.stringify(payload, null, 2));
141
+ return;
142
+ }
143
+ console.log();
144
+ console.log(` \x1B[1mIndexing API quota\x1B[0m`);
145
+ console.log(` Per day: ${payload.perDay}`);
146
+ console.log(` Per minute: ${payload.perMinute}`);
147
+ console.log();
148
+ console.log(` \x1B[90m${payload.note}\x1B[0m`);
149
+ console.log(` \x1B[90mDocs: ${payload.docs}\x1B[0m`);
150
+ console.log();
151
+ }
152
+ });
153
+ const indexingCommand = defineCommand({
154
+ meta: {
155
+ name: "indexing",
156
+ description: "Notify Google about URL updates/removals (Indexing API)"
157
+ },
158
+ subCommands: {
159
+ "submit": submitCommand,
160
+ "remove": removeCommand,
161
+ "status": statusCommand,
162
+ "batch": defineCommand({
163
+ meta: {
164
+ name: "batch",
165
+ description: "Submit many URLs from a file or stdin (one URL per line)"
166
+ },
167
+ args: {
168
+ ...OUTPUT_ARGS,
169
+ "urls": {
170
+ type: "positional",
171
+ required: false,
172
+ description: "URLs (or use --file/--from-sitemap/stdin)"
173
+ },
174
+ "file": {
175
+ type: "string",
176
+ alias: "f",
177
+ description: "File with URLs (one per line)"
178
+ },
179
+ "from-sitemap": {
180
+ type: "string",
181
+ description: "Sitemap URL (or sitemap index) to pull URLs from"
182
+ },
183
+ "type": {
184
+ type: "string",
185
+ default: "URL_UPDATED",
186
+ description: "URL_UPDATED or URL_DELETED"
187
+ },
188
+ "delay-ms": {
189
+ type: "string",
190
+ default: "100",
191
+ description: "Delay between requests"
192
+ },
193
+ "concurrency": {
194
+ type: "string",
195
+ alias: "c",
196
+ default: "1",
197
+ description: "Concurrent in-flight requests"
198
+ },
199
+ "yes": {
200
+ type: "boolean",
201
+ alias: "y",
202
+ default: false,
203
+ description: "Skip the over-quota confirmation prompt"
204
+ },
205
+ "retries": {
206
+ type: "string",
207
+ description: "Override per-call retry count (default: 3)"
208
+ }
209
+ },
210
+ async run({ args }) {
211
+ applyOutputMode(args);
212
+ const urls = await resolveUrlSource(args);
213
+ if (urls.length === 0) {
214
+ logger.error("No URLs provided. Pass URLs as args, --file, --from-sitemap, or stdin.");
215
+ process.exit(1);
216
+ }
217
+ const type = String(args.type);
218
+ if (type !== "URL_UPDATED" && type !== "URL_DELETED") {
219
+ logger.error(`Invalid --type: ${type}. Use URL_UPDATED or URL_DELETED.`);
220
+ process.exit(1);
221
+ }
222
+ if (urls.length > INDEXING_DAILY_QUOTA && !args.yes && !args.json) {
223
+ logger.warn(`Submitting ${urls.length} URLs but the Indexing API daily quota is ${INDEXING_DAILY_QUOTA}/day.`);
224
+ logger.warn(`Excess submissions will fail with quota errors. Pass --yes to proceed anyway.`);
225
+ process.exit(1);
226
+ }
227
+ if (urls.length > INDEXING_DAILY_QUOTA && !args.json && !args.quiet) logger.warn(`Proceeding with ${urls.length} URLs (over the ${INDEXING_DAILY_QUOTA}/day quota). Excess will fail.`);
228
+ const ctx = await createCommandContext({
229
+ needsAuth: true,
230
+ fetchOptions: { retry: parseRetries(args.retries) }
231
+ });
232
+ const delayMs = Number.parseInt(String(args["delay-ms"]), 10);
233
+ const concurrency = Math.max(1, Number.parseInt(String(args.concurrency), 10) || 1);
234
+ if (!args.json && !args.quiet) logger.info(`Submitting ${urls.length} URLs (${type}) ...`);
235
+ const results = await batchRequestIndexing(ctx.client, urls, {
236
+ type,
237
+ delayMs,
238
+ concurrency,
239
+ onProgress: args.json || args.quiet ? void 0 : (r, i, total) => logger.info(`[${i + 1}/${total}] ${r.url}`)
240
+ }).catch(gscErrorHandler);
241
+ if (args.json) {
242
+ console.log(JSON.stringify(results, null, 2));
243
+ return;
244
+ }
245
+ if (!args.quiet) logger.success(`Submitted ${results.length}/${urls.length} URLs`);
246
+ }
247
+ }),
248
+ "batch-status": defineCommand({
249
+ meta: {
250
+ name: "batch-status",
251
+ description: "Get indexing notification metadata for many URLs"
252
+ },
253
+ args: {
254
+ ...OUTPUT_ARGS,
255
+ "urls": {
256
+ type: "positional",
257
+ required: false,
258
+ description: "URLs (or use --file/--from-sitemap/stdin)"
259
+ },
260
+ "file": {
261
+ type: "string",
262
+ alias: "f",
263
+ description: "File with URLs (one per line)"
264
+ },
265
+ "from-sitemap": {
266
+ type: "string",
267
+ description: "Sitemap URL (or sitemap index) to pull URLs from"
268
+ },
269
+ "delay-ms": {
270
+ type: "string",
271
+ default: "100",
272
+ description: "Delay between requests"
273
+ },
274
+ "concurrency": {
275
+ type: "string",
276
+ alias: "c",
277
+ default: "1",
278
+ description: "Concurrent in-flight requests"
279
+ },
280
+ "retries": {
281
+ type: "string",
282
+ description: "Override per-call retry count (default: 3)"
283
+ }
284
+ },
285
+ async run({ args }) {
286
+ applyOutputMode(args);
287
+ const urls = await resolveUrlSource(args);
288
+ if (urls.length === 0) {
289
+ logger.error("No URLs provided. Pass URLs as args, --file, --from-sitemap, or stdin.");
290
+ process.exit(1);
291
+ }
292
+ const ctx = await createCommandContext({
293
+ needsAuth: true,
294
+ fetchOptions: { retry: parseRetries(args.retries) }
295
+ });
296
+ const delayMs = Number.parseInt(String(args["delay-ms"]), 10);
297
+ const concurrency = Math.max(1, Number.parseInt(String(args.concurrency), 10) || 1);
298
+ if (!args.json && !args.quiet) logger.info(`Fetching status for ${urls.length} URLs ...`);
299
+ const results = await runSequentialBatch(urls, (url) => getIndexingMetadata(ctx.client, url), {
300
+ delayMs,
301
+ concurrency,
302
+ onProgress: args.json || args.quiet ? void 0 : (r, i, total) => logger.info(`[${i + 1}/${total}] ${r.url}`)
303
+ }).catch(gscErrorHandler);
304
+ if (args.json) {
305
+ console.log(JSON.stringify(results, null, 2));
306
+ return;
307
+ }
308
+ if (!args.quiet) logger.success(`Fetched ${results.length}/${urls.length} URLs`);
309
+ }
310
+ }),
311
+ "quota": quotaCommand
312
+ }
313
+ });
314
+ export { indexingCommand };
@@ -0,0 +1,190 @@
1
+ import { defaultDataDir, loadConfig, saveConfig } from "./config.mjs";
2
+ import { applyCliEnvironment } from "./environment.mjs";
3
+ import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "./utils.mjs";
4
+ import { authenticate, getAuthCredentials, loadTokens, resolveBYOK, saveTokens } from "./auth.mjs";
5
+ import process from "node:process";
6
+ import { defineCommand } from "citty";
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { confirm, isCancel, text } from "@clack/prompts";
10
+ import { googleSearchConsole } from "gscdump/api";
11
+ const ENV_LINE_RE = /^([^=]+)=(.*)$/;
12
+ async function promptDataDir(existing) {
13
+ const fallback = existing ?? defaultDataDir();
14
+ const answer = await text({
15
+ message: "Where should Parquet data be stored?",
16
+ placeholder: fallback,
17
+ defaultValue: fallback
18
+ });
19
+ if (isCancel(answer)) process.exit(1);
20
+ return String(answer) || fallback;
21
+ }
22
+ async function loadEnvFile() {
23
+ const envPath = path.join(process.cwd(), ".env");
24
+ const content = await fs.readFile(envPath, "utf-8").catch(() => null);
25
+ if (!content) return null;
26
+ const env = {};
27
+ for (const line of content.split("\n")) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed || trimmed.startsWith("#")) continue;
30
+ const match = trimmed.match(ENV_LINE_RE);
31
+ if (match) {
32
+ const key = match[1].trim();
33
+ let value = match[2].trim();
34
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
35
+ env[key] = value;
36
+ }
37
+ }
38
+ return env;
39
+ }
40
+ const initCommand = defineCommand({
41
+ meta: {
42
+ name: "init",
43
+ description: "Set up GSCDump authentication"
44
+ },
45
+ args: {
46
+ "force": {
47
+ type: "boolean",
48
+ alias: "f",
49
+ description: "Force re-initialization"
50
+ },
51
+ "no-store": {
52
+ type: "boolean",
53
+ default: false,
54
+ description: "Skip dataDir prompt (auth-only setup)"
55
+ },
56
+ ...OUTPUT_ARGS
57
+ },
58
+ async run({ args }) {
59
+ applyOutputMode(args);
60
+ const config = await loadConfig();
61
+ if (config.clientId && config.clientSecret && !args.force) {
62
+ logger.info("Already configured");
63
+ const tokens = await loadTokens();
64
+ if (!tokens) logger.info("No saved tokens. Run `gscdump auth login` to authenticate.");
65
+ else {
66
+ const isExpired = tokens.expiry_date && tokens.expiry_date < Date.now();
67
+ if (isExpired && tokens.refresh_token) logger.info("Tokens expired but refresh available. Any live command will auto-refresh, or run `gscdump auth refresh`.");
68
+ else if (isExpired) logger.warn("Tokens expired without a refresh token. Run `gscdump auth login` to re-authenticate.");
69
+ else logger.info("Next: `gscdump sites` to list properties, or `gscdump query --help`.");
70
+ }
71
+ logger.info("Run `gscdump init --force` to reconfigure.");
72
+ return;
73
+ }
74
+ const byok = resolveBYOK();
75
+ if (byok) {
76
+ const dataDir = args["no-store"] ? void 0 : await promptDataDir(config.dataDir);
77
+ await saveConfig({
78
+ ...config,
79
+ dataDir: dataDir ?? config.dataDir
80
+ });
81
+ logger.success(`BYOK detected (${typeof byok === "string" ? "access-token" : "refresh-token"}) — auth setup skipped`);
82
+ logger.success("Setup complete! Run gscdump to get started.");
83
+ return;
84
+ }
85
+ const envFile = await loadEnvFile();
86
+ const envCid = envFile?.GSC_CLIENT_ID ?? envFile?.GOOGLE_CLIENT_ID;
87
+ const envSec = envFile?.GSC_CLIENT_SECRET ?? envFile?.GOOGLE_CLIENT_SECRET;
88
+ const envRef = envFile?.GSC_REFRESH_TOKEN ?? envFile?.GOOGLE_REFRESH_TOKEN;
89
+ const envAcc = envFile?.GSC_ACCESS_TOKEN ?? envFile?.GOOGLE_ACCESS_TOKEN;
90
+ if (envCid && envSec && envRef) {
91
+ logger.info("Found .env file with credentials");
92
+ applyCliEnvironment({
93
+ GOOGLE_ACCESS_TOKEN: envAcc,
94
+ GOOGLE_CLIENT_ID: envCid,
95
+ GOOGLE_CLIENT_SECRET: envSec,
96
+ GOOGLE_REFRESH_TOKEN: envRef
97
+ });
98
+ await saveConfig({
99
+ ...config,
100
+ clientId: envCid,
101
+ clientSecret: envSec,
102
+ dataDir: config.dataDir ?? defaultDataDir()
103
+ });
104
+ const creds = (await authenticate({
105
+ clientId: envCid,
106
+ clientSecret: envSec
107
+ }, false)).credentials;
108
+ if (creds.access_token) await saveTokens({
109
+ access_token: creds.access_token,
110
+ refresh_token: creds.refresh_token || envRef,
111
+ expiry_date: creds.expiry_date
112
+ });
113
+ console.log();
114
+ logger.success("Setup complete using .env credentials! Run gscdump to get started.");
115
+ return;
116
+ }
117
+ console.log();
118
+ console.log(" \x1B[1mWelcome to GSCDump!\x1B[0m");
119
+ console.log(" \x1B[90mGoogle Search Console data extraction CLI\x1B[0m");
120
+ console.log();
121
+ const dataDir = args["no-store"] ? void 0 : await promptDataDir(config.dataDir);
122
+ const credentials = await getAuthCredentials(true);
123
+ await saveConfig({
124
+ ...config,
125
+ ...dataDir ? { dataDir } : {},
126
+ clientId: credentials.clientId,
127
+ clientSecret: credentials.clientSecret
128
+ });
129
+ await smokeTest(await authenticate(credentials, true));
130
+ await maybeWriteEnvFile(credentials.clientId, credentials.clientSecret);
131
+ console.log();
132
+ logger.success("Setup complete! Run gscdump to get started.");
133
+ }
134
+ });
135
+ async function smokeTest(oauth) {
136
+ await runSmokeTest(oauth);
137
+ }
138
+ async function runSmokeTest(oauth) {
139
+ const sites = await googleSearchConsole(oauth).sites().catch((e) => e);
140
+ if (sites instanceof Error) {
141
+ const msg = sites.message;
142
+ const apiDisabledMatch = msg.match(/projects?\/(\d+)/) ?? msg.match(/project (\d+)/);
143
+ if (/has not been used in project|API has not been used|SERVICE_DISABLED/i.test(msg)) {
144
+ logger.error("Search Console API is not enabled for this Google Cloud project.");
145
+ const project = apiDisabledMatch?.[1];
146
+ const url = project ? `https://console.developers.google.com/apis/api/searchconsole.googleapis.com/overview?project=${project}` : "https://console.developers.google.com/apis/api/searchconsole.googleapis.com/overview";
147
+ logger.info(`Enable it here, then retry: ${url}`);
148
+ logger.info("Note: it can take a few minutes to propagate after enabling.");
149
+ return;
150
+ }
151
+ if (/insufficient|scope|forbidden|403/i.test(msg)) {
152
+ logger.warn(`Smoke test failed (likely missing scopes): ${msg}`);
153
+ logger.info("Run `gscdump auth login --force` to re-consent with the required scopes.");
154
+ return;
155
+ }
156
+ logger.warn(`Smoke test failed: ${msg}`);
157
+ logger.info("Auth saved, but verify via `gscdump auth status` / `gscdump doctor`.");
158
+ return;
159
+ }
160
+ logger.success(`Verified: ${sites.length} GSC site(s) accessible`);
161
+ }
162
+ async function maybeWriteEnvFile(clientId, clientSecret) {
163
+ const tokens = await loadTokens();
164
+ if (!tokens?.refresh_token) return;
165
+ const wants = await confirm({
166
+ message: "Write a `.env` file with these credentials? (handy for CI / other machines)",
167
+ initialValue: false
168
+ });
169
+ if (isCancel(wants) || !wants) return;
170
+ const envPath = path.join(process.cwd(), ".env");
171
+ if (await fs.stat(envPath).then(() => true).catch(() => false)) {
172
+ const overwrite = await confirm({
173
+ message: `${displayPath(envPath)} exists. Overwrite?`,
174
+ initialValue: false
175
+ });
176
+ if (isCancel(overwrite) || !overwrite) {
177
+ logger.info(`Skipped — keep credentials at ${displayPath(envPath)} manually if needed`);
178
+ return;
179
+ }
180
+ }
181
+ const content = [
182
+ `GSC_CLIENT_ID=${clientId}`,
183
+ `GSC_CLIENT_SECRET=${clientSecret}`,
184
+ `GSC_REFRESH_TOKEN=${tokens.refresh_token}`,
185
+ ""
186
+ ].join("\n");
187
+ await fs.writeFile(envPath, content, { mode: 384 });
188
+ logger.success(`Wrote ${displayPath(envPath)}`);
189
+ }
190
+ export { initCommand, runSmokeTest };