@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.
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
@@ -1,10 +1,11 @@
1
1
  import { syncCommandMeta } from "../command-meta.mjs";
2
- import { OUTPUT_ARGS, applyOutputMode, clearLine, displayPath, formatAge, logger, progressBar, runWithConcurrency } from "../utils.mjs";
3
- import { TABLE_DIMS, allTables, createLocalStore, transformGscRow } from "../local-store.mjs";
2
+ import { OUTPUT_ARGS, applyOutputMode, clearLine, displayPath, formatAge, logger, parseIntegerOption, progressBar, runWithConcurrency } from "../utils.mjs";
3
+ import { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore } from "../local-store.mjs";
4
4
  import { createCommandContext } from "../context.mjs";
5
5
  import process from "node:process";
6
6
  import { defineCommand } from "citty";
7
7
  import { SearchTypes } from "gscdump/query";
8
+ import { createRowAccumulator } from "@gscdump/engine/ingest";
8
9
  import { createEmptyTypesStore } from "@gscdump/engine/entities";
9
10
  import { daysAgoUtc, getDateRange } from "gscdump/dates";
10
11
  import { DEFAULT_ROLLUPS, rebuildRollups } from "@gscdump/engine/rollups";
@@ -96,7 +97,7 @@ async function syncTable(store, siteUrl, table, searchType, dates, client, concu
96
97
  failed
97
98
  };
98
99
  }
99
- async function runOneDate(store, client, siteUrl, table, searchType, dims, date) {
100
+ async function fetchDateRows(client, siteUrl, searchType, dims, date) {
100
101
  const rowLimit = 25e3;
101
102
  const rows = [];
102
103
  let startRow = 0;
@@ -109,19 +110,33 @@ async function runOneDate(store, client, siteUrl, table, searchType, dims, date)
109
110
  rowLimit,
110
111
  startRow
111
112
  })).rows || [];
112
- for (const apiRow of batch) {
113
- const transformed = transformGscRow(table, {
114
- keys: apiRow.keys ?? [],
115
- clicks: apiRow.clicks ?? 0,
116
- impressions: apiRow.impressions ?? 0,
117
- ctr: apiRow.ctr ?? 0,
118
- position: apiRow.position ?? 0
119
- });
120
- if (transformed) rows.push(transformed.row);
121
- }
113
+ for (const apiRow of batch) rows.push({
114
+ keys: apiRow.keys ?? [],
115
+ clicks: apiRow.clicks ?? 0,
116
+ impressions: apiRow.impressions ?? 0,
117
+ ctr: apiRow.ctr ?? 0,
118
+ position: apiRow.position ?? 0
119
+ });
122
120
  if (batch.length === 0) break;
123
121
  startRow += batch.length;
124
122
  }
