@gscdump/cli 0.37.3 → 0.37.4
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/bin/gscdump.mjs +3 -0
- package/dist/_chunks/analysis-local.mjs +88 -0
- package/dist/_chunks/analyze.mjs +332 -0
- package/dist/_chunks/auth.mjs +476 -0
- package/dist/_chunks/auth2.mjs +293 -0
- package/dist/_chunks/config.mjs +93 -0
- package/dist/_chunks/config2.mjs +232 -0
- package/dist/_chunks/context.mjs +69 -0
- package/dist/_chunks/doctor.mjs +400 -0
- package/dist/_chunks/dump.mjs +193 -0
- package/dist/_chunks/entities.mjs +414 -0
- package/dist/_chunks/env-file.mjs +53 -0
- package/dist/_chunks/environment.mjs +30 -0
- package/dist/_chunks/error-handler.mjs +86 -0
- package/dist/_chunks/indexing.mjs +314 -0
- package/dist/_chunks/init.mjs +190 -0
- package/dist/_chunks/inspect.mjs +203 -0
- package/dist/_chunks/mcp.mjs +867 -0
- package/dist/_chunks/native-duckdb.mjs +46 -0
- package/dist/_chunks/profile.mjs +290 -0
- package/dist/_chunks/query.mjs +537 -0
- package/dist/_chunks/report.mjs +243 -0
- package/dist/_chunks/sitemaps.mjs +274 -0
- package/dist/_chunks/sites.mjs +500 -0
- package/dist/_chunks/store.mjs +652 -0
- package/dist/_chunks/sync.mjs +489 -0
- package/dist/_chunks/utils.mjs +191 -0
- package/dist/cli.d.mts +28 -0
- package/dist/cli.mjs +104 -0
- package/package.json +15 -7
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -7330
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { loadConfig } from "./config.mjs";
|
|
2
|
+
import { resolveCliEnvironment } from "./environment.mjs";
|
|
3
|
+
import { OUTPUT_ARGS, applyOutputMode, displayPath, logger } from "./utils.mjs";
|
|
4
|
+
import { parseEnvFile } from "./env-file.mjs";
|
|
5
|
+
import { loadTokens, resolveAuth, resolveBYOK } from "./auth.mjs";
|
|
6
|
+
import { createCommandContext, createLocalStore } from "./context.mjs";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { defineCommand } from "citty";
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { googleSearchConsole, hasGscWriteScope, hasIndexingScope } from "gscdump/api";
|
|
12
|
+
import { ofetch } from "ofetch";
|
|
13
|
+
const REQUIRED_SCOPES = [
|
|
14
|
+
"https://www.googleapis.com/auth/webmasters",
|
|
15
|
+
"https://www.googleapis.com/auth/indexing",
|
|
16
|
+
"https://www.googleapis.com/auth/siteverification"
|
|
17
|
+
];
|
|
18
|
+
const FETCH_TIMEOUT_MS = 5e3;
|
|
19
|
+
const TIME_SKEW_WARN_MS = 5 * 6e4;
|
|
20
|
+
const WATERMARK_STALE_DAYS_WARN = 7;
|
|
21
|
+
const RELEVANT_ENV_KEYS = [
|
|
22
|
+
"GSC_ACCESS_TOKEN",
|
|
23
|
+
"GSC_CLIENT_ID",
|
|
24
|
+
"GSC_CLIENT_SECRET",
|
|
25
|
+
"GSC_REFRESH_TOKEN",
|
|
26
|
+
"GOOGLE_ACCESS_TOKEN",
|
|
27
|
+
"GOOGLE_CLIENT_ID",
|
|
28
|
+
"GOOGLE_CLIENT_SECRET",
|
|
29
|
+
"GOOGLE_REFRESH_TOKEN",
|
|
30
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
31
|
+
"GSC_SERVICE_ACCOUNT_JSON"
|
|
32
|
+
];
|
|
33
|
+
function redact(v) {
|
|
34
|
+
if (!v) return "<missing>";
|
|
35
|
+
if (v.length <= 6) return "***";
|
|
36
|
+
return `***${v.slice(-6)}`;
|
|
37
|
+
}
|
|
38
|
+
function hasGoogleScope(scopes, scope) {
|
|
39
|
+
const suffix = scope.replace("https://www.googleapis.com/auth/", "");
|
|
40
|
+
return scopes.includes(scope) || scopes.includes(suffix);
|
|
41
|
+
}
|
|
42
|
+
function missingRequiredScopes(scopes) {
|
|
43
|
+
return REQUIRED_SCOPES.filter((scope) => {
|
|
44
|
+
if (scope.endsWith("/webmasters")) return !hasGscWriteScope(scopes);
|
|
45
|
+
if (scope.endsWith("/indexing")) return !hasIndexingScope(scopes);
|
|
46
|
+
return !hasGoogleScope(scopes, scope);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function checkEnv() {
|
|
50
|
+
const envPath = path.join(process.cwd(), ".env");
|
|
51
|
+
const parsed = parseEnvFile(envPath);
|
|
52
|
+
if (!parsed) return {
|
|
53
|
+
checks: [{
|
|
54
|
+
name: "env",
|
|
55
|
+
status: "info",
|
|
56
|
+
detail: "no .env (using shell env / saved tokens)"
|
|
57
|
+
}],
|
|
58
|
+
envKeys: /* @__PURE__ */ new Set()
|
|
59
|
+
};
|
|
60
|
+
const relevant = RELEVANT_ENV_KEYS.filter((k) => parsed[k] !== void 0);
|
|
61
|
+
const envKeys = new Set(relevant);
|
|
62
|
+
if (relevant.length === 0) return {
|
|
63
|
+
checks: [{
|
|
64
|
+
name: "env",
|
|
65
|
+
status: "info",
|
|
66
|
+
detail: `${displayPath(envPath)} found, no auth vars`
|
|
67
|
+
}],
|
|
68
|
+
envKeys
|
|
69
|
+
};
|
|
70
|
+
const inventory = relevant.map((k) => {
|
|
71
|
+
const v = parsed[k];
|
|
72
|
+
if (k.endsWith("CLIENT_ID")) return `${k}=${v}`;
|
|
73
|
+
return `${k}=${redact(v)}`;
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
checks: [{
|
|
77
|
+
name: "env",
|
|
78
|
+
status: "info",
|
|
79
|
+
detail: `${displayPath(envPath)} → ${inventory.join(", ")} (not validated — see auth)`
|
|
80
|
+
}],
|
|
81
|
+
envKeys
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function describeAuthSource(envKeys, byok) {
|
|
85
|
+
if (!byok) return "saved tokens";
|
|
86
|
+
const isAccessToken = typeof byok === "string";
|
|
87
|
+
const driver = isAccessToken ? "GSC_ACCESS_TOKEN" : "GSC_REFRESH_TOKEN";
|
|
88
|
+
const source = (isAccessToken ? ["GSC_ACCESS_TOKEN", "GOOGLE_ACCESS_TOKEN"] : ["GSC_REFRESH_TOKEN", "GOOGLE_REFRESH_TOKEN"]).some((k) => envKeys.has(k)) ? ".env" : "shell env";
|
|
89
|
+
return `BYOK ${isAccessToken ? "(access-token)" : "(refresh-token)"} from ${source} via ${driver}`;
|
|
90
|
+
}
|
|
91
|
+
async function checkAuth(envKeys) {
|
|
92
|
+
const checks = [];
|
|
93
|
+
const byok = resolveBYOK();
|
|
94
|
+
const tokens = await loadTokens();
|
|
95
|
+
if (!byok && !tokens) {
|
|
96
|
+
checks.push({
|
|
97
|
+
name: "auth",
|
|
98
|
+
status: "fail",
|
|
99
|
+
detail: "no BYOK env vars and no saved tokens; run `gscdump init`"
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
checks,
|
|
103
|
+
liveToken: null
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const clientId = resolveCliEnvironment().clientId ?? (await loadConfig()).clientId ?? null;
|
|
107
|
+
if (clientId) checks.push({
|
|
108
|
+
name: "auth.client_id",
|
|
109
|
+
status: "info",
|
|
110
|
+
detail: clientId
|
|
111
|
+
});
|
|
112
|
+
let liveToken = null;
|
|
113
|
+
let refreshError = null;
|
|
114
|
+
if (typeof byok === "string") liveToken = byok;
|
|
115
|
+
else if (byok && "getAccessToken" in byok) liveToken = await byok.getAccessToken().then((r) => r.token ?? null).catch((e) => {
|
|
116
|
+
refreshError = e?.data?.error_description ?? e?.data?.error ?? e?.message ?? String(e);
|
|
117
|
+
return null;
|
|
118
|
+
});
|
|
119
|
+
else if (tokens?.access_token) liveToken = tokens.access_token;
|
|
120
|
+
if (!liveToken) {
|
|
121
|
+
const source = describeAuthSource(envKeys, byok);
|
|
122
|
+
const detail = refreshError ? `${source} — refresh failed: ${refreshError} (token revoked / invalid — re-run \`gscdump auth login --force\` and update the source above)` : `${source} — no usable access token`;
|
|
123
|
+
checks.push({
|
|
124
|
+
name: "auth",
|
|
125
|
+
status: "fail",
|
|
126
|
+
detail
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
checks,
|
|
130
|
+
liveToken: null
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const info = await ofetch("https://oauth2.googleapis.com/tokeninfo", { query: { access_token: liveToken } }).catch((e) => ({ error: e.message }));
|
|
134
|
+
if ("error" in info) {
|
|
135
|
+
checks.push({
|
|
136
|
+
name: "auth",
|
|
137
|
+
status: "fail",
|
|
138
|
+
detail: `tokeninfo failed: ${info.error}`
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
checks,
|
|
142
|
+
liveToken: null
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
checks.push({
|
|
146
|
+
name: "auth",
|
|
147
|
+
status: "pass",
|
|
148
|
+
detail: describeAuthSource(envKeys, byok)
|
|
149
|
+
});
|
|
150
|
+
if (info.email) checks.push({
|
|
151
|
+
name: "auth.account",
|
|
152
|
+
status: "pass",
|
|
153
|
+
detail: info.email
|
|
154
|
+
});
|
|
155
|
+
const scopes = info.scope ? info.scope.split(/\s+/) : [];
|
|
156
|
+
const missing = missingRequiredScopes(scopes);
|
|
157
|
+
if (missing.length > 0) checks.push({
|
|
158
|
+
name: "auth.scopes",
|
|
159
|
+
status: "warn",
|
|
160
|
+
detail: `missing: ${missing.join(", ")} — \`gscdump auth login --force\` to re-consent`
|
|
161
|
+
});
|
|
162
|
+
else checks.push({
|
|
163
|
+
name: "auth.scopes",
|
|
164
|
+
status: "pass",
|
|
165
|
+
detail: `${scopes.length} granted`
|
|
166
|
+
});
|
|
167
|
+
if (tokens?.expiry_date) {
|
|
168
|
+
const expiresInMs = tokens.expiry_date - Date.now();
|
|
169
|
+
if (expiresInMs < 0) checks.push({
|
|
170
|
+
name: "auth.expiry",
|
|
171
|
+
status: "warn",
|
|
172
|
+
detail: `expired ${new Date(tokens.expiry_date).toISOString()} — will refresh on next call`
|
|
173
|
+
});
|
|
174
|
+
else checks.push({
|
|
175
|
+
name: "auth.expiry",
|
|
176
|
+
status: "pass",
|
|
177
|
+
detail: `valid for ${Math.floor(expiresInMs / 6e4)}m`
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
checks,
|
|
182
|
+
liveToken
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async function checkTimeSkew() {
|
|
186
|
+
const dateHeader = await ofetch.raw("https://oauth2.googleapis.com/tokeninfo", {
|
|
187
|
+
method: "GET",
|
|
188
|
+
timeout: FETCH_TIMEOUT_MS
|
|
189
|
+
}).then((r) => r.headers.get("date")).catch((e) => e?.response?.headers?.get("date") ?? null);
|
|
190
|
+
if (!dateHeader) return [{
|
|
191
|
+
name: "time",
|
|
192
|
+
status: "warn",
|
|
193
|
+
detail: "could not probe Google clock (no Date header)"
|
|
194
|
+
}];
|
|
195
|
+
const remoteMs = Date.parse(dateHeader);
|
|
196
|
+
if (!Number.isFinite(remoteMs)) return [{
|
|
197
|
+
name: "time",
|
|
198
|
+
status: "warn",
|
|
199
|
+
detail: `unparseable Date header: ${dateHeader}`
|
|
200
|
+
}];
|
|
201
|
+
const skewMs = Date.now() - remoteMs;
|
|
202
|
+
const human = `${skewMs >= 0 ? "+" : ""}${(skewMs / 1e3).toFixed(1)}s`;
|
|
203
|
+
if (Math.abs(skewMs) > TIME_SKEW_WARN_MS) return [{
|
|
204
|
+
name: "time",
|
|
205
|
+
status: "warn",
|
|
206
|
+
detail: `local clock ${human} off Google — OAuth refresh may reject; sync your clock`
|
|
207
|
+
}];
|
|
208
|
+
return [{
|
|
209
|
+
name: "time",
|
|
210
|
+
status: "pass",
|
|
211
|
+
detail: `in sync (${human})`
|
|
212
|
+
}];
|
|
213
|
+
}
|
|
214
|
+
async function checkDataDir(dataDir) {
|
|
215
|
+
const display = displayPath(dataDir);
|
|
216
|
+
const stat = await fs.stat(dataDir).catch(() => null);
|
|
217
|
+
if (!stat) return [{
|
|
218
|
+
name: "dataDir",
|
|
219
|
+
status: "warn",
|
|
220
|
+
detail: `${display} does not exist (will be created on first sync)`
|
|
221
|
+
}];
|
|
222
|
+
if (!stat.isDirectory()) return [{
|
|
223
|
+
name: "dataDir",
|
|
224
|
+
status: "fail",
|
|
225
|
+
detail: `${display} is not a directory`
|
|
226
|
+
}];
|
|
227
|
+
const probe = `${dataDir}/.gscdump-doctor-probe`;
|
|
228
|
+
return await fs.writeFile(probe, "").then(() => fs.rm(probe)).then(() => true).catch(() => false) ? [{
|
|
229
|
+
name: "dataDir",
|
|
230
|
+
status: "pass",
|
|
231
|
+
detail: display
|
|
232
|
+
}] : [{
|
|
233
|
+
name: "dataDir",
|
|
234
|
+
status: "fail",
|
|
235
|
+
detail: `${display} not writable`
|
|
236
|
+
}];
|
|
237
|
+
}
|
|
238
|
+
async function checkStoreWatermarks(dataDir) {
|
|
239
|
+
if (!(await fs.stat(dataDir).catch(() => null))?.isDirectory()) return [{
|
|
240
|
+
name: "store.watermarks",
|
|
241
|
+
status: "pass",
|
|
242
|
+
detail: "no store yet (run `gscdump sync`)"
|
|
243
|
+
}];
|
|
244
|
+
const store = createLocalStore({ dataDir });
|
|
245
|
+
const watermarks = await store.engine.getWatermarks({ userId: store.userId }).catch(() => null);
|
|
246
|
+
if (!watermarks || watermarks.length === 0) return [{
|
|
247
|
+
name: "store.watermarks",
|
|
248
|
+
status: "pass",
|
|
249
|
+
detail: "no watermarks (run `gscdump sync`)"
|
|
250
|
+
}];
|
|
251
|
+
const bySite = /* @__PURE__ */ new Map();
|
|
252
|
+
for (const w of watermarks) {
|
|
253
|
+
const site = w.siteId ?? "(global)";
|
|
254
|
+
const existing = bySite.get(site);
|
|
255
|
+
if (!existing || w.newestDateSynced > existing) bySite.set(site, w.newestDateSynced);
|
|
256
|
+
}
|
|
257
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
258
|
+
const stale = [];
|
|
259
|
+
let freshest = null;
|
|
260
|
+
for (const [site, newest] of bySite) {
|
|
261
|
+
const days = Math.floor((Date.parse(today) - Date.parse(newest)) / 864e5);
|
|
262
|
+
if (days > WATERMARK_STALE_DAYS_WARN) stale.push({
|
|
263
|
+
site,
|
|
264
|
+
days
|
|
265
|
+
});
|
|
266
|
+
if (!freshest || days < freshest.days) freshest = {
|
|
267
|
+
site,
|
|
268
|
+
days
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (stale.length > 0) {
|
|
272
|
+
const sample = stale.slice(0, 3).map((s) => `${s.site} (${s.days}d)`).join(", ");
|
|
273
|
+
const more = stale.length > 3 ? ` +${stale.length - 3} more` : "";
|
|
274
|
+
return [{
|
|
275
|
+
name: "store.watermarks",
|
|
276
|
+
status: "warn",
|
|
277
|
+
detail: `${stale.length}/${bySite.size} site(s) stale >${WATERMARK_STALE_DAYS_WARN}d: ${sample}${more}`
|
|
278
|
+
}];
|
|
279
|
+
}
|
|
280
|
+
const tail = freshest ? `, freshest ${freshest.days}d ago` : "";
|
|
281
|
+
return [{
|
|
282
|
+
name: "store.watermarks",
|
|
283
|
+
status: "pass",
|
|
284
|
+
detail: `${bySite.size} site(s)${tail}`
|
|
285
|
+
}];
|
|
286
|
+
}
|
|
287
|
+
async function checkApiReachable(name, url) {
|
|
288
|
+
const reachable = await ofetch.raw(url, {
|
|
289
|
+
method: "GET",
|
|
290
|
+
timeout: FETCH_TIMEOUT_MS
|
|
291
|
+
}).then(() => true).catch(() => false);
|
|
292
|
+
return [{
|
|
293
|
+
name,
|
|
294
|
+
status: reachable ? "pass" : "warn",
|
|
295
|
+
detail: reachable ? "reachable" : `${new URL(url).hostname} unreachable (network/firewall?)`
|
|
296
|
+
}];
|
|
297
|
+
}
|
|
298
|
+
async function checkGscSites() {
|
|
299
|
+
const config = await loadConfig();
|
|
300
|
+
const auth = await resolveAuth({
|
|
301
|
+
interactive: false,
|
|
302
|
+
config
|
|
303
|
+
}).catch(() => null);
|
|
304
|
+
if (!auth) return [{
|
|
305
|
+
name: "gsc.sites",
|
|
306
|
+
status: "warn",
|
|
307
|
+
detail: "skipped (no usable auth)"
|
|
308
|
+
}];
|
|
309
|
+
const sites = await googleSearchConsole(auth).sites().catch((e) => e);
|
|
310
|
+
if (sites instanceof Error) return [{
|
|
311
|
+
name: "gsc.sites",
|
|
312
|
+
status: "fail",
|
|
313
|
+
detail: `sites() failed: ${sites.message}`
|
|
314
|
+
}];
|
|
315
|
+
const checks = [];
|
|
316
|
+
const verified = sites.filter((s) => s.permissionLevel !== "siteUnverifiedUser").length;
|
|
317
|
+
checks.push({
|
|
318
|
+
name: "gsc.sites",
|
|
319
|
+
status: "pass",
|
|
320
|
+
detail: `${sites.length} site(s) accessible (${verified} verified)`
|
|
321
|
+
});
|
|
322
|
+
if (config.defaultSite) {
|
|
323
|
+
const match = sites.find((s) => s.siteUrl === config.defaultSite || (s.siteUrl ?? "").includes(String(config.defaultSite)));
|
|
324
|
+
checks.push(match ? {
|
|
325
|
+
name: "config.defaultSite",
|
|
326
|
+
status: "pass",
|
|
327
|
+
detail: `${config.defaultSite} ✓`
|
|
328
|
+
} : {
|
|
329
|
+
name: "config.defaultSite",
|
|
330
|
+
status: "fail",
|
|
331
|
+
detail: `${config.defaultSite} not in verified site list`
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return checks;
|
|
335
|
+
}
|
|
336
|
+
const doctorCommand = defineCommand({
|
|
337
|
+
meta: {
|
|
338
|
+
name: "doctor",
|
|
339
|
+
description: "Run health checks (env, auth, scopes, time, dataDir, store, GSC reachability + ping, defaultSite)"
|
|
340
|
+
},
|
|
341
|
+
args: { ...OUTPUT_ARGS },
|
|
342
|
+
async run({ args }) {
|
|
343
|
+
const { json } = applyOutputMode(args);
|
|
344
|
+
const { dataDir } = await createCommandContext();
|
|
345
|
+
const envResult = await checkEnv();
|
|
346
|
+
const [authResult, timeChecks, dataDirChecks, watermarkChecks, gscApi, indexingApi, siteVerificationApi] = await Promise.all([
|
|
347
|
+
checkAuth(envResult.envKeys),
|
|
348
|
+
checkTimeSkew(),
|
|
349
|
+
checkDataDir(dataDir),
|
|
350
|
+
checkStoreWatermarks(dataDir),
|
|
351
|
+
checkApiReachable("gsc.api", "https://searchconsole.googleapis.com/$discovery/rest?version=v1"),
|
|
352
|
+
checkApiReachable("indexing.api", "https://indexing.googleapis.com/$discovery/rest?version=v3"),
|
|
353
|
+
checkApiReachable("siteverification.api", "https://www.googleapis.com/discovery/v1/apis/siteVerification/v1/rest")
|
|
354
|
+
]);
|
|
355
|
+
const sitesChecks = authResult.liveToken ? await checkGscSites() : [{
|
|
356
|
+
name: "gsc.sites",
|
|
357
|
+
status: "warn",
|
|
358
|
+
detail: "skipped (auth failed)"
|
|
359
|
+
}];
|
|
360
|
+
const all = [
|
|
361
|
+
...envResult.checks,
|
|
362
|
+
...authResult.checks,
|
|
363
|
+
...timeChecks,
|
|
364
|
+
...dataDirChecks,
|
|
365
|
+
...watermarkChecks,
|
|
366
|
+
...gscApi,
|
|
367
|
+
...indexingApi,
|
|
368
|
+
...siteVerificationApi,
|
|
369
|
+
...sitesChecks
|
|
370
|
+
];
|
|
371
|
+
if (json) {
|
|
372
|
+
console.log(JSON.stringify({
|
|
373
|
+
checks: all,
|
|
374
|
+
ok: all.every((c) => c.status !== "fail")
|
|
375
|
+
}, null, 2));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const ICONS = {
|
|
379
|
+
pass: "\x1B[32m✓\x1B[0m",
|
|
380
|
+
warn: "\x1B[33m!\x1B[0m",
|
|
381
|
+
fail: "\x1B[31m✗\x1B[0m",
|
|
382
|
+
info: "\x1B[34mℹ\x1B[0m"
|
|
383
|
+
};
|
|
384
|
+
console.log();
|
|
385
|
+
for (const c of all) {
|
|
386
|
+
const detail = c.detail ? ` \x1B[90m${c.detail}\x1B[0m` : "";
|
|
387
|
+
console.log(` ${ICONS[c.status]} ${c.name}${detail}`);
|
|
388
|
+
}
|
|
389
|
+
console.log();
|
|
390
|
+
const failed = all.filter((c) => c.status === "fail");
|
|
391
|
+
if (failed.length > 0) {
|
|
392
|
+
logger.error(`${failed.length} check(s) failed`);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
const warned = all.filter((c) => c.status === "warn");
|
|
396
|
+
if (warned.length > 0) logger.warn(`${warned.length} warning(s)`);
|
|
397
|
+
else logger.success("All checks passed");
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
export { doctorCommand };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { ALL_SEARCH_TYPES, OUTPUT_ARGS, applyOutputMode, displayPath, logger, parseSearchType, toCSV } from "./utils.mjs";
|
|
2
|
+
import { allTables, createCommandContext } from "./context.mjs";
|
|
3
|
+
import { readParquetRows } from "./native-duckdb.mjs";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { defineCommand } from "citty";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { Buffer } from "node:buffer";
|
|
9
|
+
const DEFAULT_OUT = "./gscdump-export";
|
|
10
|
+
const FORMATS = [
|
|
11
|
+
"parquet",
|
|
12
|
+
"json",
|
|
13
|
+
"ndjson",
|
|
14
|
+
"csv"
|
|
15
|
+
];
|
|
16
|
+
const dumpCommand = defineCommand({
|
|
17
|
+
meta: {
|
|
18
|
+
name: "dump",
|
|
19
|
+
description: "Export live Parquet files from the local store to a directory"
|
|
20
|
+
},
|
|
21
|
+
args: {
|
|
22
|
+
"site": {
|
|
23
|
+
type: "string",
|
|
24
|
+
alias: "s",
|
|
25
|
+
description: "Site URL (e.g., sc-domain:example.com); ignored with --all-sites"
|
|
26
|
+
},
|
|
27
|
+
"out": {
|
|
28
|
+
type: "string",
|
|
29
|
+
alias: "o",
|
|
30
|
+
default: DEFAULT_OUT,
|
|
31
|
+
description: `Output directory (default: ${DEFAULT_OUT})`
|
|
32
|
+
},
|
|
33
|
+
"format": {
|
|
34
|
+
type: "string",
|
|
35
|
+
alias: "F",
|
|
36
|
+
default: "parquet",
|
|
37
|
+
description: `Output format: ${FORMATS.join(", ")} (default: parquet copies raw files)`
|
|
38
|
+
},
|
|
39
|
+
"tables": {
|
|
40
|
+
type: "string",
|
|
41
|
+
alias: "t",
|
|
42
|
+
description: `Comma-separated table list (default: all). Known: ${allTables().join(", ")}`
|
|
43
|
+
},
|
|
44
|
+
"all-sites": {
|
|
45
|
+
type: "boolean",
|
|
46
|
+
default: false,
|
|
47
|
+
description: "Iterate every site with local data"
|
|
48
|
+
},
|
|
49
|
+
"compact": {
|
|
50
|
+
type: "boolean",
|
|
51
|
+
default: false,
|
|
52
|
+
description: "Compact every closed month into a single file before exporting"
|
|
53
|
+
},
|
|
54
|
+
"search-type": {
|
|
55
|
+
type: "string",
|
|
56
|
+
description: `Restrict dump to a single GSC search-type slice (${ALL_SEARCH_TYPES.join(", ")}). Default: all slices.`
|
|
57
|
+
},
|
|
58
|
+
...OUTPUT_ARGS
|
|
59
|
+
},
|
|
60
|
+
async run({ args }) {
|
|
61
|
+
const { json, quiet } = applyOutputMode(args);
|
|
62
|
+
const format = String(args.format);
|
|
63
|
+
if (!FORMATS.includes(format)) {
|
|
64
|
+
logger.error(`Invalid --format: ${format}. Allowed: ${FORMATS.join(", ")}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const tablesFilter = args.tables ? new Set(String(args.tables).split(",").map((t) => t.trim()).filter(Boolean)) : null;
|
|
68
|
+
const searchType = parseSearchType(args["search-type"]);
|
|
69
|
+
const ctx = await createCommandContext({
|
|
70
|
+
needsAuth: !args["all-sites"],
|
|
71
|
+
needsStore: true
|
|
72
|
+
});
|
|
73
|
+
const store = ctx.store;
|
|
74
|
+
const outDir = path.resolve(String(args.out));
|
|
75
|
+
const targets = args["all-sites"] ? await listSitesWithData(store) : [await ctx.resolveSite(args.site ? String(args.site) : void 0)];
|
|
76
|
+
if (targets.length === 0) {
|
|
77
|
+
logger.warn("No sites with local data. Run `gscdump sync` first.");
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
if (args.compact) for (const siteUrl of targets) await compactClosedMonths(store, siteUrl, quiet);
|
|
81
|
+
const summary = [];
|
|
82
|
+
for (const siteUrl of targets) {
|
|
83
|
+
const entries = (await listLiveEntries(store, siteUrl, searchType)).filter((e) => !tablesFilter || tablesFilter.has(e.table));
|
|
84
|
+
if (entries.length === 0) {
|
|
85
|
+
if (!quiet) logger.warn(`No data for ${siteUrl}; skipping`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (format === "parquet") {
|
|
89
|
+
const written = await dumpParquet(store, entries, outDir);
|
|
90
|
+
summary.push({
|
|
91
|
+
site: siteUrl,
|
|
92
|
+
files: written,
|
|
93
|
+
rows: 0,
|
|
94
|
+
format,
|
|
95
|
+
outPath: outDir
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
const written = await dumpRowFormat(store, entries, outDir, siteUrl, format);
|
|
99
|
+
summary.push({
|
|
100
|
+
site: siteUrl,
|
|
101
|
+
files: written.files,
|
|
102
|
+
rows: written.rows,
|
|
103
|
+
format,
|
|
104
|
+
outPath: outDir
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (json) {
|
|
109
|
+
console.log(JSON.stringify({
|
|
110
|
+
outDir,
|
|
111
|
+
sites: summary
|
|
112
|
+
}, null, 2));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const s of summary) {
|
|
116
|
+
const rows = s.rows ? `, ${s.rows.toLocaleString()} rows` : "";
|
|
117
|
+
logger.success(`[${s.site}] ${s.files} ${s.format} file(s)${rows} → ${displayPath(s.outPath)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
async function listSitesWithData(store) {
|
|
122
|
+
const siteIds = /* @__PURE__ */ new Set();
|
|
123
|
+
for (const table of allTables()) {
|
|
124
|
+
const entries = await store.engine.listLive({
|
|
125
|
+
userId: store.userId,
|
|
126
|
+
table
|
|
127
|
+
});
|
|
128
|
+
for (const e of entries) if (e.siteId) siteIds.add(e.siteId);
|
|
129
|
+
}
|
|
130
|
+
return Array.from(siteIds);
|
|
131
|
+
}
|
|
132
|
+
async function listLiveEntries(store, siteUrl, searchType) {
|
|
133
|
+
const siteId = store.siteIdFor(siteUrl);
|
|
134
|
+
return (await Promise.all(allTables().map((table) => store.engine.listLive({
|
|
135
|
+
userId: store.userId,
|
|
136
|
+
siteId,
|
|
137
|
+
table,
|
|
138
|
+
...searchType !== void 0 ? { searchType } : {}
|
|
139
|
+
})))).flat();
|
|
140
|
+
}
|
|
141
|
+
async function dumpParquet(store, entries, outDir) {
|
|
142
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
143
|
+
let copied = 0;
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const bytes = await store.engine.readObject(entry.objectKey);
|
|
146
|
+
const target = path.join(outDir, entry.objectKey);
|
|
147
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
148
|
+
await fs.writeFile(target, Buffer.from(bytes));
|
|
149
|
+
copied++;
|
|
150
|
+
}
|
|
151
|
+
return copied;
|
|
152
|
+
}
|
|
153
|
+
async function dumpRowFormat(store, entries, outDir, siteUrl, format) {
|
|
154
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
155
|
+
for (const e of entries) {
|
|
156
|
+
const arr = byTable.get(e.table) ?? [];
|
|
157
|
+
arr.push(e);
|
|
158
|
+
byTable.set(e.table, arr);
|
|
159
|
+
}
|
|
160
|
+
const safeSite = siteUrl.replace(/[^a-z0-9]+/gi, "_");
|
|
161
|
+
const siteDir = path.join(outDir, safeSite);
|
|
162
|
+
await fs.mkdir(siteDir, { recursive: true });
|
|
163
|
+
let files = 0;
|
|
164
|
+
let totalRows = 0;
|
|
165
|
+
for (const [table, tableEntries] of byTable) {
|
|
166
|
+
const rows = await readParquetRows(tableEntries.map((e) => path.join(store.dataDir, e.objectKey)), table);
|
|
167
|
+
const ext = format === "csv" ? "csv" : format === "ndjson" ? "ndjson" : "json";
|
|
168
|
+
const target = path.join(siteDir, `${table}.${ext}`);
|
|
169
|
+
let body;
|
|
170
|
+
if (format === "json") body = JSON.stringify(rows, null, 2);
|
|
171
|
+
else if (format === "ndjson") body = rows.map((r) => JSON.stringify(r)).join("\n");
|
|
172
|
+
else body = rows.length > 0 ? toCSV(rows, Object.keys(rows[0])) : "";
|
|
173
|
+
await fs.writeFile(target, body);
|
|
174
|
+
files++;
|
|
175
|
+
totalRows += rows.length;
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
files,
|
|
179
|
+
rows: totalRows
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async function compactClosedMonths(store, siteUrl, quiet) {
|
|
183
|
+
const siteId = store.siteIdFor(siteUrl);
|
|
184
|
+
for (const table of allTables()) {
|
|
185
|
+
if (!quiet) logger.info(`Compacting ${table} (raw→d7→d30→d90)`);
|
|
186
|
+
await store.engine.compactTiered({
|
|
187
|
+
userId: store.userId,
|
|
188
|
+
siteId,
|
|
189
|
+
table
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export { dumpCommand };
|