@gscdump/cli 3.4.4 → 3.6.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 +217 -54
- package/bin/gscdump.mjs +6 -1
- package/dist/analysis-local.mjs +1 -1
- package/dist/auth-state.mjs +112 -0
- package/dist/auth.mjs +109 -122
- package/dist/bing-auth.mjs +200 -0
- package/dist/bing-data.mjs +160 -0
- package/dist/bing-hosted.mjs +121 -0
- package/dist/cli.d.mts +4 -4
- package/dist/cli.mjs +20 -21
- package/dist/cloud-google.mjs +92 -0
- package/dist/command-meta.mjs +13 -1
- package/dist/command-registry.mjs +5 -2
- package/dist/commands/analyze.mjs +34 -177
- package/dist/commands/auth.mjs +161 -7
- package/dist/commands/bing.mjs +337 -0
- package/dist/commands/config.mjs +36 -43
- package/dist/commands/doctor.mjs +73 -24
- package/dist/commands/dump.mjs +1 -1
- package/dist/commands/entities.mjs +4 -4
- package/dist/commands/indexing.mjs +11 -14
- package/dist/commands/init.mjs +1 -1
- package/dist/commands/inspect.mjs +3 -3
- package/dist/commands/mcp.mjs +16 -2
- package/dist/commands/papercut.mjs +76 -0
- package/dist/commands/profile-selection.mjs +2 -2
- package/dist/commands/profile.mjs +8 -3
- package/dist/commands/query.mjs +86 -40
- package/dist/commands/report.mjs +5 -3
- package/dist/commands/sitemaps.mjs +24 -18
- package/dist/commands/skill.mjs +52 -0
- package/dist/commands/stats.mjs +58 -32
- package/dist/commands/sync.mjs +58 -25
- package/dist/config.mjs +39 -4
- package/dist/context.mjs +13 -8
- package/dist/env-file.mjs +1 -1
- package/dist/local-store.mjs +2 -2
- package/dist/mcp/errors.mjs +8 -0
- package/dist/mcp/handlers/diagnostics.mjs +31 -0
- package/dist/mcp/handlers/reports.mjs +38 -11
- package/dist/mcp/server/index.mjs +9 -10
- package/dist/mcp/types.mjs +8 -3
- package/dist/package.mjs +1 -1
- package/dist/papercut.mjs +99 -0
- package/dist/render/analysis.mjs +98 -0
- package/dist/render/charts.mjs +170 -0
- package/dist/render/layout.mjs +87 -0
- package/dist/render/metrics.mjs +163 -0
- package/dist/render/query.mjs +30 -0
- package/dist/render/report.mjs +69 -0
- package/dist/render/terminal.mjs +25 -0
- package/dist/runtime.d.mts +5 -5
- package/dist/runtime.mjs +1 -1
- package/dist/skill.mjs +45 -0
- package/dist/utils.mjs +10 -38
- package/package.json +14 -12
- package/skills/gscdump/SKILL.md +287 -0
package/dist/auth.mjs
CHANGED
|
@@ -3,17 +3,18 @@ import { pickCliEnvironmentValue, resolveCliEnvironment } from "./environment.mj
|
|
|
3
3
|
import { getAppliedEnvKeys, getLoadedEnvPath } from "./env-file.mjs";
|
|
4
4
|
import { displayPath, logger } from "./utils.mjs";
|
|
5
5
|
import process from "node:process";
|
|
6
|
-
import
|
|
6
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
7
|
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { Buffer } from "node:buffer";
|
|
8
10
|
import { err, ok, unwrapResult } from "gscdump/result";
|
|
9
|
-
import {
|
|
11
|
+
import { text } from "@clack/prompts";
|
|
10
12
|
import { createAuth } from "gscdump/client";
|
|
11
13
|
import { createServer } from "node:http";
|
|
12
|
-
import { JWT, OAuth2Client } from "google-auth-library";
|
|
13
|
-
import
|
|
14
|
+
import { CodeChallengeMethod, JWT, OAuth2Client } from "google-auth-library";
|
|
15
|
+
import open from "open";
|
|
14
16
|
function authErrorToException(error) {
|
|
15
17
|
const exception = new Error(error.message);
|
|
16
|
-
if ("cause" in error && error.cause !== void 0) exception.cause = error.cause;
|
|
17
18
|
exception.authError = error;
|
|
18
19
|
return exception;
|
|
19
20
|
}
|
|
@@ -46,70 +47,6 @@ async function resolveServiceAccount(opts = {}) {
|
|
|
46
47
|
if (!p) return null;
|
|
47
48
|
return loadServiceAccount(p);
|
|
48
49
|
}
|
|
49
|
-
async function authenticateDeviceCode(credentials) {
|
|
50
|
-
return unwrapResult(await authenticateDeviceCodeResult(credentials), authErrorToException);
|
|
51
|
-
}
|
|
52
|
-
async function authenticateDeviceCodeResult(credentials) {
|
|
53
|
-
const init = await ofetch("https://oauth2.googleapis.com/device/code", {
|
|
54
|
-
method: "POST",
|
|
55
|
-
body: new URLSearchParams({
|
|
56
|
-
client_id: credentials.clientId,
|
|
57
|
-
scope: SCOPES.join(" ")
|
|
58
|
-
})
|
|
59
|
-
}).then(ok).catch((e) => err({
|
|
60
|
-
kind: "device-code-request-failed",
|
|
61
|
-
message: `Device-code request failed: ${e.message}`,
|
|
62
|
-
cause: e
|
|
63
|
-
}));
|
|
64
|
-
if (!init.ok) return init;
|
|
65
|
-
return pollDeviceCode(credentials, init.value);
|
|
66
|
-
}
|
|
67
|
-
async function pollDeviceCode(credentials, init) {
|
|
68
|
-
console.log();
|
|
69
|
-
console.log(` \x1B[1mDevice-code OAuth\x1B[0m`);
|
|
70
|
-
console.log(` 1. On any device, open: \x1B[36m${init.verification_url}\x1B[0m`);
|
|
71
|
-
console.log(` 2. Enter this code: \x1B[1m${init.user_code}\x1B[0m`);
|
|
72
|
-
console.log(` 3. Approve the requested scopes`);
|
|
73
|
-
console.log();
|
|
74
|
-
logger.info(`Polling for completion (expires in ${Math.floor(init.expires_in / 60)}m)...`);
|
|
75
|
-
const intervalMs = init.interval * 1e3;
|
|
76
|
-
const deadline = Date.now() + init.expires_in * 1e3;
|
|
77
|
-
while (Date.now() < deadline) {
|
|
78
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
79
|
-
const res = await ofetch("https://oauth2.googleapis.com/token", {
|
|
80
|
-
method: "POST",
|
|
81
|
-
body: new URLSearchParams({
|
|
82
|
-
client_id: credentials.clientId,
|
|
83
|
-
client_secret: credentials.clientSecret,
|
|
84
|
-
device_code: init.device_code,
|
|
85
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
86
|
-
})
|
|
87
|
-
}).catch((e) => e?.data ?? { error: "request_failed" });
|
|
88
|
-
if (res.access_token) return ok({
|
|
89
|
-
access_token: res.access_token,
|
|
90
|
-
refresh_token: res.refresh_token,
|
|
91
|
-
expiry_date: res.expires_in ? Date.now() + res.expires_in * 1e3 : void 0
|
|
92
|
-
});
|
|
93
|
-
if (res.error === "authorization_pending" || res.error === "slow_down") continue;
|
|
94
|
-
if (res.error === "access_denied") return err({
|
|
95
|
-
kind: "device-code-denied",
|
|
96
|
-
message: "User denied authorization."
|
|
97
|
-
});
|
|
98
|
-
if (res.error === "expired_token") return err({
|
|
99
|
-
kind: "device-code-expired",
|
|
100
|
-
message: "Device code expired. Re-run `gscdump auth login --no-browser`."
|
|
101
|
-
});
|
|
102
|
-
if (res.error) return err({
|
|
103
|
-
kind: "device-code-failed",
|
|
104
|
-
reason: res.error,
|
|
105
|
-
message: `Device-code poll failed: ${res.error_description || res.error}`
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
return err({
|
|
109
|
-
kind: "device-code-timed-out",
|
|
110
|
-
message: "Device-code flow timed out."
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
50
|
function resolveBYOK(opts = {}) {
|
|
114
51
|
const env = resolveCliEnvironment();
|
|
115
52
|
const accessToken = opts.accessToken || env.accessToken;
|
|
@@ -124,7 +61,6 @@ function resolveBYOK(opts = {}) {
|
|
|
124
61
|
if (accessToken) return accessToken;
|
|
125
62
|
return null;
|
|
126
63
|
}
|
|
127
|
-
const REDIRECT_URI_RE = /redirect_uri=[^&]+/;
|
|
128
64
|
function getTokensPath() {
|
|
129
65
|
return path.join(getConfigDir(), "tokens.json");
|
|
130
66
|
}
|
|
@@ -186,12 +122,12 @@ async function getAuthCredentials(interactive) {
|
|
|
186
122
|
placeholder: "your-client-id.googleusercontent.com",
|
|
187
123
|
validate: (v) => v ? void 0 : "Required"
|
|
188
124
|
});
|
|
189
|
-
if (
|
|
125
|
+
if (typeof clientIdResult !== "string") process.exit(1);
|
|
190
126
|
const clientSecretResult = await text({
|
|
191
127
|
message: "Enter your Google OAuth Client Secret:",
|
|
192
128
|
validate: (v) => v ? void 0 : "Required"
|
|
193
129
|
});
|
|
194
|
-
if (
|
|
130
|
+
if (typeof clientSecretResult !== "string") process.exit(1);
|
|
195
131
|
console.log();
|
|
196
132
|
logger.info("Tip: Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars to skip prompts");
|
|
197
133
|
return {
|
|
@@ -199,65 +135,114 @@ async function getAuthCredentials(interactive) {
|
|
|
199
135
|
clientSecret: clientSecretResult
|
|
200
136
|
};
|
|
201
137
|
}
|
|
202
|
-
async function getAuthCodeViaLoopback(authUrl) {
|
|
138
|
+
async function getAuthCodeViaLoopback(authUrl, expectedState, noBrowser) {
|
|
203
139
|
return new Promise((resolve, reject) => {
|
|
204
140
|
let resolvedRedirectUri = "";
|
|
205
141
|
let timeoutId;
|
|
206
|
-
let
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
142
|
+
let settled = false;
|
|
143
|
+
const server = createServer();
|
|
144
|
+
const settle = (result) => {
|
|
145
|
+
if (settled) return;
|
|
146
|
+
settled = true;
|
|
147
|
+
clearTimeout(timeoutId);
|
|
210
148
|
server.close();
|
|
211
|
-
|
|
149
|
+
server.closeAllConnections();
|
|
150
|
+
if (result.ok) resolve(result.value);
|
|
151
|
+
else reject(result.error);
|
|
212
152
|
};
|
|
213
|
-
server
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (
|
|
218
|
-
res.writeHead(
|
|
219
|
-
res.end(
|
|
220
|
-
|
|
153
|
+
server.on("request", (req, res) => {
|
|
154
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
155
|
+
res.setHeader("Cache-Control", "no-store");
|
|
156
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
157
|
+
if (settled) {
|
|
158
|
+
res.writeHead(409);
|
|
159
|
+
res.end("Authorization already completed.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!URL.canParse(req.url || "/", "http://127.0.0.1")) {
|
|
163
|
+
res.writeHead(400);
|
|
164
|
+
res.end("Invalid authorization URL.");
|
|
221
165
|
return;
|
|
222
166
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
res.
|
|
226
|
-
|
|
227
|
-
|
|
167
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
168
|
+
if (req.method !== "GET" || url.pathname !== "/") {
|
|
169
|
+
res.writeHead(404);
|
|
170
|
+
res.end("Not found.");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const state = url.searchParams.getAll("state");
|
|
174
|
+
const receivedState = Buffer.from(state[0] ?? "");
|
|
175
|
+
const expected = Buffer.from(expectedState);
|
|
176
|
+
if (state.length !== 1 || receivedState.length !== expected.length || !timingSafeEqual(receivedState, expected)) {
|
|
177
|
+
res.writeHead(400);
|
|
178
|
+
res.end("Invalid authorization state. Use the URL shown in the terminal.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const codes = url.searchParams.getAll("code");
|
|
182
|
+
const errors = url.searchParams.getAll("error");
|
|
183
|
+
if (codes.length === 1 && codes[0] && errors.length === 0) {
|
|
184
|
+
res.once("finish", () => settle(ok({
|
|
185
|
+
code: codes[0],
|
|
228
186
|
redirectUri: resolvedRedirectUri
|
|
229
|
-
}));
|
|
187
|
+
})));
|
|
188
|
+
res.writeHead(200);
|
|
189
|
+
res.end("Authorization successful. Close this window and return to the terminal.");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (errors.length === 1 && errors[0] && codes.length === 0) {
|
|
193
|
+
const error = errors[0] === "access_denied" ? {
|
|
194
|
+
kind: "oauth-denied",
|
|
195
|
+
message: "Authorization denied. If you want to retry, run `gscdump auth login`."
|
|
196
|
+
} : {
|
|
197
|
+
kind: "oauth-failed",
|
|
198
|
+
message: "Authorization failed. Run `gscdump auth login` to retry."
|
|
199
|
+
};
|
|
200
|
+
res.once("finish", () => settle(err(authErrorToException(error))));
|
|
201
|
+
res.writeHead(400);
|
|
202
|
+
res.end(error.message);
|
|
230
203
|
return;
|
|
231
204
|
}
|
|
232
|
-
res.writeHead(400
|
|
233
|
-
res.end(
|
|
205
|
+
res.writeHead(400);
|
|
206
|
+
res.end("Invalid authorization response. Use the URL shown in the terminal.");
|
|
234
207
|
});
|
|
208
|
+
server.on("error", (error) => settle(err(error)));
|
|
209
|
+
timeoutId = setTimeout(() => {
|
|
210
|
+
settle(err(authErrorToException({
|
|
211
|
+
kind: "oauth-timed-out",
|
|
212
|
+
message: "Authorization timed out. Use a Desktop application OAuth client. Run `gscdump auth login` to retry."
|
|
213
|
+
})));
|
|
214
|
+
}, 3e5);
|
|
235
215
|
server.listen(0, "127.0.0.1", () => {
|
|
236
216
|
const addr = server.address();
|
|
237
217
|
if (!addr || typeof addr === "string") {
|
|
238
|
-
settle((
|
|
218
|
+
settle(err(/* @__PURE__ */ new Error("Failed to start local server")));
|
|
239
219
|
return;
|
|
240
220
|
}
|
|
241
221
|
resolvedRedirectUri = `http://127.0.0.1:${addr.port}`;
|
|
242
|
-
|
|
222
|
+
authUrl.searchParams.set("redirect_uri", resolvedRedirectUri);
|
|
223
|
+
const fullAuthUrl = authUrl.toString();
|
|
243
224
|
console.log();
|
|
244
|
-
console.log("
|
|
245
|
-
|
|
246
|
-
|
|
225
|
+
if (noBrowser) console.log(" Open this URL in your browser:");
|
|
226
|
+
else {
|
|
227
|
+
console.log(" Opening browser for authorization...");
|
|
228
|
+
console.log(" If the browser does not open, visit:");
|
|
229
|
+
}
|
|
230
|
+
console.log(` ${fullAuthUrl}`);
|
|
247
231
|
console.log();
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
232
|
+
if (noBrowser) {
|
|
233
|
+
console.log(` If the CLI runs on another host, forward loopback port ${addr.port} before opening the URL.`);
|
|
234
|
+
console.log(` On your browser host, run: ssh -N -L ${addr.port}:127.0.0.1:${addr.port} user@host`);
|
|
235
|
+
console.log(" Replace user@host with the CLI host. Keep this command running during login.");
|
|
236
|
+
console.log();
|
|
237
|
+
}
|
|
238
|
+
console.log(" If Google reports redirect_uri_mismatch, use a Desktop application OAuth client.");
|
|
239
|
+
console.log(" Create it at https://console.cloud.google.com/apis/credentials.");
|
|
240
|
+
console.log(" Then run `gscdump init --force` with the new client credentials.");
|
|
252
241
|
console.log();
|
|
253
|
-
|
|
254
|
-
logger.warn("Could not open browser automatically");
|
|
242
|
+
if (!noBrowser) open(fullAuthUrl).catch(() => {
|
|
243
|
+
logger.warn("Could not open browser automatically. Open the URL shown above.");
|
|
255
244
|
});
|
|
256
245
|
});
|
|
257
|
-
server.on("error", (err) => settle(() => reject(err)));
|
|
258
|
-
timeoutId = setTimeout(() => {
|
|
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
|
-
}, 3e5);
|
|
261
246
|
});
|
|
262
247
|
}
|
|
263
248
|
async function authenticate(credentials, interactive, opts = {}) {
|
|
@@ -312,21 +297,23 @@ async function authenticate(credentials, interactive, opts = {}) {
|
|
|
312
297
|
}
|
|
313
298
|
process.exit(1);
|
|
314
299
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
await saveTokens(tokens);
|
|
319
|
-
logger.success(`Tokens saved to ${displayPath(getTokensPath())}`);
|
|
320
|
-
return oauth2Client;
|
|
321
|
-
}
|
|
322
|
-
const authUrl = oauth2Client.generateAuthUrl({
|
|
300
|
+
const state = randomBytes(32).toString("base64url");
|
|
301
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
302
|
+
const authUrl = new URL(oauth2Client.generateAuthUrl({
|
|
323
303
|
access_type: "offline",
|
|
324
304
|
scope: SCOPES,
|
|
325
|
-
prompt: "consent"
|
|
326
|
-
|
|
305
|
+
prompt: "consent",
|
|
306
|
+
state,
|
|
307
|
+
code_challenge: createHash("sha256").update(codeVerifier).digest("base64url"),
|
|
308
|
+
code_challenge_method: CodeChallengeMethod.S256
|
|
309
|
+
}));
|
|
327
310
|
logger.info("Waiting for authorization...");
|
|
328
|
-
const { code, redirectUri } = await getAuthCodeViaLoopback(authUrl);
|
|
329
|
-
const { tokens } = await new OAuth2Client(credentials.clientId, credentials.clientSecret, redirectUri).getToken(
|
|
311
|
+
const { code, redirectUri } = await getAuthCodeViaLoopback(authUrl, state, Boolean(opts.noBrowser));
|
|
312
|
+
const { tokens } = await new OAuth2Client(credentials.clientId, credentials.clientSecret, redirectUri).getToken({
|
|
313
|
+
code,
|
|
314
|
+
codeVerifier,
|
|
315
|
+
redirect_uri: redirectUri
|
|
316
|
+
});
|
|
330
317
|
oauth2Client.setCredentials(tokens);
|
|
331
318
|
await saveTokens(tokens);
|
|
332
319
|
logger.success(`Tokens saved to ${displayPath(getTokensPath())}`);
|
|
@@ -473,4 +460,4 @@ function isAuthError(err) {
|
|
|
473
460
|
if (!msg) return false;
|
|
474
461
|
return /\b(?:401|403|unauthorized|forbidden|invalid_grant|invalid_token|insufficient.*scope|invalid_client|token has been expired|token has been revoked)\b/.test(msg) || msg.includes("oauth2.googleapis.com/token");
|
|
475
462
|
}
|
|
476
|
-
export { authenticate,
|
|
463
|
+
export { authenticate, clearTokens, describeAuthProvenance, formatAuthProvenance, getAuth, getAuthCredentials, isAuthError, loadServiceAccount, loadServiceAccountResult, loadTokens, resolveAuth, resolveBYOK, resolveServiceAccount, saveTokens };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useCliRuntime } from "./runtime.mjs";
|
|
2
|
+
import { getConfigDir } from "./config.mjs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { bingWebmaster } from "gscdump/bing";
|
|
9
|
+
const secret = z.string().trim().min(1);
|
|
10
|
+
const oauthClientSchema = z.object({
|
|
11
|
+
clientId: secret,
|
|
12
|
+
clientSecret: secret,
|
|
13
|
+
redirectUri: z.url()
|
|
14
|
+
});
|
|
15
|
+
const credentialsSchema = z.discriminatedUnion("_tag", [z.object({
|
|
16
|
+
_tag: z.literal("ApiKey"),
|
|
17
|
+
apiKey: secret
|
|
18
|
+
}), z.object({
|
|
19
|
+
_tag: z.literal("OAuth"),
|
|
20
|
+
...oauthClientSchema.shape,
|
|
21
|
+
accessToken: secret,
|
|
22
|
+
refreshToken: secret,
|
|
23
|
+
expiresAt: z.number().finite().positive()
|
|
24
|
+
})]);
|
|
25
|
+
const tokenSchema = z.object({
|
|
26
|
+
access_token: secret,
|
|
27
|
+
refresh_token: secret.optional(),
|
|
28
|
+
expires_in: z.union([z.number(), z.string().regex(/^\d+$/).transform(Number)]).pipe(z.number().int().positive().max(31536e3))
|
|
29
|
+
});
|
|
30
|
+
const BING_REDIRECT_URI = "http://127.0.0.1:53683/oauth/bing";
|
|
31
|
+
function credentialsPath() {
|
|
32
|
+
return path.join(getConfigDir(), "bing-tokens.json");
|
|
33
|
+
}
|
|
34
|
+
async function saveBingCredentials(credentials) {
|
|
35
|
+
const value = credentialsSchema.parse(credentials);
|
|
36
|
+
await fs.mkdir(getConfigDir(), {
|
|
37
|
+
recursive: true,
|
|
38
|
+
mode: 448
|
|
39
|
+
});
|
|
40
|
+
const temporary = `${credentialsPath()}.${randomUUID()}.tmp`;
|
|
41
|
+
try {
|
|
42
|
+
await fs.writeFile(temporary, JSON.stringify(value, null, 2), {
|
|
43
|
+
mode: 384,
|
|
44
|
+
flag: "wx"
|
|
45
|
+
});
|
|
46
|
+
await fs.rename(temporary, credentialsPath());
|
|
47
|
+
} finally {
|
|
48
|
+
await fs.rm(temporary, { force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function clearBingCredentials() {
|
|
52
|
+
await fs.rm(credentialsPath(), { force: true });
|
|
53
|
+
}
|
|
54
|
+
async function inspectBingCredentials() {
|
|
55
|
+
const env = useCliRuntime().environment;
|
|
56
|
+
if (env.BING_API_KEY?.trim()) return { _tag: "ApiKey" };
|
|
57
|
+
if (env.BING_ACCESS_TOKEN?.trim()) return { _tag: "AccessToken" };
|
|
58
|
+
return { _tag: (await loadBingCredentials())?._tag ?? "Missing" };
|
|
59
|
+
}
|
|
60
|
+
async function loadBingCredentials() {
|
|
61
|
+
const body = await fs.readFile(credentialsPath(), "utf8").catch((error) => {
|
|
62
|
+
if (error.code === "ENOENT") return null;
|
|
63
|
+
throw error;
|
|
64
|
+
});
|
|
65
|
+
if (body === null) return null;
|
|
66
|
+
const parsed = credentialsSchema.safeParse(JSON.parse(body));
|
|
67
|
+
if (!parsed.success) throw new Error("Saved Bing credentials are invalid. Run `gscdump bing login` again.");
|
|
68
|
+
return parsed.data;
|
|
69
|
+
}
|
|
70
|
+
async function requestToken(client, grant) {
|
|
71
|
+
const response = await fetch("https://www.bing.com/webmasters/oauth/token", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
74
|
+
body: new URLSearchParams({
|
|
75
|
+
client_id: client.clientId,
|
|
76
|
+
client_secret: client.clientSecret,
|
|
77
|
+
redirect_uri: client.redirectUri,
|
|
78
|
+
...grant
|
|
79
|
+
}),
|
|
80
|
+
signal: AbortSignal.timeout(3e4)
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) throw new Error(`Bing token request failed (${response.status}). Run \`gscdump bing login --oauth\` again.`);
|
|
83
|
+
const payload = await response.json().catch((error) => {
|
|
84
|
+
if (error instanceof SyntaxError) throw new Error("Bing returned an invalid OAuth token.");
|
|
85
|
+
throw error;
|
|
86
|
+
});
|
|
87
|
+
const token = tokenSchema.safeParse(payload);
|
|
88
|
+
if (!token.success) throw new Error("Bing returned an invalid OAuth token.");
|
|
89
|
+
return token.data;
|
|
90
|
+
}
|
|
91
|
+
async function getBingClient() {
|
|
92
|
+
const env = useCliRuntime().environment;
|
|
93
|
+
if (env.BING_API_KEY?.trim()) return bingWebmaster({ apiKey: env.BING_API_KEY.trim() });
|
|
94
|
+
if (env.BING_ACCESS_TOKEN?.trim()) return bingWebmaster({ accessToken: env.BING_ACCESS_TOKEN.trim() });
|
|
95
|
+
const credentials = await loadBingCredentials();
|
|
96
|
+
if (!credentials) throw new Error("Bing credentials are missing. Run `gscdump bing login` or set BING_API_KEY.");
|
|
97
|
+
if (credentials._tag === "ApiKey") return bingWebmaster({ apiKey: credentials.apiKey });
|
|
98
|
+
let current = credentials;
|
|
99
|
+
let refreshing;
|
|
100
|
+
const accessToken = () => {
|
|
101
|
+
if (current.expiresAt > Date.now() + 6e4) return Promise.resolve(current.accessToken);
|
|
102
|
+
refreshing ??= requestToken(current, {
|
|
103
|
+
grant_type: "refresh_token",
|
|
104
|
+
refresh_token: current.refreshToken
|
|
105
|
+
}).then(async (token) => {
|
|
106
|
+
const updated = {
|
|
107
|
+
...current,
|
|
108
|
+
accessToken: token.access_token,
|
|
109
|
+
refreshToken: token.refresh_token ?? current.refreshToken,
|
|
110
|
+
expiresAt: Date.now() + token.expires_in * 1e3
|
|
111
|
+
};
|
|
112
|
+
await saveBingCredentials(updated);
|
|
113
|
+
current = updated;
|
|
114
|
+
return updated.accessToken;
|
|
115
|
+
}).finally(() => {
|
|
116
|
+
refreshing = void 0;
|
|
117
|
+
});
|
|
118
|
+
return refreshing;
|
|
119
|
+
};
|
|
120
|
+
await accessToken();
|
|
121
|
+
return bingWebmaster({ accessToken });
|
|
122
|
+
}
|
|
123
|
+
async function loginBingOAuth(input) {
|
|
124
|
+
const parsed = oauthClientSchema.safeParse(input);
|
|
125
|
+
if (!parsed.success) throw new Error("Bing OAuth needs a client ID, client secret, and redirect URI.");
|
|
126
|
+
const client = parsed.data;
|
|
127
|
+
const redirect = new URL(client.redirectUri);
|
|
128
|
+
if (redirect.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(redirect.hostname) || !redirect.port || redirect.username || redirect.password || redirect.search || redirect.hash) throw new Error("Use an HTTP loopback redirect URI with an explicit port and no query or fragment.");
|
|
129
|
+
const state = randomUUID();
|
|
130
|
+
const authorize = new URL("https://www.bing.com/webmasters/oauth/authorize");
|
|
131
|
+
authorize.search = new URLSearchParams({
|
|
132
|
+
client_id: client.clientId,
|
|
133
|
+
redirect_uri: client.redirectUri,
|
|
134
|
+
response_type: "code",
|
|
135
|
+
scope: "webmaster.manage",
|
|
136
|
+
state
|
|
137
|
+
}).toString();
|
|
138
|
+
const result = Promise.withResolvers();
|
|
139
|
+
const settled = result.promise.then((code) => ({ code }), (error) => ({ error }));
|
|
140
|
+
const server = createServer((request, response) => {
|
|
141
|
+
const callback = URL.parse(request.url ?? "/", redirect.origin);
|
|
142
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
143
|
+
response.setHeader("Cache-Control", "no-store");
|
|
144
|
+
if (!callback) {
|
|
145
|
+
response.writeHead(400).end("Invalid OAuth callback URL.");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (request.method !== "GET" || callback.pathname !== redirect.pathname) {
|
|
149
|
+
response.writeHead(404).end("Not found.");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (callback.searchParams.getAll("state").length !== 1 || callback.searchParams.get("state") !== state || callback.searchParams.getAll("code").length > 1 || callback.searchParams.getAll("error").length > 1) {
|
|
153
|
+
response.writeHead(400).end("Invalid OAuth state. Use the original login link.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const code = callback.searchParams.get("code");
|
|
157
|
+
if (!code && !callback.searchParams.has("error") || code && callback.searchParams.has("error")) {
|
|
158
|
+
response.writeHead(400).end("Invalid OAuth response. Use the original login link.");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (callback.searchParams.has("error") || !code) {
|
|
162
|
+
response.writeHead(400).end("Bing authorization failed. Return to the terminal.");
|
|
163
|
+
result.reject(/* @__PURE__ */ new Error("Bing authorization failed. Run `gscdump bing login --oauth` again."));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
response.end("Bing authorization received. Return to the terminal.");
|
|
167
|
+
result.resolve(code);
|
|
168
|
+
});
|
|
169
|
+
const timeout = setTimeout(() => result.reject(/* @__PURE__ */ new Error("Bing login timed out. Run `gscdump bing login --oauth` again.")), input.timeoutMs ?? 3e5);
|
|
170
|
+
try {
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
server.once("error", reject);
|
|
173
|
+
server.listen(Number(redirect.port), redirect.hostname, () => resolve());
|
|
174
|
+
});
|
|
175
|
+
await input.openUrl(authorize.toString());
|
|
176
|
+
const authorization = await settled;
|
|
177
|
+
if ("error" in authorization) throw authorization.error;
|
|
178
|
+
const token = await requestToken(client, {
|
|
179
|
+
grant_type: "authorization_code",
|
|
180
|
+
code: authorization.code
|
|
181
|
+
});
|
|
182
|
+
if (!token.refresh_token) throw new Error("Bing did not return a refresh token. Run `gscdump bing login --oauth` again.");
|
|
183
|
+
const sites = await bingWebmaster({ accessToken: token.access_token }).getUserSites({ signal: AbortSignal.timeout(3e4) });
|
|
184
|
+
if (!sites.ok) throw new Error(`Bing login failed: ${sites.error._tag}.`);
|
|
185
|
+
await saveBingCredentials({
|
|
186
|
+
_tag: "OAuth",
|
|
187
|
+
...client,
|
|
188
|
+
accessToken: token.access_token,
|
|
189
|
+
refreshToken: token.refresh_token,
|
|
190
|
+
expiresAt: Date.now() + token.expires_in * 1e3
|
|
191
|
+
});
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timeout);
|
|
194
|
+
server.closeAllConnections();
|
|
195
|
+
await new Promise((resolve) => {
|
|
196
|
+
server.close(() => resolve());
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export { BING_REDIRECT_URI, clearBingCredentials, getBingClient, inspectBingCredentials, loginBingOAuth, saveBingCredentials };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { toCSV } from "./utils.mjs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const BING_DATASETS = [
|
|
6
|
+
"traffic",
|
|
7
|
+
"pages",
|
|
8
|
+
"keywords",
|
|
9
|
+
"crawl",
|
|
10
|
+
"crawl-issues"
|
|
11
|
+
];
|
|
12
|
+
const datedDatasets = BING_DATASETS.filter((dataset) => dataset !== "crawl-issues");
|
|
13
|
+
const dateSchema = z.iso.date();
|
|
14
|
+
const dumpSchema = z.object({
|
|
15
|
+
format: z.enum([
|
|
16
|
+
"json",
|
|
17
|
+
"ndjson",
|
|
18
|
+
"csv"
|
|
19
|
+
]).default("json"),
|
|
20
|
+
datasets: z.array(z.enum(BING_DATASETS)).min(1),
|
|
21
|
+
start: dateSchema.optional(),
|
|
22
|
+
end: dateSchema.optional()
|
|
23
|
+
}).refine((value) => !value.start || !value.end || value.start <= value.end, { message: "--start must precede --end" });
|
|
24
|
+
function parseBingDumpOptions(args) {
|
|
25
|
+
const parsed = dumpSchema.safeParse({
|
|
26
|
+
...args,
|
|
27
|
+
datasets: args.datasets ? [...new Set(args.datasets.split(",").map((value) => value.trim()))] : datedDatasets
|
|
28
|
+
});
|
|
29
|
+
if (!parsed.success) throw new Error(`Invalid Bing dump options: ${parsed.error.issues.map((issue) => issue.message).join("; ")}.`);
|
|
30
|
+
if (parsed.data.datasets.includes("crawl-issues") && (parsed.data.start || parsed.data.end)) throw new Error("Bing crawl issues have no dates. Export them without --start or --end.");
|
|
31
|
+
return parsed.data;
|
|
32
|
+
}
|
|
33
|
+
function unwrapBing(result) {
|
|
34
|
+
if (result.ok) return result.value;
|
|
35
|
+
const error = result.error;
|
|
36
|
+
const help = error._tag === "AuthenticationRequired" ? " Run `gscdump bing login` again." : error._tag === "Throttled" && error.retryAfterMs !== void 0 ? ` Retry after ${Math.ceil(error.retryAfterMs / 1e3)} seconds.` : "";
|
|
37
|
+
throw new Error(`Bing request failed: ${error._tag}.${help}`);
|
|
38
|
+
}
|
|
39
|
+
function siteIdentity(value) {
|
|
40
|
+
const url = URL.parse(value);
|
|
41
|
+
if (!url || !["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("Use the full Bing site URL from `gscdump bing sites`.");
|
|
42
|
+
return url.toString();
|
|
43
|
+
}
|
|
44
|
+
async function resolveBingSites(client, input) {
|
|
45
|
+
if (Boolean(input.site) === Boolean(input.allSites)) throw new Error("Choose --site URL or --all-sites.");
|
|
46
|
+
const wanted = input.site ? siteIdentity(input.site) : null;
|
|
47
|
+
const verified = unwrapBing(await client.getUserSites({ signal: AbortSignal.timeout(3e4) })).filter((site) => site.isVerified);
|
|
48
|
+
if (input.allSites) {
|
|
49
|
+
const unique = new Map(verified.map((site) => [siteIdentity(site.url), site.url]));
|
|
50
|
+
if (unique.size === 0) throw new Error("No verified Bing sites are available.");
|
|
51
|
+
return [...unique.values()];
|
|
52
|
+
}
|
|
53
|
+
const match = verified.find((site) => siteIdentity(site.url) === wanted);
|
|
54
|
+
if (!match) throw new Error("The Bing site is missing or unverified. Run `gscdump bing sites` and verify the site.");
|
|
55
|
+
return [match.url];
|
|
56
|
+
}
|
|
57
|
+
const columns = {
|
|
58
|
+
"traffic": [
|
|
59
|
+
"date",
|
|
60
|
+
"clicks",
|
|
61
|
+
"impressions"
|
|
62
|
+
],
|
|
63
|
+
"pages": [
|
|
64
|
+
"date",
|
|
65
|
+
"page",
|
|
66
|
+
"clicks",
|
|
67
|
+
"impressions",
|
|
68
|
+
"averageClickPosition",
|
|
69
|
+
"averageImpressionPosition"
|
|
70
|
+
],
|
|
71
|
+
"keywords": [
|
|
72
|
+
"date",
|
|
73
|
+
"query",
|
|
74
|
+
"clicks",
|
|
75
|
+
"impressions",
|
|
76
|
+
"averageClickPosition",
|
|
77
|
+
"averageImpressionPosition"
|
|
78
|
+
],
|
|
79
|
+
"crawl": [
|
|
80
|
+
"date",
|
|
81
|
+
"crawledPages",
|
|
82
|
+
"inIndex",
|
|
83
|
+
"inLinks",
|
|
84
|
+
"crawlErrors",
|
|
85
|
+
"blockedByRobotsTxt",
|
|
86
|
+
"containsMalware",
|
|
87
|
+
"code2xx",
|
|
88
|
+
"code301",
|
|
89
|
+
"code302",
|
|
90
|
+
"code4xx",
|
|
91
|
+
"code5xx",
|
|
92
|
+
"allOtherCodes",
|
|
93
|
+
"connectionTimeout",
|
|
94
|
+
"dnsFailures"
|
|
95
|
+
],
|
|
96
|
+
"crawl-issues": [
|
|
97
|
+
"url",
|
|
98
|
+
"httpCode",
|
|
99
|
+
"inLinks",
|
|
100
|
+
"issue",
|
|
101
|
+
"rawIssueCode"
|
|
102
|
+
]
|
|
103
|
+
};
|
|
104
|
+
async function dumpBingSite(client, siteUrl, outDir, options) {
|
|
105
|
+
const calls = {
|
|
106
|
+
"traffic": client.getRankAndTrafficStats,
|
|
107
|
+
"pages": client.getPageStats,
|
|
108
|
+
"keywords": client.getQueryStats,
|
|
109
|
+
"crawl": client.getCrawlStats,
|
|
110
|
+
"crawl-issues": client.getCrawlIssues
|
|
111
|
+
};
|
|
112
|
+
const results = await Promise.all(options.datasets.map(async (dataset) => {
|
|
113
|
+
return {
|
|
114
|
+
dataset,
|
|
115
|
+
rows: unwrapBing(await calls[dataset](siteUrl, { signal: AbortSignal.timeout(3e4) })).filter((row) => {
|
|
116
|
+
if (!("date" in row) || typeof row.date !== "string") return true;
|
|
117
|
+
const date = row.date.slice(0, 10);
|
|
118
|
+
return (!options.start || date >= options.start) && (!options.end || date <= options.end);
|
|
119
|
+
})
|
|
120
|
+
};
|
|
121
|
+
}));
|
|
122
|
+
return writeBingDump(siteUrl, outDir, options.format, results);
|
|
123
|
+
}
|
|
124
|
+
async function writeBingDump(siteUrl, outDir, format, results) {
|
|
125
|
+
const siteDir = path.resolve(outDir, encodeURIComponent(siteUrl));
|
|
126
|
+
await fs.mkdir(siteDir, { recursive: true });
|
|
127
|
+
const staging = await fs.mkdtemp(path.join(siteDir, ".dump-"));
|
|
128
|
+
try {
|
|
129
|
+
const files = [];
|
|
130
|
+
for (const { dataset, rows, sync, semantics } of results) {
|
|
131
|
+
const name = `${dataset}.${format}`;
|
|
132
|
+
const body = format === "json" ? `${JSON.stringify(rows, null, 2)}\n` : format === "ndjson" ? rows.map((row) => `${JSON.stringify(row)}\n`).join("") : `${toCSV(rows, columns[dataset])}\n`;
|
|
133
|
+
await fs.writeFile(path.join(staging, name), body);
|
|
134
|
+
files.push({
|
|
135
|
+
dataset,
|
|
136
|
+
rows: rows.length,
|
|
137
|
+
path: path.join(siteDir, name),
|
|
138
|
+
...sync ? { sync } : {},
|
|
139
|
+
...semantics ? { semantics } : {}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const summary = {
|
|
143
|
+
searchEngine: "bing",
|
|
144
|
+
siteUrl,
|
|
145
|
+
observedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
146
|
+
format,
|
|
147
|
+
files
|
|
148
|
+
};
|
|
149
|
+
await fs.writeFile(path.join(staging, "metadata.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
|
150
|
+
for (const file of files) await fs.rename(path.join(staging, path.basename(file.path)), file.path);
|
|
151
|
+
await fs.rename(path.join(staging, "metadata.json"), path.join(siteDir, "metadata.json"));
|
|
152
|
+
return summary;
|
|
153
|
+
} finally {
|
|
154
|
+
await fs.rm(staging, {
|
|
155
|
+
recursive: true,
|
|
156
|
+
force: true
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export { BING_DATASETS, dumpBingSite, parseBingDumpOptions, resolveBingSites, unwrapBing, writeBingDump };
|