123
+ return rows;
124
+ }
125
+ async function runOneDate(store, client, siteUrl, table, searchType, dims, date) {
126
+ const apiRows = await fetchDateRows(client, siteUrl, searchType, dims, date);
127
+ let rows = [];
128
+ if (table === "dates") {
129
+ const totals = apiRows.find((row) => row.keys[0] === date);
130
+ if (totals) {
131
+ const deviceRows = await fetchDateRows(client, siteUrl, searchType, ["date", "device"], date);
132
+ const queryImpressions = (await fetchDateRows(client, siteUrl, searchType, TABLE_DIMS.queries, date)).reduce((sum, row) => sum + row.impressions, 0);
133
+ rows.push(assembleDatesRow(date, totals, deviceRows, queryImpressions).row);
134
+ }
135
+ } else {
136
+ const accumulator = createRowAccumulator({ maxRows: apiRows.length });
137
+ accumulator.push(table, apiRows);
138
+ rows = accumulator.drain().get(table)?.get(date) ?? [];
139
+ }
125
140
  const writeCtx = {
126
141
  userId: store.userId,
127
142
  siteId: store.siteIdFor(siteUrl),
@@ -212,6 +227,8 @@ const syncCommand = defineCommand({
212
227
  },
213
228
  async run({ args }) {
214
229
  const { json, quiet } = applyOutputMode(args);
230
+ const days = parseIntegerOption(args.days, "--days");
231
+ const concurrency = parseIntegerOption(args.concurrency, "--concurrency") ?? DEFAULT_CONCURRENCY;
215
232
  if (args.status) {
216
233
  const ctx = await createCommandContext();
217
234
  await printSyncStatus({
@@ -232,12 +249,23 @@ const syncCommand = defineCommand({
232
249
  logger.error(`No valid search types specified. Allowed: ${ALL_SEARCH_TYPES.join(",")}`);
233
250
  process.exit(1);
234
251
  }
235
- const siteId = ctx.store.siteIdFor(siteUrl);
236
- const emptyTypesStore = createEmptyTypesStore({ dataSource: ctx.store.dataSource });
237
- const emptyTypesDoc = await emptyTypesStore.load({
238
- userId: ctx.store.userId,
252
+ const store = ctx.store;
253
+ const siteId = store.siteIdFor(siteUrl);
254
+ const scope = {
255
+ userId: store.userId,
239
256
  siteId
240
- });
257
+ };
258
+ const emptyTypesStore = createEmptyTypesStore({ dataSource: store.dataSource });
259
+ let emptyTypesDoc = await emptyTypesStore.load(scope);
260
+ if (emptyTypesDoc.emptyTypes.length > 0) {
261
+ const entries = await store.engine.listLive(scope);
262
+ const populated = new Set(entries.filter((entry) => entry.rowCount > 0).map((entry) => entry.searchType ?? "web"));
263
+ const toClear = requestedTypes.filter((type) => emptyTypesDoc.emptyTypes.includes(type) && populated.has(type));
264
+ if (toClear.length > 0) {
265
+ if (!args["dry-run"]) emptyTypesDoc = await emptyTypesStore.clear(scope, toClear);
266
+ else emptyTypesDoc.emptyTypes = emptyTypesDoc.emptyTypes.filter((type) => !toClear.includes(type));
267
+ }
268
+ }
241
269
  const forceTypes = Boolean(args["force-types"]);
242
270
  const skippedTypes = [];
243
271
  const types = [];
@@ -257,14 +285,13 @@ const syncCommand = defineCommand({
257
285
  let startDate;
258
286
  if (args.start) startDate = String(args.start);
259
287
  else if (args.full) startDate = daysAgoUtc(450);
260
- else if (args.days) startDate = daysAgoUtc(Number.parseInt(String(args.days), 10) + DEFAULT_PENDING_DAYS - 1);
288
+ else if (days !== void 0) startDate = daysAgoUtc(days + DEFAULT_PENDING_DAYS - 1);
261
289
  else startDate = daysAgoUtc(5);
262
290
  let dates = getDateRange(startDate, endDate);
263
291
  if (dates.length === 0) {
264
292
  logger.error(`No dates to sync (start=${startDate}, end=${endDate})`);
265
293
  process.exit(1);
266
294
  }
267
- const store = ctx.store;
268
295
  if (args["retry-failed"]) {
269
296
  const failedSet = /* @__PURE__ */ new Set();
270
297
  const selectedTables = new Set(tables);
@@ -316,7 +343,6 @@ const syncCommand = defineCommand({
316
343
  logger.info(`Syncing ${siteUrl} (${tables.join(", ")}) [${types.join(", ")}] → ${displayPath(store.dataDir)}`);
317
344
  logger.info(`Range: ${startDate} → ${endDate} (${dates.length} days)`);
318
345
  }
319
- const concurrency = args.concurrency ? Math.max(1, Number.parseInt(String(args.concurrency), 10) || DEFAULT_CONCURRENCY) : DEFAULT_CONCURRENCY;
320
346
  const serialTables = Boolean(args["serial-tables"]);
321
347
  const start = Date.now();
322
348
  const totals = {};
@@ -330,9 +356,9 @@ const syncCommand = defineCommand({
330
356
  });
331
357
  }
332
358
  const progress = createProgressTracker(dates.length * jobs.length, quiet);
333
- if (serialTables) for (const job of jobs) totals[job.label] = await syncTable(store, siteUrl, job.table, job.type, dates, client, concurrency, args.force, progress);
359
+ if (serialTables) for (const job of jobs) totals[job.label] = await syncTable(store, siteUrl, job.table, job.type, dates, client, concurrency, args.force || forceTypes, progress);
334
360
  else {
335
- const results = await Promise.all(jobs.map((job) => syncTable(store, siteUrl, job.table, job.type, dates, client, concurrency, args.force, progress)));
361
+ const results = await Promise.all(jobs.map((job) => syncTable(store, siteUrl, job.table, job.type, dates, client, concurrency, args.force || forceTypes, progress)));
336
362
  jobs.forEach((job, i) => {
337
363
  totals[job.label] = results[i];
338
364
  });
@@ -351,17 +377,24 @@ const syncCommand = defineCommand({
351
377
  const anyFailed = Object.values(totals).some((t) => t.failed > 0);
352
378
  const rowsByType = /* @__PURE__ */ new Map();
353
379
  const failedByType = /* @__PURE__ */ new Map();
380
+ const skippedByType = /* @__PURE__ */ new Map();
354
381
  for (const job of jobs) {
355
382
  const t = totals[job.label];
356
383
  rowsByType.set(job.type, (rowsByType.get(job.type) ?? 0) + t.rows);
357
384
  failedByType.set(job.type, (failedByType.get(job.type) ?? 0) + t.failed);
385
+ skippedByType.set(job.type, (skippedByType.get(job.type) ?? 0) + t.skipped);
358
386
  }
359
- if (!forceTypes && dates.length >= EMPTY_TYPE_PROBE_MIN_DAYS) {
387
+ if (!forceTypes && tables.length > 0 && dates.length >= EMPTY_TYPE_PROBE_MIN_DAYS) {
360
388
  const toMark = [];
361
389
  for (const type of types) {
362
390
  if (EMPTY_TYPE_PROTECTED.includes(type)) continue;
363
391
  if ((failedByType.get(type) ?? 0) > 0) continue;
364
- if ((rowsByType.get(type) ?? 0) === 0) toMark.push(type);
392
+ if ((skippedByType.get(type) ?? 0) > 0 || (rowsByType.get(type) ?? 0) > 0) continue;
393
+ if ((await store.engine.listLive({
394
+ ...scope,
395
+ searchType: type
396
+ })).some((entry) => entry.rowCount > 0)) continue;
397
+ toMark.push(type);
365
398
  }
366
399
  if (toMark.length > 0) {
367
400
  await emptyTypesStore.mark({
package/dist/config.mjs CHANGED
@@ -1,13 +1,40 @@
1
1
  import { useCliRuntime } from "./runtime.mjs";
2
- import os from "node:os";
3
- import path from "node:path";
4
2
  import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import os from "node:os";
6
+ import { SearchTypes } from "gscdump/query";
5
7
  function setConfigDir(dir) {
6
8
  useCliRuntime().configDir = dir;
7
9
  }
8
10
  function getConfigDir() {
9
11
  return useCliRuntime().configDir;
10
12
  }
13
+ const configSchema = z.strictObject({
14
+ clientId: z.string().optional(),
15
+ clientSecret: z.string().optional(),
16
+ defaultSite: z.string().optional(),
17
+ defaultPeriod: z.string().optional(),
18
+ defaultFormat: z.enum(["json", "csv"]).optional(),
19
+ defaultDb: z.string().optional(),
20
+ dataDir: z.string().optional(),
21
+ defaultLimit: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
22
+ defaultSearchType: z.enum(SearchTypes).optional(),
23
+ defaultDataState: z.enum([
24
+ "all",
25
+ "final",
26
+ "hourly_all"
27
+ ]).optional(),
28
+ serviceAccountPath: z.string().optional()
29
+ });
30
+ function parseConfig(value) {
31
+ const parsed = configSchema.safeParse(value);
32
+ if (!parsed.success) {
33
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`);
34
+ throw new Error(`Invalid config at ${getConfigPath()}. ${issues.join("; ")}`);
35
+ }
36
+ return parsed.data;
37
+ }
11
38
  function defaultDataDir() {
12
39
  return path.join(os.homedir(), ".gscdump", "data");
13
40
  }
@@ -20,7 +47,14 @@ function expandTilde(p) {
20
47
  return p;
21
48
  }
22
49
  async function loadConfig() {
23
- return fs.readFile(path.join(getConfigDir(), "config.json"), "utf-8").then((data) => JSON.parse(data)).catch(() => ({}));
50
+ const data = await fs.readFile(getConfigPath(), "utf-8").catch((error) => {
51
+ if (error.code === "ENOENT") return void 0;
52
+ throw error;
53
+ });
54
+ if (data === void 0) return {};
55
+ return parseConfig(await Promise.resolve().then(() => JSON.parse(data)).catch((cause) => {
56
+ throw new Error(`Invalid JSON at ${getConfigPath()}. Fix this file before running the command.`, { cause });
57
+ }));
24
58
  }
25
59
  async function loadResolvedConfig() {
26
60
  const config = await loadConfig();
@@ -30,12 +64,13 @@ async function loadResolvedConfig() {
30
64
  };
31
65
  }
32
66
  async function saveConfig(config) {
67
+ const parsed = parseConfig(config);
33
68
  const configDir = getConfigDir();
34
69
  await fs.mkdir(configDir, {
35
70
  recursive: true,
36
71
  mode: 448
37
72
  });
38
- await fs.writeFile(path.join(configDir, "config.json"), JSON.stringify(config, null, 2), { mode: 384 });
73
+ await fs.writeFile(path.join(configDir, "config.json"), JSON.stringify(parsed, null, 2), { mode: 384 });
39
74
  }
40
75
  function getConfigPath() {
41
76
  return path.join(getConfigDir(), "config.json");
package/dist/context.mjs CHANGED
@@ -1,6 +1,8 @@
1
+ import { resolveAuthentication } from "./auth-state.mjs";
1
2
  import { loadResolvedConfig } from "./config.mjs";
2
3
  import { logger } from "./utils.mjs";
3
4
  import { resolveAuth } from "./auth.mjs";
5
+ import { createCloudGoogleClient } from "./cloud-google.mjs";
4
6
  import { createLocalStore } from "./local-store.mjs";
5
7
  import process from "node:process";
6
8
  import { cancel, isCancel, select } from "@clack/prompts";
@@ -8,19 +10,17 @@ import { googleSearchConsole } from "gscdump/client";
8
10
  async function createCommandContext(opts = {}) {
9
11
  const { needsAuth = false, needsStore = false, interactive = false, byok, fetchOptions } = opts;
10
12
  const { config, dataDir } = await loadResolvedConfig();
11
- const auth = needsAuth ? await resolveAuth({
13
+ const authentication = needsAuth ? await resolveAuthentication() : { _tag: "Local" };
14
+ const auth = needsAuth && authentication._tag === "Local" ? await resolveAuth({
12
15
  interactive,
13
16
  config,
14
17
  byok
15
18
  }) : null;
16
- const client = auth ? googleSearchConsole(auth, { fetchOptions }) : null;
19
+ const client = needsAuth && authentication._tag === "Cloud" ? createCloudGoogleClient(authentication, fetchOptions) : auth ? googleSearchConsole(auth, { fetchOptions }) : null;
17
20
  const store = needsStore ? createLocalStore({ dataDir }) : null;
18
21
  const loadSites = async () => {
19
22
  if (!client) throw new Error("loadSites requires needsAuth: true");
20
- return (await client.sites().catch((e) => {
21
- logger.error(`Failed to fetch sites: ${e.message}`);
22
- process.exit(1);
23
- })).filter((s) => s.siteUrl && s.permissionLevel !== "siteUnverifiedUser").map((s) => ({
23
+ return (await client.sites()).filter((s) => s.siteUrl && s.permissionLevel !== "siteUnverifiedUser").map((s) => ({
24
24
  siteUrl: s.siteUrl,
25
25
  permissionLevel: s.permissionLevel || "unknown"
26
26
  }));
@@ -33,8 +33,12 @@ async function createCommandContext(opts = {}) {
33
33
  process.exit(1);
34
34
  }
35
35
  if (hint) {
36
- const match = sites.find((s) => s.siteUrl === hint || s.siteUrl.includes(hint));
37
- if (match) return match.siteUrl;
36
+ const exact = sites.find((site) => site.siteUrl === hint);
37
+ if (exact) return exact.siteUrl;
38
+ const matches = sites.filter((site) => site.siteUrl.includes(hint));
39
+ if (matches.length === 1) return matches[0].siteUrl;
40
+ if (matches.length > 1) throw new Error(`Multiple Sites match "${hint}". Use an exact Site URL from gscdump sites.`);
41
+ throw new Error(`No verified Site matches "${hint}". Run gscdump sites to list available Sites.`);
38
42
  }
39
43
  if (sites.length === 1) return sites[0].siteUrl;
40
44
  const selected = await select({
@@ -53,6 +57,7 @@ async function createCommandContext(opts = {}) {
53
57
  return {
54
58
  config,
55
59
  dataDir,
60
+ authentication,
56
61
  auth,
57
62
  client,
58
63
  store,
package/dist/env-file.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useCliRuntime } from "./runtime.mjs";
2
2
  import process from "node:process";
3
- import fs from "node:fs";
4
3
  import path from "node:path";
4
+ import fs from "node:fs";
5
5
  const ENV_LINE_RE = /^([^=]+)=(.*)$/;
6
6
  function parseEnvFile(envPath) {
7
7
  let content;
@@ -1,7 +1,7 @@
1
1
  import { createNodeHarness } from "@gscdump/engine/node";
2
- import { TABLE_DIMS, transformGscRow } from "@gscdump/engine/ingest";
2
+ import { TABLE_DIMS, assembleDatesRow } from "@gscdump/engine/ingest";
3
3
  import { allTables, inferTable } from "@gscdump/engine/schema";
4
4
  function createLocalStore(opts) {
5
5
  return createNodeHarness(opts);
6
6
  }
7
- export { TABLE_DIMS, allTables, createLocalStore, inferTable, transformGscRow };
7
+ export { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore, inferTable };
@@ -26,6 +26,14 @@ const mcpHandlerErrors = {
26
26
  message: `Unknown report id "${id}". Available: ${available.join(", ")}`
27
27
  };
28
28
  },
29
+ unsupportedReport(id, available) {
30
+ return {
31
+ kind: "unsupported-report",
32
+ id,
33
+ available,
34
+ message: `Report "${id}" cannot run through the live MCP Source. Use the CLI with the local Store.`
35
+ };
36
+ },
29
37
  unknownPeriod(value) {
30
38
  return {
31
39
  kind: "unknown-period",
@@ -1,9 +1,40 @@
1
+ import { getCloudAccount } from "../../auth-state.mjs";
1
2
  import { missingRequiredScopes } from "../../auth-scopes.mjs";
2
3
  import { ofetch } from "ofetch";
3
4
  const FETCH_TIMEOUT_MS = 5e3;
4
5
  const TIME_SKEW_WARN_MS = 3e5;
5
6
  async function diagnostics(_input, ctx) {
6
7
  const checks = [];
8
+ if (ctx.authentication?._tag === "Cloud") {
9
+ const [account, sites] = await Promise.all([getCloudAccount(ctx.authentication).catch((error) => error), ctx.client.sites().catch((error) => error)]);
10
+ checks.push(account instanceof Error ? {
11
+ name: "auth",
12
+ status: "fail",
13
+ detail: account.message
14
+ } : {
15
+ name: "auth",
16
+ status: "pass",
17
+ detail: `Cloud account: ${account.user.email}`
18
+ });
19
+ checks.push(sites instanceof Error ? {
20
+ name: "gsc.sites",
21
+ status: "fail",
22
+ detail: sites.message
23
+ } : {
24
+ name: "gsc.sites",
25
+ status: "pass",
26
+ detail: `${sites.length} Site(s) accessible through cloud authentication`
27
+ });
28
+ checks.push({
29
+ name: "auth.capabilities",
30
+ status: "info",
31
+ detail: "Google indexing and Site Verification require --mode local."
32
+ });
33
+ return {
34
+ ok: checks.every((check) => check.status !== "fail"),
35
+ checks
36
+ };
37
+ }
7
38
  const token = await resolveAccessToken(ctx.auth);
8
39
  if (!token) {
9
40
  checks.push({
@@ -1,9 +1,10 @@
1
1
  import { enrichToolError, mcpHandlerErrorToException, mcpHandlerErrors } from "../errors.mjs";
2
+ import { runReportInput } from "../types.mjs";
2
3
  import { defaultAnalyzerRegistry } from "@gscdump/analysis/registry";
3
- import { createGscApiQuerySource } from "@gscdump/engine-gsc-api";
4
+ import { resolveWindow } from "@gscdump/engine/period";
5
+ import { GSC_API_CAPABILITIES, createGscApiQuerySource } from "@gscdump/engine-gsc-api";
4
6
  import { err, ok, unwrapResult } from "gscdump/result";
5
7
  import { defaultReportRegistry, runReport } from "@gscdump/analysis/report";
6
- import { resolveWindow } from "@gscdump/engine/period";
7
8
  const PERIOD_ALIASES = {
8
9
  "7d": "last-7d",
9
10
  "28d": "last-28d",
@@ -22,18 +23,40 @@ const COMPARISON_ALIASES = {
22
23
  "prior": "prev-period",
23
24
  "yoy": "yoy"
24
25
  };
26
+ function mcpArgName(name) {
27
+ return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
28
+ }
29
+ function supportsLiveSteps(steps) {
30
+ const capabilities = GSC_API_CAPABILITIES;
31
+ const supported = steps.map((step) => {
32
+ const analyzer = defaultAnalyzerRegistry.resolveAnalyzer(step.type, false);
33
+ return !!analyzer && analyzer.requires.every((capability) => capability !== "executeSql" && capabilities[capability] === true);
34
+ });
35
+ return supported.some(Boolean) && steps.every((step, index) => !step.required || supported[index]);
36
+ }
37
+ function supportsLiveReport(report) {
38
+ const args = Object.entries(report.argsSpec);
39
+ if (args.some(([name]) => !Object.hasOwn(runReportInput.shape, mcpArgName(name)))) return false;
40
+ const params = Object.fromEntries(args.map(([name, arg]) => [mcpArgName(name), arg.default ?? (arg.type === "number" ? 1 : arg.type === "boolean" ? false : "mcp-discovery")]));
41
+ const window = resolveWindow({
42
+ preset: report.defaultPeriod,
43
+ comparison: report.defaultComparison
44
+ });
45
+ return supportsLiveSteps(report.plan(params, window));
46
+ }
25
47
  function listReports() {
26
- return defaultReportRegistry.listReports().map((r) => ({
48
+ return defaultReportRegistry.listReports().filter(supportsLiveReport).map((r) => ({
27
49
  id: r.id,
28
50
  description: r.description,
29
- defaultPeriod: r.defaultPeriod,
51
+ defaultPeriod: r.defaultPeriod.replace(/^last-/, ""),
30
52
  defaultComparison: r.defaultComparison,
31
- argsSpec: r.argsSpec
53
+ argsSpec: Object.fromEntries(Object.entries(r.argsSpec).map(([name, arg]) => [mcpArgName(name), arg]))
32
54
  }));
33
55
  }
34
- async function runReportHandlerResult(input, ctx) {
56
+ async function runReportHandlerResult(input, getContext) {
35
57
  const report = defaultReportRegistry.getReport(input.id);
36
- if (!report) return err(mcpHandlerErrors.unknownReport(input.id, defaultReportRegistry.listReportIds()));
58
+ if (!report) return err(mcpHandlerErrors.unknownReport(input.id, listReports().map((report) => report.id)));
59
+ if (!supportsLiveReport(report)) return err(mcpHandlerErrors.unsupportedReport(input.id, listReports().map((report) => report.id)));
37
60
  const preset = input.period ? PERIOD_ALIASES[input.period.toLowerCase()] ?? null : report.defaultPeriod;
38
61
  if (!preset) return err(mcpHandlerErrors.unknownPeriod(input.period ?? ""));
39
62
  const comparison = input.comparison ? COMPARISON_ALIASES[input.comparison.toLowerCase()] ?? null : report.defaultComparison;
@@ -48,8 +71,12 @@ async function runReportHandlerResult(input, ctx) {
48
71
  start: input.prevStart,
49
72
  end: input.prevEnd
50
73
  };
51
- const params = {};
52
- if (input.maxFindings != null) params.maxFindings = input.maxFindings;
74
+ const params = Object.fromEntries(Object.keys(report.argsSpec).map((name) => {
75
+ const key = mcpArgName(name);
76
+ return [key, input[key]];
77
+ }).filter(([, value]) => value !== void 0));
78
+ if (!supportsLiveSteps(report.plan(params, window))) return err(mcpHandlerErrors.unsupportedReport(input.id, listReports().map((report) => report.id)));
79
+ const ctx = await getContext();
53
80
  const source = createGscApiQuerySource({
54
81
  client: ctx.client,
55
82
  siteUrl: input.siteUrl
@@ -66,8 +93,8 @@ async function runReportHandlerResult(input, ctx) {
66
93
  ctx: reportCtx
67
94
  }));
68
95
  }
69
- async function runReportHandler(input, ctx) {
70
- return unwrapResult(await runReportHandlerResult(input, ctx).catch((thrown) => {
96
+ async function runReportHandler(input, getContext) {
97
+ return unwrapResult(await runReportHandlerResult(input, getContext).catch((thrown) => {
71
98
  throw enrichToolError(thrown) ?? thrown;
72
99
  }), mcpHandlerErrorToException);
73
100
  }
@@ -1,14 +1,15 @@
1
1
  import { discoverLiveSitemap } from "../../sitemap.mjs";
2
2
  import { diagnostics } from "../handlers/diagnostics.mjs";
3
3
  import { batchInspectUrls as batchInspectUrls$1, batchRequestIndexing as batchRequestIndexing$1, getIndexingStatus, requestIndexing as requestIndexing$1 } from "../handlers/indexing.mjs";
4
- import { getSitemap, listSitesWithSitemaps } from "../handlers/sites.mjs";
5
4
  import { batchInspectUrlsInput, batchRequestIndexingInput, getIndexingStatusInput, inspectUrlInput, listReportsInput, listSitemapsInput, listSitesInput, requestIndexingInput, runReportInput, sitemapInput } from "../types.mjs";
5
+ import { listReports, runReportHandler } from "../handlers/reports.mjs";
6
+ import { getSitemap, listSitesWithSitemaps } from "../handlers/sites.mjs";
7
+ import { z } from "zod";
6
8
  import { SearchTypes } from "gscdump/query";
7
9
  import { googleSearchConsole } from "gscdump/client";
8
10
  import { getIndexingMetadata, runSequentialBatch } from "gscdump/indexing";
9
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
12
  import { addSite, deleteSite, getVerificationToken, getVerifiedSite, listVerifiedSites, unverifySite, verifySite } from "gscdump/sites";
11
- import { z } from "zod";
12
13
  async function runMcpSearchAnalyticsQuery(client, args) {
13
14
  const totalLimit = Math.max(0, args.rowLimit ?? 25e3);
14
15
  const pageSize = Math.min(totalLimit, 25e3);
@@ -54,20 +55,20 @@ async function runMcpSearchAnalyticsQuery(client, args) {
54
55
  };
55
56
  }
56
57
  function createGscMcpServer(options) {
57
- const { name = "gscdump", version = "1.0.0", getAuth } = options;
58
+ const { name = "gscdump", version = "1.0.0" } = options;
58
59
  const server = new McpServer({
59
60
  name,
60
61
  version
61
62
  });
62
- const auth = async () => Promise.resolve(getAuth());
63
63
  const getContext = async () => {
64
- const a = await auth();
64
+ if (options.getContext) return options.getContext();
65
+ const a = await options.getAuth();
65
66
  return {
66
67
  auth: a,
67
68
  client: googleSearchConsole(a)
68
69
  };
69
70
  };
70
- const getClient = async () => googleSearchConsole(await auth());
71
+ const getClient = async () => (await getContext()).client;
71
72
  server.registerTool("list-sites", {
72
73
  description: "List all Google Search Console sites visible to the authenticated user.",
73
74
  inputSchema: listSitesInput.shape
@@ -132,10 +133,9 @@ function createGscMcpServer(options) {
132
133
  }] };
133
134
  });
134
135
  server.registerTool("list-reports", {
135
- description: "List available reports (intent-keyed analyzer compositions). Returns id, description, default period/comparison, and per-report argsSpec.",
136
+ description: "List Reports supported by the live Google API. Returns defaults and argsSpec using run-report input names.",
136
137
  inputSchema: listReportsInput.shape
137
138
  }, async () => {
138
- const { listReports } = await import("../handlers/reports.mjs");
139
139
  const result = listReports();
140
140
  return { content: [{
141
141
  type: "text",
@@ -146,8 +146,7 @@ function createGscMcpServer(options) {
146
146
  description: "Run a report against the GSC API. Returns a structured ReportResult with bounded findings per section. See list-reports for ids.",
147
147
  inputSchema: runReportInput.shape
148
148
  }, async (args) => {
149
- const { runReportHandler } = await import("../handlers/reports.mjs");
150
- const result = await runReportHandler(args, await getContext());
149
+ const result = await runReportHandler(args, getContext);
151
150
  return { content: [{
152
151
  type: "text",
153
152
  text: JSON.stringify(result, null, 2)
@@ -1,5 +1,5 @@
1
- import { SearchTypes } from "gscdump/query";
2
1
  import { z } from "zod";
2
+ import { SearchTypes } from "gscdump/query";
3
3
  const periodSchema = z.object({
4
4
  start: z.string().describe("Start date (YYYY-MM-DD)"),
5
5
  end: z.string().describe("End date (YYYY-MM-DD)")
@@ -68,13 +68,18 @@ const batchInspectUrlsInput = z.object({
68
68
  const listReportsInput = z.object({});
69
69
  const runReportInput = z.object({
70
70
  siteUrl: siteUrlSchema,
71
- id: z.string().describe("Report id (e.g. health, movers, opportunities, risks). See list-reports."),
71
+ id: z.string().describe("Report ID supported by the live Google API. See list-reports."),
72
72
  period: z.string().optional().describe("Window: 7d|28d|30d|90d|180d|365d|mtd|ytd|custom (default per report)."),
73
73
  comparison: z.string().optional().describe("Comparison: none|prev-period|yoy (default per report)."),
74
74
  start: z.string().optional().describe("Custom window start (YYYY-MM-DD); requires period=custom."),
75
75
  end: z.string().optional().describe("Custom window end (YYYY-MM-DD); requires period=custom."),
76
76
  prevStart: z.string().optional().describe("Override comparison-window start."),
77
77
  prevEnd: z.string().optional().describe("Override comparison-window end."),
78
- maxFindings: z.number().optional().describe("Cap findings per section (per-report default ~5).")
78
+ maxFindings: z.number().optional().describe("Cap findings per section (per-report default ~5)."),
79
+ minClicksChange: z.number().optional().describe("Minimum absolute click change for movers findings."),
80
+ target: z.string().optional().describe("Page URL or query for triage. This Report requires the local Store."),
81
+ targetKind: z.enum(["page", "query"]).optional().describe("Target kind for triage. Defaults to page."),
82
+ topic: z.string().optional().describe("Required for pre-publish: topic or URL slug to check."),
83
+ brandTerms: z.string().optional().describe("Required for brand: comma-separated brand terms.")
79
84
  });
80
85
  export { batchInspectUrlsInput, batchRequestIndexingInput, getIndexingStatusInput, inspectUrlInput, listReportsInput, listSitemapsInput, listSitesInput, periodSchema, queryOptionsSchema, requestIndexingInput, runReportInput, siteUrlSchema, sitemapInput };
package/dist/package.mjs CHANGED
@@ -1,2 +1,2 @@
1
- var version = "3.4.4";
1
+ var version = "3.6.0";
2
2
  export { version };
@@ -0,0 +1,99 @@
1
+ import { VERSION } from "./utils.mjs";
2
+ import process from "node:process";
3
+ import { z } from "zod";
4
+ const DEFAULT_PAPERCUT_BASE_URL = "https://gscdump.com";
5
+ const PAPERCUT_PATH = "/api/cli/papercuts";
6
+ const REQUEST_TIMEOUT_MS = 1e4;
7
+ const singleLine = (max) => z.string().trim().min(1).max(max).regex(/^\P{Cc}*$/u, "must be a single line without control characters");
8
+ const papercutBodySchema = z.strictObject({
9
+ command: singleLine(200),
10
+ comment: z.string().trim().min(1).max(2e3),
11
+ agent: singleLine(100),
12
+ intent: z.enum(["bug", "improvement"]).default("bug"),
13
+ cliVersion: singleLine(64),
14
+ node: singleLine(64),
15
+ platform: singleLine(32)
16
+ });
17
+ const receiptSchema = z.object({
18
+ id: z.string().min(1),
19
+ status: z.literal("new")
20
+ });
21
+ function buildPapercutBody(input) {
22
+ const parsed = papercutBodySchema.safeParse({
23
+ command: input.command,
24
+ comment: input.comment,
25
+ agent: input.agent,
26
+ intent: input.intent ?? "bug",
27
+ cliVersion: VERSION,
28
+ node: process.versions.node,
29
+ platform: process.platform
30
+ });
31
+ if (!parsed.success) {
32
+ const issue = parsed.error.issues[0];
33
+ return {
34
+ _tag: "Err",
35
+ message: `${issue?.path.join(".") || "input"}: ${issue?.message ?? "invalid"}`
36
+ };
37
+ }
38
+ return {
39
+ _tag: "Ok",
40
+ body: parsed.data
41
+ };
42
+ }
43
+ function resolvePapercutUrl(environment) {
44
+ return `${(environment.GSCDUMP_API_URL || "https://gscdump.com").replace(/\/+$/, "")}${PAPERCUT_PATH}`;
45
+ }
46
+ async function submitPapercut(body, deps) {
47
+ const response = await deps.fetch(deps.url, {
48
+ method: "POST",
49
+ headers: {
50
+ "content-type": "application/json",
51
+ "accept": "application/json",
52
+ "x-cli-version": body.cliVersion,
53
+ "user-agent": `gscdump-cli/${body.cliVersion}`
54
+ },
55
+ body: JSON.stringify(body),
56
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
57
+ }).catch((cause) => ({
58
+ _tag: "FetchFailed",
59
+ cause
60
+ }));
61
+ if ("_tag" in response) {
62
+ const detail = response.cause instanceof Error ? response.cause.message : String(response.cause);
63
+ return {
64
+ _tag: "Err",
65
+ reason: "unreachable",
66
+ message: `Could not reach ${deps.url}: ${detail}`
67
+ };
68
+ }
69
+ if (response.status === 429) {
70
+ const retryAfter = response.headers.get("retry-after");
71
+ return {
72
+ _tag: "Err",
73
+ reason: "rate_limited",
74
+ message: `Papercut limit reached. Try again in ${retryAfter ? `${retryAfter}s` : "an hour"}.`
75
+ };
76
+ }
77
+ if (response.status === 400 || response.status === 422) return {
78
+ _tag: "Err",
79
+ reason: "invalid_input",
80
+ message: `gscdump.com rejected the papercut: ${(await response.text().catch(() => "")).slice(0, 300) || response.status}`
81
+ };
82
+ if (!response.ok) return {
83
+ _tag: "Err",
84
+ reason: "rejected",
85
+ message: `gscdump.com answered ${response.status}.`
86
+ };
87
+ const json = await response.json().catch(() => null);
88
+ const receipt = receiptSchema.safeParse(json);
89
+ if (!receipt.success) return {
90
+ _tag: "Err",
91
+ reason: "rejected",
92
+ message: "gscdump.com answered without a papercut id."
93
+ };
94
+ return {
95
+ _tag: "Ok",
96
+ receipt: receipt.data
97
+ };
98
+ }
99
+ export { DEFAULT_PAPERCUT_BASE_URL, PAPERCUT_PATH, buildPapercutBody, papercutBodySchema, resolvePapercutUrl, submitPapercut };