@gscdump/cli 3.5.0 → 3.6.1

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.
Files changed (57) hide show
  1. package/README.md +217 -54
  2. package/bin/gscdump.mjs +6 -1
  3. package/dist/analysis-local.mjs +1 -1
  4. package/dist/auth-state.mjs +112 -0
  5. package/dist/auth.mjs +109 -122
  6. package/dist/bing-auth.mjs +200 -0
  7. package/dist/bing-data.mjs +160 -0
  8. package/dist/bing-hosted.mjs +121 -0
  9. package/dist/cli.d.mts +4 -4
  10. package/dist/cli.mjs +20 -21
  11. package/dist/cloud-google.mjs +92 -0
  12. package/dist/command-meta.mjs +13 -1
  13. package/dist/command-registry.mjs +5 -2
  14. package/dist/commands/analyze.mjs +34 -177
  15. package/dist/commands/auth.mjs +161 -7
  16. package/dist/commands/bing.mjs +337 -0
  17. package/dist/commands/config.mjs +36 -43
  18. package/dist/commands/doctor.mjs +73 -24
  19. package/dist/commands/dump.mjs +1 -1
  20. package/dist/commands/entities.mjs +4 -4
  21. package/dist/commands/indexing.mjs +11 -14
  22. package/dist/commands/init.mjs +1 -1
  23. package/dist/commands/inspect.mjs +3 -3
  24. package/dist/commands/mcp.mjs +16 -2
  25. package/dist/commands/papercut.mjs +76 -0
  26. package/dist/commands/profile-selection.mjs +2 -2
  27. package/dist/commands/profile.mjs +8 -3
  28. package/dist/commands/query.mjs +86 -40
  29. package/dist/commands/report.mjs +5 -3
  30. package/dist/commands/sitemaps.mjs +24 -18
  31. package/dist/commands/skill.mjs +52 -0
  32. package/dist/commands/stats.mjs +58 -32
  33. package/dist/commands/sync.mjs +58 -25
  34. package/dist/config.mjs +39 -4
  35. package/dist/context.mjs +13 -8
  36. package/dist/env-file.mjs +1 -1
  37. package/dist/local-store.mjs +2 -2
  38. package/dist/mcp/errors.mjs +8 -0
  39. package/dist/mcp/handlers/diagnostics.mjs +31 -0
  40. package/dist/mcp/handlers/reports.mjs +38 -11
  41. package/dist/mcp/server/index.mjs +9 -10
  42. package/dist/mcp/types.mjs +8 -3
  43. package/dist/package.mjs +1 -1
  44. package/dist/papercut.mjs +99 -0
  45. package/dist/render/analysis.mjs +98 -0
  46. package/dist/render/charts.mjs +170 -0
  47. package/dist/render/layout.mjs +87 -0
  48. package/dist/render/metrics.mjs +163 -0
  49. package/dist/render/query.mjs +30 -0
  50. package/dist/render/report.mjs +69 -0
  51. package/dist/render/terminal.mjs +25 -0
  52. package/dist/runtime.d.mts +5 -5
  53. package/dist/runtime.mjs +1 -1
  54. package/dist/skill.mjs +45 -0
  55. package/dist/utils.mjs +10 -38
  56. package/package.json +14 -12
  57. 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 path from "node:path";
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 { isCancel, text } from "@clack/prompts";
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 { ofetch } from "ofetch";
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 (isCancel(clientIdResult)) process.exit(1);
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 (isCancel(clientSecretResult)) process.exit(1);
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 server;
207
- const settle = (fn) => {
208
- if (timeoutId) clearTimeout(timeoutId);
209
- server.closeAllConnections?.();
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
- fn();
149
+ server.closeAllConnections();
150
+ if (result.ok) resolve(result.value);
151
+ else reject(result.error);
212
152
  };
213
- server = createServer((req, res) => {
214
- const url = new URL(req.url || "", `http://127.0.0.1`);
215
- const code = url.searchParams.get("code");
216
- const error = url.searchParams.get("error");
217
- if (error) {
218
- res.writeHead(400, { "Content-Type": "text/html" });
219
- res.end(`<html><body><h1>Authorization Failed</h1><p>${error}</p><p>You can close this window.</p></body></html>`);
220
- settle(() => reject(/* @__PURE__ */ new Error(`OAuth error: ${error}`)));
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
- if (code) {
224
- res.writeHead(200, { "Content-Type": "text/html" });
225
- res.end(`<html><body><h1>Authorization Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`);
226
- settle(() => resolve({
227
- code,
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, { "Content-Type": "text/html" });
233
- res.end(`<html><body><h1>Missing authorization code</h1></body></html>`);
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(() => reject(/* @__PURE__ */ new Error("Failed to start local server")));
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
- const fullAuthUrl = authUrl.replace(REDIRECT_URI_RE, `redirect_uri=${encodeURIComponent(resolvedRedirectUri)}`);
222
+ authUrl.searchParams.set("redirect_uri", resolvedRedirectUri);
223
+ const fullAuthUrl = authUrl.toString();
243
224
  console.log();
244
- console.log(" \x1B[1mOpening browser for authorization...\x1B[0m");
245
- console.log(` \x1B[90mIf browser doesn't open, visit:\x1B[0m`);
246
- console.log(` \x1B[36m${fullAuthUrl}\x1B[0m`);
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
- console.log(` \x1B[90mIf Google says "redirect_uri_mismatch", your OAuth client is`);
249
- console.log(` not a "Desktop application" type. Create a Desktop client at`);
250
- console.log(` https://console.cloud.google.com/apis/credentials, then run`);
251
- console.log(` \`gscdump init --force\` with the new ID/secret.\x1B[0m`);
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
- import("open").then(({ default: open }) => open(fullAuthUrl)).catch(() => {
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
- if (opts.noBrowser) {
316
- const tokens = await authenticateDeviceCode(credentials);
317
- oauth2Client.setCredentials(tokens);
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(code);
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, authenticateDeviceCode, authenticateDeviceCodeResult, clearTokens, describeAuthProvenance, formatAuthProvenance, getAuth, getAuthCredentials, isAuthError, loadServiceAccount, loadServiceAccountResult, loadTokens, resolveAuth, resolveBYOK, resolveServiceAccount, saveTokens };
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 };