@gscdump/cli 3.6.2 → 3.7.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 +13 -6
- package/dist/auth-scopes.mjs +4 -4
- package/dist/auth.mjs +39 -2
- package/dist/cli-args.mjs +63 -0
- package/dist/cli.mjs +3 -0
- package/dist/commands/auth.mjs +3 -7
- package/dist/commands/doctor.mjs +1 -1
- package/dist/commands/init.mjs +4 -7
- package/dist/commands/query.mjs +72 -16
- package/dist/commands/stats.mjs +12 -14
- package/dist/commands/store.mjs +1 -0
- package/dist/commands/sync.mjs +32 -6
- package/dist/config.mjs +6 -1
- package/dist/error-handler.mjs +1 -1
- package/dist/hosted-auth.mjs +55 -0
- package/dist/local-store.mjs +13 -2
- package/dist/package.mjs +1 -1
- package/package.json +6 -6
- package/skills/gscdump/SKILL.md +53 -12
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ npx @gscdump/cli
|
|
|
20
20
|
## Quick start
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
#
|
|
23
|
+
# Connect Google for free local CLI use
|
|
24
24
|
gscdump init --mode local
|
|
25
25
|
gscdump auth login --mode local
|
|
26
26
|
|
|
@@ -280,16 +280,22 @@ This section covers local Google credentials. See [shared authentication](#hoste
|
|
|
280
280
|
Credentials are saved under `~/.config/gscdump/` on XDG systems, or the platform equivalent.
|
|
281
281
|
Use `gscdump auth login --mode local` to connect Google and save local mode.
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
If you configure your own Google OAuth client, browser login uses a temporary listener on `127.0.0.1` with a random port.
|
|
284
284
|
Each attempt uses state validation and PKCE S256 to bind the authorization response to that attempt.
|
|
285
285
|
The listener closes after authorization, denial, or a five-minute timeout.
|
|
286
286
|
|
|
287
|
-
|
|
287
|
+
By default, login opens gscdump.com. No Google Cloud project is required.
|
|
288
|
+
The platform handles Google login and token refresh. Data queries call Google directly.
|
|
289
|
+
This grants read-only Search Console access. It does not activate hosted sync, storage, or hosted MCP.
|
|
290
|
+
Hosted Pro is free during beta and will require payment after launch.
|
|
291
|
+
For Google write operations, configure your own OAuth client with the required scopes.
|
|
292
|
+
|
|
293
|
+
For your own OAuth client:
|
|
288
294
|
|
|
289
295
|
1. Create a Google Cloud project.
|
|
290
296
|
2. Enable **Search Console API**, **Web Search Indexing API**, and **Site Verification API**.
|
|
291
297
|
3. Create OAuth2 credentials (Desktop app).
|
|
292
|
-
4.
|
|
298
|
+
4. Set `GSC_CLIENT_ID` and `GSC_CLIENT_SECRET` for your Desktop app.
|
|
293
299
|
5. Run `gscdump auth login --mode local` to save local mode.
|
|
294
300
|
|
|
295
301
|
### BYOK (Bring Your Own Key)
|
|
@@ -328,8 +334,9 @@ gscdump auth login --mode local --no-browser
|
|
|
328
334
|
# Open the printed URL in your browser.
|
|
329
335
|
```
|
|
330
336
|
|
|
331
|
-
|
|
332
|
-
|
|
337
|
+
Default login works from a remote terminal without port forwarding. Keep the command running until you approve the browser request.
|
|
338
|
+
|
|
339
|
+
With your own OAuth client, login uses the Desktop application loopback flow.
|
|
333
340
|
|
|
334
341
|
If the CLI runs on another host, forward its printed loopback port before opening the URL.
|
|
335
342
|
Keep the login command running on that host.
|
package/dist/auth-scopes.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { GSC_INDEXING_SCOPE, GSC_SITE_VERIFICATION_SCOPE, GSC_WRITE_SCOPE, hasGoogleScope } from "gscdump/client";
|
|
2
|
-
function missingRequiredScopes(scopes) {
|
|
3
|
-
return [
|
|
1
|
+
import { GSC_INDEXING_SCOPE, GSC_READ_SCOPE, GSC_SITE_VERIFICATION_SCOPE, GSC_WRITE_SCOPE, hasGoogleScope } from "gscdump/client";
|
|
2
|
+
function missingRequiredScopes(scopes, provider) {
|
|
3
|
+
return (provider === "gscdump" ? [GSC_READ_SCOPE] : [
|
|
4
4
|
GSC_WRITE_SCOPE,
|
|
5
5
|
GSC_INDEXING_SCOPE,
|
|
6
6
|
GSC_SITE_VERIFICATION_SCOPE
|
|
7
|
-
].filter((scope) => !hasGoogleScope(scopes, scope));
|
|
7
|
+
]).filter((scope) => !hasGoogleScope(scopes, scope));
|
|
8
8
|
}
|
|
9
9
|
export { missingRequiredScopes };
|
package/dist/auth.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { getConfigDir, loadConfig } from "./config.mjs";
|
|
|
2
2
|
import { pickCliEnvironmentValue, resolveCliEnvironment } from "./environment.mjs";
|
|
3
3
|
import { getAppliedEnvKeys, getLoadedEnvPath } from "./env-file.mjs";
|
|
4
4
|
import { displayPath, logger } from "./utils.mjs";
|
|
5
|
+
import { loginWithPlatform, refreshWithPlatform } from "./hosted-auth.mjs";
|
|
5
6
|
import process from "node:process";
|
|
6
7
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
8
|
import fs from "node:fs/promises";
|
|
@@ -11,6 +12,7 @@ import { err, ok, unwrapResult } from "gscdump/result";
|
|
|
11
12
|
import { text } from "@clack/prompts";
|
|
12
13
|
import { createAuth } from "gscdump/client";
|
|
13
14
|
import { createServer } from "node:http";
|
|
15
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
14
16
|
import { CodeChallengeMethod, JWT, OAuth2Client } from "google-auth-library";
|
|
15
17
|
import open from "open";
|
|
16
18
|
function authErrorToException(error) {
|
|
@@ -261,7 +263,8 @@ async function authenticate(credentials, interactive, opts = {}) {
|
|
|
261
263
|
}
|
|
262
264
|
return oauth2Client;
|
|
263
265
|
}
|
|
264
|
-
const
|
|
266
|
+
const savedTokens = !opts.force ? await loadTokens() : null;
|
|
267
|
+
const existingTokens = savedTokens?.provider === "gscdump" ? null : savedTokens;
|
|
265
268
|
let refreshFailed = false;
|
|
266
269
|
let refreshError = null;
|
|
267
270
|
if (existingTokens) {
|
|
@@ -321,10 +324,44 @@ async function authenticate(credentials, interactive, opts = {}) {
|
|
|
321
324
|
}
|
|
322
325
|
async function getAuth(opts = {}) {
|
|
323
326
|
const { interactive = true, noBrowser = false, force = false } = opts;
|
|
324
|
-
|
|
327
|
+
const env = resolveCliEnvironment();
|
|
328
|
+
const config = opts.config ?? await loadConfig();
|
|
329
|
+
if (env.clientId && env.clientSecret || config.clientId && config.clientSecret) return authenticate(await getAuthCredentials(interactive), interactive, {
|
|
325
330
|
noBrowser,
|
|
326
331
|
force
|
|
327
332
|
});
|
|
333
|
+
let tokens = force ? null : await loadTokens();
|
|
334
|
+
if (tokens?.provider !== "gscdump" || !tokens.refresh_token) {
|
|
335
|
+
if (!interactive) throw new Error("Run `gscdump auth login` to connect Google.");
|
|
336
|
+
tokens = await loginWithPlatform({
|
|
337
|
+
force,
|
|
338
|
+
request: fetch,
|
|
339
|
+
now: Date.now,
|
|
340
|
+
wait: setTimeout$1,
|
|
341
|
+
authorize: async (url) => {
|
|
342
|
+
logger.info(`Open this URL to connect Google:\n${url}`);
|
|
343
|
+
if (!noBrowser) await open(url).catch((error) => logger.warn(`Browser could not open: ${error.message}. Open the URL above.`));
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
await saveTokens(tokens);
|
|
347
|
+
}
|
|
348
|
+
const refreshToken = tokens.refresh_token;
|
|
349
|
+
const client = new OAuth2Client();
|
|
350
|
+
client.refreshHandler = async () => {
|
|
351
|
+
const refreshed = await refreshWithPlatform(refreshToken);
|
|
352
|
+
await saveTokens({
|
|
353
|
+
provider: "gscdump",
|
|
354
|
+
refresh_token: refreshToken,
|
|
355
|
+
...refreshed
|
|
356
|
+
});
|
|
357
|
+
return refreshed;
|
|
358
|
+
};
|
|
359
|
+
client.setCredentials({
|
|
360
|
+
access_token: tokens.access_token,
|
|
361
|
+
expiry_date: tokens.expiry_date
|
|
362
|
+
});
|
|
363
|
+
await client.getAccessToken();
|
|
364
|
+
return client;
|
|
328
365
|
}
|
|
329
366
|
async function resolveAuth(opts = {}) {
|
|
330
367
|
const sa = await resolveServiceAccount({ path: opts.serviceAccount });
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
async function resolve(value) {
|
|
3
|
+
return typeof value === "function" ? value() : value;
|
|
4
|
+
}
|
|
5
|
+
async function checkCliArgs(command, rawArgs, path = "gscdump") {
|
|
6
|
+
const definitions = await resolve(command.args ?? {});
|
|
7
|
+
const options = {
|
|
8
|
+
"help": { type: "boolean" },
|
|
9
|
+
"h": { type: "boolean" },
|
|
10
|
+
"version": { type: "boolean" },
|
|
11
|
+
"no-color": { type: "boolean" }
|
|
12
|
+
};
|
|
13
|
+
for (const [name, definition] of Object.entries(definitions)) {
|
|
14
|
+
if (definition.type === "positional") continue;
|
|
15
|
+
const type = definition.type === "boolean" ? "boolean" : "string";
|
|
16
|
+
const alias = "alias" in definition ? definition.alias : void 0;
|
|
17
|
+
const names = [
|
|
18
|
+
name,
|
|
19
|
+
...typeof alias === "string" ? [alias] : alias ?? [],
|
|
20
|
+
name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()),
|
|
21
|
+
name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
|
|
22
|
+
];
|
|
23
|
+
for (const key of names) {
|
|
24
|
+
options[key] = { type };
|
|
25
|
+
if (type === "boolean" && !key.startsWith("no-")) options[`no-${key}`] = { type };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const { tokens } = parseArgs({
|
|
29
|
+
args: rawArgs,
|
|
30
|
+
options,
|
|
31
|
+
strict: false,
|
|
32
|
+
allowPositionals: true,
|
|
33
|
+
tokens: true
|
|
34
|
+
});
|
|
35
|
+
const subCommands = await resolve(command.subCommands ?? {});
|
|
36
|
+
const positional = tokens.find((token) => token.kind === "positional");
|
|
37
|
+
const hasSubCommands = Object.keys(subCommands).length > 0;
|
|
38
|
+
const boundary = hasSubCommands && positional ? positional.index : rawArgs.length;
|
|
39
|
+
for (const token of tokens) {
|
|
40
|
+
if (token.index >= boundary || token.kind !== "option") continue;
|
|
41
|
+
const option = options[token.name];
|
|
42
|
+
if (!option) {
|
|
43
|
+
const matches = Object.keys(definitions).filter((name) => definitions[name]?.type !== "positional" && name.startsWith(token.name) && name.length - token.name.length <= 2);
|
|
44
|
+
const suggestion = matches.length === 1 ? ` Use --${matches[0]}.` : "";
|
|
45
|
+
return `Unknown option ${token.rawName}.${suggestion} Run ${path} --help.`;
|
|
46
|
+
}
|
|
47
|
+
if (option.type === "string" && (token.value === void 0 || !token.inlineValue && token.value.startsWith("-"))) return `${token.rawName} requires a value. Use ${token.rawName}=VALUE.`;
|
|
48
|
+
}
|
|
49
|
+
if (hasSubCommands && positional?.kind === "positional") {
|
|
50
|
+
let selected = subCommands[positional.value];
|
|
51
|
+
if (!selected) for (const candidate of Object.values(subCommands)) {
|
|
52
|
+
const meta = await resolve((await resolve(candidate))?.meta ?? {});
|
|
53
|
+
if ((typeof meta.alias === "string" ? [meta.alias] : meta.alias ?? []).includes(positional.value)) {
|
|
54
|
+
selected = candidate;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!selected) return `Unknown command ${positional.value}. Run ${path} --help.`;
|
|
59
|
+
const child = await resolve(selected);
|
|
60
|
+
if (child) return checkCliArgs(child, rawArgs.slice(boundary + 1), `${path} ${positional.value}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export { checkCliArgs };
|
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createCliRuntime, runWithCliRuntime, useCliRuntime } from "./runtime.mjs";
|
|
2
2
|
import { parseAuthMode } from "./auth-state.mjs";
|
|
3
|
+
import { checkCliArgs } from "./cli-args.mjs";
|
|
3
4
|
import { CLI_SUBCOMMANDS } from "./command-registry.mjs";
|
|
4
5
|
import { resolveCliEnvironment } from "./environment.mjs";
|
|
5
6
|
import { applyProfileFromCli } from "./commands/profile-selection.mjs";
|
|
@@ -78,6 +79,8 @@ async function runCli(opts = {}) {
|
|
|
78
79
|
if (opts.loadEnv !== false) loadEnvFromCwd();
|
|
79
80
|
const rawArgs = prepareCliArgs(input);
|
|
80
81
|
runtime.rawArgs = [...rawArgs];
|
|
82
|
+
const argumentError = await checkCliArgs(main, rawArgs);
|
|
83
|
+
if (argumentError) throw new Error(argumentError);
|
|
81
84
|
await withConfiguredOutput(() => runMain(main, { rawArgs }));
|
|
82
85
|
});
|
|
83
86
|
}
|
package/dist/commands/auth.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { clearAuthentication, getCloudAccount, parseAuthMode, parseAuthenticatio
|
|
|
3
3
|
import { authCommandMeta } from "../command-meta.mjs";
|
|
4
4
|
import { loadConfig, saveConfig } from "../config.mjs";
|
|
5
5
|
import { OUTPUT_ARGS, applyOutputMode, logger, noSubcommandSelected } from "../utils.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { clearTokens, formatAuthProvenance, getAuth, loadServiceAccount, loadTokens, resolveBYOK, saveTokens } from "../auth.mjs";
|
|
7
7
|
import { missingRequiredScopes } from "../auth-scopes.mjs";
|
|
8
8
|
import { clearBingCredentials, getBingClient, inspectBingCredentials } from "../bing-auth.mjs";
|
|
9
9
|
import { runSmokeTest } from "./init.mjs";
|
|
@@ -64,7 +64,7 @@ async function resolveLiveAuthState() {
|
|
|
64
64
|
else if (tokens?.access_token) liveToken = tokens.access_token;
|
|
65
65
|
const tokenInfo = liveToken ? await fetchTokenInfo(liveToken) : null;
|
|
66
66
|
const scopes = tokenInfo?.scope ? tokenInfo.scope.split(/\s+/).filter(Boolean) : [];
|
|
67
|
-
const missing = missingRequiredScopes(scopes);
|
|
67
|
+
const missing = missingRequiredScopes(scopes, byok ? void 0 : tokens?.provider);
|
|
68
68
|
return {
|
|
69
69
|
byok,
|
|
70
70
|
tokens,
|
|
@@ -250,15 +250,11 @@ const refreshCommand = defineCommand({
|
|
|
250
250
|
logger.error("No saved refresh token. Run `gscdump auth login`.");
|
|
251
251
|
process.exit(1);
|
|
252
252
|
}
|
|
253
|
-
const credentials = await getAuthCredentials(false).catch((e) => {
|
|
254
|
-
logger.error(`Cannot resolve credentials: ${e.message}`);
|
|
255
|
-
process.exit(1);
|
|
256
|
-
});
|
|
257
253
|
await saveTokens({
|
|
258
254
|
...tokens,
|
|
259
255
|
expiry_date: 1
|
|
260
256
|
});
|
|
261
|
-
if ((await
|
|
257
|
+
if ((await getAuth({ interactive: false }).catch((e) => {
|
|
262
258
|
logger.error(`Refresh failed: ${e.message}`);
|
|
263
259
|
process.exit(1);
|
|
264
260
|
})).credentials?.access_token) logger.success("Token refreshed");
|
package/dist/commands/doctor.mjs
CHANGED
|
@@ -141,7 +141,7 @@ async function checkAuth(envKeys) {
|
|
|
141
141
|
detail: info.email
|
|
142
142
|
});
|
|
143
143
|
const scopes = info.scope ? info.scope.split(/\s+/) : [];
|
|
144
|
-
const missing = missingRequiredScopes(scopes);
|
|
144
|
+
const missing = missingRequiredScopes(scopes, byok ? void 0 : tokens?.provider);
|
|
145
145
|
if (missing.length > 0) checks.push({
|
|
146
146
|
name: "auth.scopes",
|
|
147
147
|
status: "warn",
|
package/dist/commands/init.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { initCommandMeta } from "../command-meta.mjs";
|
|
|
2
2
|
import { defaultDataDir, loadConfig, saveConfig } from "../config.mjs";
|
|
3
3
|
import { applyCliEnvironment } from "../environment.mjs";
|
|
4
4
|
import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "../utils.mjs";
|
|
5
|
-
import { authenticate,
|
|
5
|
+
import { authenticate, getAuth, loadTokens, resolveBYOK, saveTokens } from "../auth.mjs";
|
|
6
6
|
import process from "node:process";
|
|
7
7
|
import { defineCommand } from "citty";
|
|
8
8
|
import fs from "node:fs/promises";
|
|
@@ -117,15 +117,12 @@ const initCommand = defineCommand({
|
|
|
117
117
|
console.log(" \x1B[90mGoogle Search Console data extraction CLI\x1B[0m");
|
|
118
118
|
console.log();
|
|
119
119
|
const dataDir = args["no-store"] ? void 0 : await promptDataDir(config.dataDir);
|
|
120
|
-
const credentials = await getAuthCredentials(true);
|
|
121
120
|
await saveConfig({
|
|
122
121
|
...config,
|
|
123
|
-
...dataDir ? { dataDir } : {}
|
|
124
|
-
clientId: credentials.clientId,
|
|
125
|
-
clientSecret: credentials.clientSecret
|
|
122
|
+
...dataDir ? { dataDir } : {}
|
|
126
123
|
});
|
|
127
|
-
await smokeTest(await
|
|
128
|
-
await maybeWriteEnvFile(
|
|
124
|
+
await smokeTest(await getAuth({ interactive: true }));
|
|
125
|
+
if (config.clientId && config.clientSecret) await maybeWriteEnvFile(config.clientId, config.clientSecret);
|
|
129
126
|
console.log();
|
|
130
127
|
logger.success("Setup complete! Run gscdump to get started.");
|
|
131
128
|
}
|
package/dist/commands/query.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { queryCommandMeta } from "../command-meta.mjs";
|
|
|
2
2
|
import { loadConfig } from "../config.mjs";
|
|
3
3
|
import { terminalOutputOptions } from "../render/terminal.mjs";
|
|
4
4
|
import { ALL_SEARCH_TYPES, logger, parseSearchType, toCSV } from "../utils.mjs";
|
|
5
|
-
import { allTables, inferTable } from "../local-store.mjs";
|
|
5
|
+
import { allTables, inferTable, tableDimensions } from "../local-store.mjs";
|
|
6
6
|
import { createCommandContext } from "../context.mjs";
|
|
7
7
|
import { gscErrorHandler } from "../error-handler.mjs";
|
|
8
8
|
import { renderTable } from "../render/layout.mjs";
|
|
@@ -12,6 +12,7 @@ import process from "node:process";
|
|
|
12
12
|
import { defineCommand } from "citty";
|
|
13
13
|
import fs from "node:fs/promises";
|
|
14
14
|
import { and, between, contains, country, date, device, eq, gsc, hour, notRegex, page, query, regex, searchAppearance } from "gscdump/query";
|
|
15
|
+
import { decodeSiteId } from "gscdump/tenant";
|
|
15
16
|
import { cancel, isCancel, multiselect, text } from "@clack/prompts";
|
|
16
17
|
import { daysAgoUtc } from "gscdump/dates";
|
|
17
18
|
import { collectSpans } from "@gscdump/engine/profile";
|
|
@@ -279,7 +280,9 @@ const queryCommand = defineCommand({
|
|
|
279
280
|
needsStore: !args.live,
|
|
280
281
|
interactive: Boolean(args.interactive)
|
|
281
282
|
});
|
|
282
|
-
const
|
|
283
|
+
const hint = args.site ? String(args.site) : ctx.config.defaultSite;
|
|
284
|
+
const exactHint = !args.live && hint && /^(?:sc-domain:\S+|https?:\/\/\S+)$/.test(hint) ? hint : void 0;
|
|
285
|
+
const siteUrl = exactHint ? await resolveLocalSite(ctx.store, exactHint) ?? await ctx.resolveSite(hint) : await ctx.resolveSite(hint);
|
|
283
286
|
if (args.live) {
|
|
284
287
|
if (args.explain) {
|
|
285
288
|
const body = {
|
|
@@ -339,7 +342,7 @@ const queryCommand = defineCommand({
|
|
|
339
342
|
}, null, 2));
|
|
340
343
|
return;
|
|
341
344
|
}
|
|
342
|
-
await assertRangeCovered(store, siteUrl, table, startDate, endDate, searchType);
|
|
345
|
+
await assertRangeCovered(store, siteUrl, table, startDate, endDate, format === "json", searchType);
|
|
343
346
|
const probe = Boolean(args.profile) ? collectSpans() : void 0;
|
|
344
347
|
const result = await store.engine.query({
|
|
345
348
|
userId: store.userId,
|
|
@@ -477,6 +480,14 @@ async function promptFilters(args) {
|
|
|
477
480
|
if (ds && String(ds).length > 0) args["data-state"] = String(ds);
|
|
478
481
|
}
|
|
479
482
|
}
|
|
483
|
+
async function resolveLocalSite(store, hint) {
|
|
484
|
+
const siteIds = new Set((await store.engine.getWatermarks({ userId: store.userId })).map((w) => w.siteId).filter((siteId) => siteId !== void 0));
|
|
485
|
+
const exact = store.siteIdFor(hint);
|
|
486
|
+
if (siteIds.has(exact)) return hint;
|
|
487
|
+
const lowered = exact.toLowerCase();
|
|
488
|
+
const matches = [...siteIds].filter((siteId) => siteId.toLowerCase() === lowered);
|
|
489
|
+
return matches.length === 1 ? decodeSiteId(matches[0]) : void 0;
|
|
490
|
+
}
|
|
480
491
|
function buildLocalState(dimNames, startDate, endDate, rowLimit, dimensionFilter) {
|
|
481
492
|
const dims = dimNames.map((d) => DIM_COLUMNS[d]).filter((c) => Boolean(c));
|
|
482
493
|
const dateFilter = between(date, startDate, endDate);
|
|
@@ -494,25 +505,70 @@ function buildDimensionFilter(args) {
|
|
|
494
505
|
if (leaves.length === 1) return leaves[0];
|
|
495
506
|
return and(...leaves);
|
|
496
507
|
}
|
|
497
|
-
async function assertRangeCovered(store, siteUrl, table, startDate, endDate, searchType) {
|
|
498
|
-
const
|
|
508
|
+
async function assertRangeCovered(store, siteUrl, table, startDate, endDate, json, searchType) {
|
|
509
|
+
const watermarks = await store.engine.getWatermarks({
|
|
499
510
|
userId: store.userId,
|
|
500
511
|
siteId: store.siteIdFor(siteUrl),
|
|
501
512
|
table,
|
|
502
513
|
...searchType !== void 0 ? { searchType } : {}
|
|
503
|
-
})
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
514
|
+
});
|
|
515
|
+
const wm = watermarks[0];
|
|
516
|
+
const states = await store.engine.getSyncStates({
|
|
517
|
+
userId: store.userId,
|
|
518
|
+
siteId: store.siteIdFor(siteUrl),
|
|
519
|
+
table,
|
|
520
|
+
searchType: searchType ?? "web",
|
|
521
|
+
state: "done"
|
|
522
|
+
});
|
|
523
|
+
const completed = new Set(states.map((state) => state.date));
|
|
524
|
+
const missingDates = [];
|
|
525
|
+
for (let date = Date.parse(startDate); date <= Date.parse(endDate); date += 864e5) {
|
|
526
|
+
const day = new Date(date).toISOString().slice(0, 10);
|
|
527
|
+
if (!completed.has(day)) missingDates.push(day);
|
|
511
528
|
}
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
529
|
+
if (missingDates.length === 0) return;
|
|
530
|
+
const nextArgs = [
|
|
531
|
+
"sync",
|
|
532
|
+
"--site",
|
|
533
|
+
siteUrl,
|
|
534
|
+
"--start",
|
|
535
|
+
startDate,
|
|
536
|
+
"--end",
|
|
537
|
+
endDate,
|
|
538
|
+
"--tables",
|
|
539
|
+
table,
|
|
540
|
+
"--types",
|
|
541
|
+
searchType ?? "web",
|
|
542
|
+
"--json"
|
|
543
|
+
];
|
|
544
|
+
const nextCommand = `gscdump ${nextArgs.map((value) => /^[\w:./=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`).join(" ")}`;
|
|
545
|
+
const message = !wm ? `No data synced for ${siteUrl} / ${table}.` : `Store coverage is incomplete for ${missingDates.length} requested dates.`;
|
|
546
|
+
if (json) {
|
|
547
|
+
const available = await store.engine.getWatermarks({
|
|
548
|
+
userId: store.userId,
|
|
549
|
+
siteId: store.siteIdFor(siteUrl)
|
|
550
|
+
});
|
|
551
|
+
console.log(JSON.stringify({ error: {
|
|
552
|
+
code: "STORE_RANGE_NOT_COVERED",
|
|
553
|
+
message,
|
|
554
|
+
siteUrl,
|
|
555
|
+
table,
|
|
556
|
+
range: {
|
|
557
|
+
start: startDate,
|
|
558
|
+
end: endDate
|
|
559
|
+
},
|
|
560
|
+
missingDates,
|
|
561
|
+
watermarks: watermarks.filter((w) => w.table === table),
|
|
562
|
+
availableTables: available.map((w) => ({
|
|
563
|
+
...w,
|
|
564
|
+
dimensions: tableDimensions(w.table)
|
|
565
|
+
})),
|
|
566
|
+
nextArgs,
|
|
567
|
+
nextCommand
|
|
568
|
+
} }, null, 2));
|
|
515
569
|
}
|
|
570
|
+
logger.error(`${message} Run ${nextCommand}, or pass --live.`);
|
|
571
|
+
process.exit(1);
|
|
516
572
|
}
|
|
517
573
|
async function runRawSqlMode(opts) {
|
|
518
574
|
if (!isKnownTable(opts.table)) {
|
package/dist/commands/stats.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { terminalOutputOptions } from "../render/terminal.mjs";
|
|
2
2
|
import { OUTPUT_ARGS, applyOutputMode, displayPath, formatAge, logger } from "../utils.mjs";
|
|
3
|
-
import { allTables } from "../local-store.mjs";
|
|
3
|
+
import { allTables, tableDimensions } from "../local-store.mjs";
|
|
4
4
|
import { createCommandContext } from "../context.mjs";
|
|
5
5
|
import { renderTable, textLines } from "../render/layout.mjs";
|
|
6
6
|
import { formatMetric } from "../render/metrics.mjs";
|
|
@@ -26,15 +26,11 @@ const statsCommand = defineCommand({
|
|
|
26
26
|
const { json } = applyOutputMode(args);
|
|
27
27
|
const store = (await createCommandContext({ needsStore: true })).store;
|
|
28
28
|
const allEntries = await store.engine.listAll({ userId: store.userId });
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
logger.error(`No local data for --site=${args.site}. Known site IDs: ${known.size === 0 ? "(none — run `gscdump sync` first)" : Array.from(known).join(", ")}`);
|
|
35
|
-
process.exit(1);
|
|
36
|
-
}
|
|
37
|
-
siteId = candidate;
|
|
29
|
+
const knownSites = [...new Set(allEntries.filter((entry) => entry.retiredAt === void 0 && entry.siteId !== void 0).map((entry) => entry.siteId))];
|
|
30
|
+
const siteId = args.site ? store.siteIdFor(args.site) : void 0;
|
|
31
|
+
if (args.site && !json && !knownSites.includes(siteId)) {
|
|
32
|
+
logger.error(`No local data for --site=${args.site}. Known site IDs: ${knownSites.length === 0 ? "(none — run `gscdump sync` first)" : knownSites.join(", ")}`);
|
|
33
|
+
process.exit(1);
|
|
38
34
|
}
|
|
39
35
|
const selectedEntries = siteId === void 0 ? allEntries : allEntries.filter((entry) => entry.siteId === siteId);
|
|
40
36
|
const perTable = allTables().map((table) => {
|
|
@@ -48,16 +44,17 @@ const statsCommand = defineCommand({
|
|
|
48
44
|
const [watermarks, disk] = await Promise.all([store.engine.getWatermarks({
|
|
49
45
|
userId: store.userId,
|
|
50
46
|
siteId
|
|
51
|
-
}), filesystemStats(store.dataDir)
|
|
52
|
-
files: 0,
|
|
53
|
-
bytes: 0
|
|
54
|
-
}))]);
|
|
47
|
+
}), filesystemStats(store.dataDir)]);
|
|
55
48
|
if (json) {
|
|
56
49
|
const payload = {
|
|
57
50
|
dataDir: store.dataDir,
|
|
51
|
+
siteId: siteId ?? null,
|
|
52
|
+
knownSites,
|
|
53
|
+
nextCommand: `gscdump sync${args.site ? ` --site '${String(args.site).replaceAll("'", "'\\''")}'` : ""} --status --json`,
|
|
58
54
|
disk,
|
|
59
55
|
tables: perTable.map(({ table, live, retired }) => ({
|
|
60
56
|
table,
|
|
57
|
+
dimensions: tableDimensions(table),
|
|
61
58
|
liveFiles: live.length,
|
|
62
59
|
liveRows: sumRows(live),
|
|
63
60
|
liveBytes: sumBytes(live),
|
|
@@ -65,6 +62,7 @@ const statsCommand = defineCommand({
|
|
|
65
62
|
retiredBytes: sumBytes(retired),
|
|
66
63
|
watermarks: watermarks.filter((w) => w.table === table).map((w) => ({
|
|
67
64
|
siteId: w.siteId ?? null,
|
|
65
|
+
searchType: w.searchType ?? "web",
|
|
68
66
|
newestDateSynced: w.newestDateSynced,
|
|
69
67
|
oldestDateSynced: w.oldestDateSynced,
|
|
70
68
|
lastSyncAt: w.lastSyncAt
|
package/dist/commands/store.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { resetCommand, rmSiteCommand } from "./store-purge.mjs";
|
|
|
9
9
|
import { defineCommand } from "citty";
|
|
10
10
|
const storeCommand = defineCommand({
|
|
11
11
|
meta: storeCommandMeta,
|
|
12
|
+
args: statsCommand.args,
|
|
12
13
|
subCommands: {
|
|
13
14
|
"stats": statsCommand,
|
|
14
15
|
"compact": compactCommand,
|
package/dist/commands/sync.mjs
CHANGED
|
@@ -276,10 +276,6 @@ const syncCommand = defineCommand({
|
|
|
276
276
|
}
|
|
277
277
|
types.push(t);
|
|
278
278
|
}
|
|
279
|
-
if (types.length === 0) {
|
|
280
|
-
logger.warn(`All requested types (${requestedTypes.join(", ")}) are marked empty for this site. Pass --force-types to re-probe.`);
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
279
|
if (skippedTypes.length > 0 && !quiet) logger.info(`Skipping ${skippedTypes.join(", ")} (marked empty for this site; pass --force-types to re-probe).`);
|
|
284
280
|
const endDate = args.end ? String(args.end) : daysAgoUtc(DEFAULT_PENDING_DAYS);
|
|
285
281
|
let startDate;
|
|
@@ -292,6 +288,32 @@ const syncCommand = defineCommand({
|
|
|
292
288
|
logger.error(`No dates to sync (start=${startDate}, end=${endDate})`);
|
|
293
289
|
process.exit(1);
|
|
294
290
|
}
|
|
291
|
+
const printCompletion = async (status, totals, reason, rollupError) => {
|
|
292
|
+
if (!json) return;
|
|
293
|
+
console.log(JSON.stringify({
|
|
294
|
+
status,
|
|
295
|
+
...reason ? { reason } : {},
|
|
296
|
+
siteUrl,
|
|
297
|
+
range: {
|
|
298
|
+
start: startDate,
|
|
299
|
+
end: endDate
|
|
300
|
+
},
|
|
301
|
+
tables,
|
|
302
|
+
types,
|
|
303
|
+
skippedTypes,
|
|
304
|
+
totals,
|
|
305
|
+
watermarks: await store.engine.getWatermarks({
|
|
306
|
+
userId: store.userId,
|
|
307
|
+
siteId
|
|
308
|
+
}),
|
|
309
|
+
...rollupError ? { rollupError } : {}
|
|
310
|
+
}, null, 2));
|
|
311
|
+
};
|
|
312
|
+
if (types.length === 0) {
|
|
313
|
+
if (!quiet) logger.warn(`All requested types are marked empty. Pass --force-types to check them again.`);
|
|
314
|
+
await printCompletion("skipped", {}, "empty-types");
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
295
317
|
if (args["retry-failed"]) {
|
|
296
318
|
const failedSet = /* @__PURE__ */ new Set();
|
|
297
319
|
const selectedTables = new Set(tables);
|
|
@@ -303,7 +325,8 @@ const syncCommand = defineCommand({
|
|
|
303
325
|
for (const s of states) if (selectedTables.has(s.table) && selectedTypes.has(s.searchType ?? "web") && s.state === "failed" && s.date >= startDate && s.date <= endDate) failedSet.add(s.date);
|
|
304
326
|
dates = dates.filter((d) => failedSet.has(d));
|
|
305
327
|
if (dates.length === 0) {
|
|
306
|
-
logger.success("No failed dates in range
|
|
328
|
+
if (!quiet) logger.success("No failed dates in range. Nothing to retry.");
|
|
329
|
+
await printCompletion("skipped", {}, "no-failed-dates");
|
|
307
330
|
return;
|
|
308
331
|
}
|
|
309
332
|
args.force = true;
|
|
@@ -416,6 +439,7 @@ const syncCommand = defineCommand({
|
|
|
416
439
|
}
|
|
417
440
|
}
|
|
418
441
|
const noRollups = Boolean(args["no-rollups"]);
|
|
442
|
+
let rollupError;
|
|
419
443
|
const anyRowsSynced = Object.values(totals).some((t) => t.rows > 0);
|
|
420
444
|
if (!noRollups && anyRowsSynced) {
|
|
421
445
|
if (!quiet) logger.info(`Rebuilding rollups for [${siteId}] (${DEFAULT_ROLLUPS.length} rollups)…`);
|
|
@@ -442,6 +466,7 @@ const syncCommand = defineCommand({
|
|
|
442
466
|
},
|
|
443
467
|
defs: DEFAULT_ROLLUPS
|
|
444
468
|
}).catch((err) => {
|
|
469
|
+
rollupError = err.message;
|
|
445
470
|
logger.warn(`Rollup rebuild failed: ${err.message}`);
|
|
446
471
|
return [];
|
|
447
472
|
});
|
|
@@ -451,7 +476,8 @@ const syncCommand = defineCommand({
|
|
|
451
476
|
logger.success(`Rebuilt ${results.length} rollup(s) in ${ms}ms — ${kb.toFixed(1)} KB`);
|
|
452
477
|
}
|
|
453
478
|
}
|
|
454
|
-
|
|
479
|
+
await printCompletion(anyFailed || rollupError ? "failed" : "completed", totals, void 0, rollupError);
|
|
480
|
+
if (anyFailed || rollupError) process.exit(1);
|
|
455
481
|
}
|
|
456
482
|
});
|
|
457
483
|
function isKnownTable(name) {
|
package/dist/config.mjs
CHANGED
|
@@ -27,8 +27,13 @@ const configSchema = z.strictObject({
|
|
|
27
27
|
]).optional(),
|
|
28
28
|
serviceAccountPath: z.string().optional()
|
|
29
29
|
});
|
|
30
|
+
const RETIRED_KEYS = ["mode", "cloudUrl"];
|
|
31
|
+
function dropRetiredKeys(value) {
|
|
32
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
|
33
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !RETIRED_KEYS.includes(key)));
|
|
34
|
+
}
|
|
30
35
|
function parseConfig(value) {
|
|
31
|
-
const parsed = configSchema.safeParse(value);
|
|
36
|
+
const parsed = configSchema.safeParse(dropRetiredKeys(value));
|
|
32
37
|
if (!parsed.success) {
|
|
33
38
|
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`);
|
|
34
39
|
throw new Error(`Invalid config at ${getConfigPath()}. ${issues.join("; ")}`);
|
package/dist/error-handler.mjs
CHANGED
|
@@ -75,7 +75,7 @@ var LocalStoreUnsupportedError = class extends Error {
|
|
|
75
75
|
tool;
|
|
76
76
|
mode;
|
|
77
77
|
constructor(tool, mode) {
|
|
78
|
-
super(`analysis "${tool}"
|
|
78
|
+
super(mode === "live" ? `The live API cannot run analysis "${tool}". Run gscdump sync, then retry without --live.` : `Local data cannot run analysis "${tool}".`);
|
|
79
79
|
this.name = "LocalStoreUnsupportedError";
|
|
80
80
|
this.tool = tool;
|
|
81
81
|
this.mode = mode;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const ORIGIN = "https://gscdump.com";
|
|
3
|
+
const accessSchema = z.object({
|
|
4
|
+
accessToken: z.string().min(1),
|
|
5
|
+
expiresAt: z.number().int().positive()
|
|
6
|
+
});
|
|
7
|
+
const pollSchema = z.discriminatedUnion("status", [z.object({ status: z.literal("pending") }), z.object({
|
|
8
|
+
status: z.literal("complete"),
|
|
9
|
+
tokens: accessSchema.extend({ refreshToken: z.string().min(1) })
|
|
10
|
+
})]);
|
|
11
|
+
async function requestJson(request, route, init = {}) {
|
|
12
|
+
const response = await request(`${ORIGIN}/api/cli/auth/${route}`, {
|
|
13
|
+
...init,
|
|
14
|
+
redirect: "error",
|
|
15
|
+
signal: AbortSignal.timeout(3e4)
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
if (response.status === 429 || response.status >= 500) throw new Error("Google authorization is temporarily unavailable. Try again later.");
|
|
19
|
+
throw new Error("Google authorization failed. Run `gscdump auth login --mode local --force` to reconnect.");
|
|
20
|
+
}
|
|
21
|
+
return response.json();
|
|
22
|
+
}
|
|
23
|
+
async function refreshWithPlatform(refreshToken, request = fetch) {
|
|
24
|
+
const result = accessSchema.parse(await requestJson(request, "refresh", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ refreshToken })
|
|
28
|
+
}));
|
|
29
|
+
return {
|
|
30
|
+
access_token: result.accessToken,
|
|
31
|
+
expiry_date: result.expiresAt
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function loginWithPlatform(deps) {
|
|
35
|
+
const init = z.object({
|
|
36
|
+
code: z.string().regex(/^[A-F0-9]{20}$/),
|
|
37
|
+
expiresIn: z.number().int().positive().max(600)
|
|
38
|
+
}).parse(await requestJson(deps.request, "init", { method: "POST" }));
|
|
39
|
+
const deadline = deps.now() + init.expiresIn * 1e3;
|
|
40
|
+
const redirect = `/app/cli/auth?code=${init.code}`;
|
|
41
|
+
const route = deps.force ? `/auth/google?reauth=1&redirect=${encodeURIComponent(redirect)}` : redirect;
|
|
42
|
+
await deps.authorize(`${ORIGIN}${route}`);
|
|
43
|
+
while (deps.now() < deadline) {
|
|
44
|
+
const result = pollSchema.parse(await requestJson(deps.request, `poll?code=${init.code}`));
|
|
45
|
+
if (result.status === "complete") return {
|
|
46
|
+
provider: "gscdump",
|
|
47
|
+
access_token: result.tokens.accessToken,
|
|
48
|
+
refresh_token: result.tokens.refreshToken,
|
|
49
|
+
expiry_date: result.tokens.expiresAt
|
|
50
|
+
};
|
|
51
|
+
await deps.wait(2e3);
|
|
52
|
+
}
|
|
53
|
+
throw new Error("Authorization expired. Run `gscdump auth login` to try again.");
|
|
54
|
+
}
|
|
55
|
+
export { loginWithPlatform, refreshWithPlatform };
|
package/dist/local-store.mjs
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { createNodeHarness } from "@gscdump/engine/node";
|
|
2
|
+
import { SCHEMAS, allTables, inferTable } from "@gscdump/engine/schema";
|
|
2
3
|
import { TABLE_DIMS, assembleDatesRow } from "@gscdump/engine/ingest";
|
|
3
|
-
|
|
4
|
+
function tableDimensions(table) {
|
|
5
|
+
return SCHEMAS[table].columns.map((column) => column.name === "url" ? "page" : column.name === "search_appearance" ? "searchAppearance" : column.name).filter((name) => [
|
|
6
|
+
"page",
|
|
7
|
+
"query",
|
|
8
|
+
"date",
|
|
9
|
+
"hour",
|
|
10
|
+
"country",
|
|
11
|
+
"device",
|
|
12
|
+
"searchAppearance"
|
|
13
|
+
].includes(name));
|
|
14
|
+
}
|
|
4
15
|
function createLocalStore(opts) {
|
|
5
16
|
return createNodeHarness(opts);
|
|
6
17
|
}
|
|
7
|
-
export { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore, inferTable };
|
|
18
|
+
export { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore, inferTable, tableDimensions };
|
package/dist/package.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var version = "3.
|
|
1
|
+
var version = "3.7.0";
|
|
2
2
|
export { version };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gscdump/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.7.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,15 +44,15 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@clack/prompts": "^1.8.0",
|
|
46
46
|
"@duckdb/node-api": "1.5.5-r.4",
|
|
47
|
-
"@gscdump/analysis": "^3.
|
|
48
|
-
"@gscdump/engine": "^3.
|
|
49
|
-
"@gscdump/engine-gsc-api": "^3.
|
|
50
|
-
"@gscdump/sdk": "^3.
|
|
47
|
+
"@gscdump/analysis": "^3.7.0",
|
|
48
|
+
"@gscdump/engine": "^3.7.0",
|
|
49
|
+
"@gscdump/engine-gsc-api": "^3.7.0",
|
|
50
|
+
"@gscdump/sdk": "^3.7.0",
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
52
52
|
"citty": "^0.2.2",
|
|
53
53
|
"consola": "^3.4.2",
|
|
54
54
|
"google-auth-library": "^11.0.2",
|
|
55
|
-
"gscdump": "^3.
|
|
55
|
+
"gscdump": "^3.7.0",
|
|
56
56
|
"ofetch": "^1.5.1",
|
|
57
57
|
"open": "^11.0.2",
|
|
58
58
|
"sitemapd": "^0.2.2",
|
package/skills/gscdump/SKILL.md
CHANGED
|
@@ -7,6 +7,24 @@ description: Drive the `gscdump` CLI for Google Search Console and Bing with clo
|
|
|
7
7
|
|
|
8
8
|
`gscdump` reads Google Search Console and Bing with hosted or local authentication.
|
|
9
9
|
It keeps a local Parquet Store for Google rows. Every command has `--help`.
|
|
10
|
+
For `query`, `-s` means `--site`, `-d` means `--dimensions`, and `-f` means `--format`.
|
|
11
|
+
Use `--start` and `--end` for dates. `--site=SITE` also works.
|
|
12
|
+
Use each option once, with either its short or long spelling.
|
|
13
|
+
Example: `gscdump query --site=SITE --start=DATE --end=DATE -d page -f json`.
|
|
14
|
+
|
|
15
|
+
## Start each task
|
|
16
|
+
|
|
17
|
+
1. Before reading traffic, run `gscdump auth status --json`. Do this even when the user says authentication works.
|
|
18
|
+
2. Keep the requested Site, dates, dimensions, and task scope. A request for pages does not need query dimensions.
|
|
19
|
+
3. Before local queries, check coverage with `gscdump store stats --site SITE --json`.
|
|
20
|
+
Use `gscdump sync --site SITE --status --json` when you need sync-state details.
|
|
21
|
+
4. Read the table dimensions and watermarks. Sync only missing tables and the requested dates, once per task.
|
|
22
|
+
5. Use `sync --json`. Read its completion result before deciding what to do next. Never repeat a successful sync.
|
|
23
|
+
|
|
24
|
+
If the task only asks about deletion, explain the scope and ask for consent.
|
|
25
|
+
You may read Store metadata with `store stats` and `sync --status`.
|
|
26
|
+
Do not query traffic, sync rows, or delete data to explain deletion.
|
|
27
|
+
Call the local data directory the Store in your answer.
|
|
10
28
|
|
|
11
29
|
## Authentication mode
|
|
12
30
|
|
|
@@ -104,10 +122,13 @@ If local Google credentials are missing, use one of these paths:
|
|
|
104
122
|
| Service account | CI with a service-account key that has Site access | `export GOOGLE_APPLICATION_CREDENTIALS=/abs/path/key.json` |
|
|
105
123
|
| Interactive OAuth | A person is present | `gscdump init --mode local` |
|
|
106
124
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
when a browser
|
|
110
|
-
|
|
125
|
+
Default local login opens gscdump.com for free Google login and token refresh.
|
|
126
|
+
Data queries call Google directly. No Google Cloud project or hosted activation is required.
|
|
127
|
+
Use `gscdump auth login --mode local --no-browser` when a browser runs on another host.
|
|
128
|
+
The default grant is read-only Search Console access.
|
|
129
|
+
For Google write operations, use your own OAuth client with the required scopes.
|
|
130
|
+
Set `GSC_CLIENT_ID` and `GSC_CLIENT_SECRET` to use a Desktop app OAuth client.
|
|
131
|
+
Cloud mode requires hosted access. Pro is free during beta, then paid after launch.
|
|
111
132
|
|
|
112
133
|
`--profile <name>` or `GSCDUMP_PROFILE` isolates the selected mode and Google, Bing, and cloud credentials.
|
|
113
134
|
|
|
@@ -130,11 +151,14 @@ gscdump config set defaultSite sc-domain:example.com
|
|
|
130
151
|
|
|
131
152
|
## Output
|
|
132
153
|
|
|
133
|
-
Pass `--json` on every command that supports it. `
|
|
154
|
+
Pass `--json` on every command that supports it. `sync --json` includes completion, row counts, skipped dates, and failures.
|
|
155
|
+
`query` uses
|
|
134
156
|
`--format json` and prints rows to stdout. Progress goes to stderr, so stdout
|
|
135
157
|
stays parseable. `--quiet` drops progress lines.
|
|
136
158
|
|
|
137
159
|
Parse JSON. Never scrape human output.
|
|
160
|
+
If the user requests JSON, return the CLI JSON unchanged. Do not replace it with a table.
|
|
161
|
+
Do not rewrite rows, estimate metrics, or add manually calculated totals.
|
|
138
162
|
|
|
139
163
|
## Commands
|
|
140
164
|
|
|
@@ -172,9 +196,10 @@ The MCP server does not expose Bing tools. Use `gscdump bing` commands through t
|
|
|
172
196
|
## Sync before local analysis
|
|
173
197
|
|
|
174
198
|
```sh
|
|
199
|
+
gscdump store stats --site sc-domain:example.com --json
|
|
175
200
|
gscdump sync --site sc-domain:example.com --days 90 \
|
|
176
|
-
--tables pages,queries,page_queries,countries
|
|
177
|
-
gscdump sync --site sc-domain:example.com --status
|
|
201
|
+
--tables pages,queries,page_queries,countries --json
|
|
202
|
+
gscdump sync --site sc-domain:example.com --status --json
|
|
178
203
|
```
|
|
179
204
|
|
|
180
205
|
- Pass an explicit `--tables` list. The default list has a known daily-totals
|
|
@@ -183,6 +208,8 @@ gscdump sync --site sc-domain:example.com --status
|
|
|
183
208
|
- Sync skips completed dates. `--force` refreshes them. `--retry-failed`
|
|
184
209
|
reruns only failed dates.
|
|
185
210
|
- `--dry-run` prints the planned work without calling Google.
|
|
211
|
+
- Use the user's date range. The 90-day example does not authorize a wider sync.
|
|
212
|
+
- Empty Store metadata is expected before the first sync. It does not prove zero traffic.
|
|
186
213
|
|
|
187
214
|
## Query rows
|
|
188
215
|
|
|
@@ -195,8 +222,11 @@ gscdump query --site sc-domain:example.com --dimensions page,query \
|
|
|
195
222
|
- Filters: `--query`, `--page`, `--country`, `--device`,
|
|
196
223
|
`--search-appearance`. Prefixes: bare equals, `~` contains, `!~` not
|
|
197
224
|
contains, `re:` regex, `!re:` not regex, `!` not equals.
|
|
198
|
-
- `--live` bypasses the Store. `--
|
|
199
|
-
`--aggregation-type` apply to live mode only.
|
|
225
|
+
- `--live` bypasses the Store. `--type` selects a search type.
|
|
226
|
+
`--data-state` and `--aggregation-type` apply to live mode only.
|
|
227
|
+
- Metrics already include clicks, impressions, CTR, and position. There is no `--metrics` option.
|
|
228
|
+
- If Store coverage is missing, read the JSON error and its bounded `nextArgs` before syncing.
|
|
229
|
+
Do not switch dimensions to make a failed query succeed.
|
|
200
230
|
- `--explain` prints the request body or planned SQL without executing.
|
|
201
231
|
- `--sql` runs raw DuckDB SQL over the Store with `{{FILES}}` as the file list.
|
|
202
232
|
|
|
@@ -270,9 +300,9 @@ the failure and continue. Never retry an uncertain submission.
|
|
|
270
300
|
delete local data. `--yes` is consent you borrow from the user.
|
|
271
301
|
- **Never loop unattended.** One `sync` per Site per task. Inspection batches
|
|
272
302
|
spend a daily pool. Use `--dry-run` and `--explain` to plan first.
|
|
273
|
-
- **Report the result as the CLI gave it.**
|
|
274
|
-
|
|
275
|
-
rows
|
|
303
|
+
- **Report the result as the CLI gave it.** Zero clicks with impressions means rows exist with no clicks.
|
|
304
|
+
An empty row array means no rows matched. Missing coverage means the Store cannot answer that date range.
|
|
305
|
+
Check `sync --status --json` before interpreting empty rows. None of these results proves a clean Site.
|
|
276
306
|
- **Do not widen the Site.** A `sc-domain:` property includes every
|
|
277
307
|
subdomain. Filter with `--page` when the user means one host.
|
|
278
308
|
|
|
@@ -285,3 +315,14 @@ Hosted exports follow pagination and reject missing, unavailable, or changing da
|
|
|
285
315
|
Hosted date ranges span at most 366 days. The default range is the last 366 days.
|
|
286
316
|
Local date filters only narrow data currently returned by Bing.
|
|
287
317
|
Do not treat Bing crawl evidence as proof that a URL is indexed.
|
|
318
|
+
|
|
319
|
+
## Before the next command
|
|
320
|
+
|
|
321
|
+
For a traffic task, run `gscdump auth status --json` now, before any `query` command.
|
|
322
|
+
The user saying credentials work does not replace this check. It identifies the selected authentication mode.
|
|
323
|
+
Then check Store metadata, keep the requested dimensions, and read or sync only the requested dates.
|
|
324
|
+
If the user requests JSON, return the command's JSON unchanged, without a table or a separate totals summary.
|
|
325
|
+
Include that JSON in your final response. Tool output alone is not a final answer.
|
|
326
|
+
Include every returned row. Do not refer the user to results "above".
|
|
327
|
+
|
|
328
|
+
For a deletion explanation, read metadata only if needed. Explain the scope and ask for consent, then stop.
|