@gscdump/cli 3.5.0 → 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/commands/sync.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
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 (
|
|
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 ((
|
|
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
|
-
|
|
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(
|
|
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
|
|
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().
|
|
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
|
|
37
|
-
if (
|
|
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
package/dist/local-store.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createNodeHarness } from "@gscdump/engine/node";
|
|
2
|
-
import { TABLE_DIMS,
|
|
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
|
|
7
|
+
export { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore, inferTable };
|
package/dist/mcp/errors.mjs
CHANGED
|
@@ -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 {
|
|
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,
|
|
56
|
+
async function runReportHandlerResult(input, getContext) {
|
|
35
57
|
const report = defaultReportRegistry.getReport(input.id);
|
|
36
|
-
if (!report) return err(mcpHandlerErrors.unknownReport(input.id,
|
|
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
|
-
|
|
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,
|
|
70
|
-
return unwrapResult(await runReportHandlerResult(input,
|
|
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"
|
|
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
|
-
|
|
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 () =>
|
|
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
|
|
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
|
|
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)
|
package/dist/mcp/types.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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 };
|