@gscdump/cli 3.8.0 → 4.0.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.
- package/README.md +115 -30
- package/bin/gscdump.mjs +21 -1
- package/dist/analysis-local.mjs +172 -53
- package/dist/auth-state.mjs +21 -6
- package/dist/auth.mjs +27 -18
- package/dist/bing-data.mjs +6 -2
- package/dist/bing-hosted.mjs +1 -1
- package/dist/cli-args.mjs +8 -0
- package/dist/cli.d.mts +5 -1
- package/dist/cli.mjs +41 -10
- package/dist/cloud-google.mjs +8 -1
- package/dist/command-meta.mjs +4 -4
- package/dist/command-registry.mjs +86 -10
- package/dist/commands/analyze.mjs +84 -43
- package/dist/commands/auth.mjs +46 -37
- package/dist/commands/bing.mjs +3 -3
- package/dist/commands/compact.mjs +12 -8
- package/dist/commands/config.mjs +14 -14
- package/dist/commands/doctor.mjs +26 -26
- package/dist/commands/dump.mjs +203 -105
- package/dist/commands/entities.mjs +10 -113
- package/dist/commands/gc.mjs +4 -3
- package/dist/commands/indexing-urls.mjs +185 -0
- package/dist/commands/indexing.mjs +12 -8
- package/dist/commands/init.mjs +77 -18
- package/dist/commands/inspect.mjs +97 -110
- package/dist/commands/mcp.mjs +37 -57
- package/dist/commands/papercut.mjs +1 -1
- package/dist/commands/profile.mjs +9 -17
- package/dist/commands/query.mjs +219 -210
- package/dist/commands/report.mjs +68 -85
- package/dist/commands/rollups.mjs +9 -6
- package/dist/commands/sitemaps.mjs +51 -104
- package/dist/commands/sites.mjs +40 -24
- package/dist/commands/stats.mjs +14 -17
- package/dist/commands/store-purge.mjs +9 -5
- package/dist/commands/store.mjs +0 -12
- package/dist/commands/sync.mjs +774 -296
- package/dist/config.mjs +7 -4
- package/dist/context.mjs +51 -21
- package/dist/coverage.mjs +251 -0
- package/dist/dump-bing.mjs +71 -0
- package/dist/dump-writers.mjs +246 -0
- package/dist/error-handler.mjs +108 -38
- package/dist/filters.mjs +125 -0
- package/dist/hosted-site.mjs +91 -0
- package/dist/inspect-urls.mjs +76 -0
- package/dist/inspection-record.mjs +93 -0
- package/dist/local-entities.mjs +658 -0
- package/dist/mcp/errors.mjs +71 -18
- package/dist/mcp/handlers/diagnostics.mjs +15 -12
- package/dist/mcp/handlers/reports.mjs +33 -42
- package/dist/mcp/server/index.mjs +80 -38
- package/dist/mcp/types.mjs +7 -7
- package/dist/package.mjs +1 -1
- package/dist/quota-ledger.mjs +370 -0
- package/dist/render/analysis.mjs +8 -1
- package/dist/render/report.mjs +8 -1
- package/dist/request-pacer.mjs +29 -0
- package/dist/route.mjs +301 -0
- package/dist/sitemap.mjs +4 -0
- package/dist/sql-views.mjs +117 -0
- package/dist/store-sites.mjs +134 -0
- package/dist/sync-plan.mjs +129 -0
- package/dist/sync-run.mjs +96 -0
- package/dist/table-sources.mjs +57 -0
- package/dist/token-info.mjs +54 -0
- package/dist/utils.mjs +28 -19
- package/dist/window.mjs +159 -0
- package/package.json +17 -14
- package/skills/gscdump/SKILL.md +172 -37
- package/dist/commands/export.mjs +0 -76
- package/dist/native-duckdb.mjs +0 -46
package/dist/package.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var version = "
|
|
1
|
+
var version = "4.0.1";
|
|
2
2
|
export { version };
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { classifyError } from "gscdump/errors";
|
|
5
|
+
import { getNextPstMidnight, getPstDate } from "gscdump/dates";
|
|
6
|
+
const QUOTA_CAPS = {
|
|
7
|
+
searchAnalytics: {
|
|
8
|
+
perMinute: 1200,
|
|
9
|
+
scope: "site"
|
|
10
|
+
},
|
|
11
|
+
urlInspection: {
|
|
12
|
+
perDay: 2e3,
|
|
13
|
+
perMinute: 600,
|
|
14
|
+
scope: "site"
|
|
15
|
+
},
|
|
16
|
+
indexing: {
|
|
17
|
+
perDay: 200,
|
|
18
|
+
perMinute: 380,
|
|
19
|
+
scope: "project"
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const SHORT_TERM_BLOCK_MS = {
|
|
23
|
+
perMinute: 6e4,
|
|
24
|
+
load: 9e5
|
|
25
|
+
};
|
|
26
|
+
function emptyLedgerState() {
|
|
27
|
+
return {
|
|
28
|
+
version: 1,
|
|
29
|
+
usage: []
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function siteKey(api, site) {
|
|
33
|
+
return QUOTA_CAPS[api].scope === "project" ? "*" : site;
|
|
34
|
+
}
|
|
35
|
+
function findUsage(state, api, site, day) {
|
|
36
|
+
return state.usage.find((entry) => entry.api === api && entry.site === site && entry.day === day);
|
|
37
|
+
}
|
|
38
|
+
function withUsage(state, next) {
|
|
39
|
+
return {
|
|
40
|
+
version: 1,
|
|
41
|
+
usage: [...state.usage.filter((entry) => !(entry.api === next.api && entry.site === next.site && entry.day === next.day)), next]
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function activeBlock(state, api, site, now) {
|
|
45
|
+
let block;
|
|
46
|
+
for (const entry of state.usage) {
|
|
47
|
+
if (entry.api !== api || entry.site !== site || entry.blockedUntil === void 0 || entry.blockedUntil <= now) continue;
|
|
48
|
+
if (!block || entry.blockedUntil > block.until) block = {
|
|
49
|
+
until: entry.blockedUntil,
|
|
50
|
+
reason: entry.reason ?? "quota"
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return block;
|
|
54
|
+
}
|
|
55
|
+
function reserveQuota(state, input) {
|
|
56
|
+
const site = siteKey(input.api, input.site);
|
|
57
|
+
const now = input.now.getTime();
|
|
58
|
+
const block = activeBlock(state, input.api, site, now);
|
|
59
|
+
if (block) return {
|
|
60
|
+
decision: {
|
|
61
|
+
kind: "exhausted",
|
|
62
|
+
resetsAt: block.until,
|
|
63
|
+
reason: block.reason
|
|
64
|
+
},
|
|
65
|
+
state
|
|
66
|
+
};
|
|
67
|
+
const day = getPstDate(input.now);
|
|
68
|
+
const usage = findUsage(state, input.api, site, day) ?? {
|
|
69
|
+
api: input.api,
|
|
70
|
+
site,
|
|
71
|
+
day,
|
|
72
|
+
used: 0
|
|
73
|
+
};
|
|
74
|
+
const cap = QUOTA_CAPS[input.api].perDay;
|
|
75
|
+
const left = cap === void 0 ? input.n : Math.max(0, cap - usage.used);
|
|
76
|
+
if (left <= 0) return {
|
|
77
|
+
decision: {
|
|
78
|
+
kind: "exhausted",
|
|
79
|
+
resetsAt: getNextPstMidnight(input.now),
|
|
80
|
+
reason: `${cap} calls a day`
|
|
81
|
+
},
|
|
82
|
+
state
|
|
83
|
+
};
|
|
84
|
+
const n = Math.min(input.n, left);
|
|
85
|
+
return {
|
|
86
|
+
decision: n < input.n ? {
|
|
87
|
+
kind: "partial",
|
|
88
|
+
n,
|
|
89
|
+
resetsAt: getNextPstMidnight(input.now)
|
|
90
|
+
} : {
|
|
91
|
+
kind: "granted",
|
|
92
|
+
n
|
|
93
|
+
},
|
|
94
|
+
state: withUsage(state, {
|
|
95
|
+
...usage,
|
|
96
|
+
used: usage.used + n
|
|
97
|
+
})
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function blockUntil(api, reason, now) {
|
|
101
|
+
if (api === "searchAnalytics") {
|
|
102
|
+
const perMinute = /qps|per minute|rate limit|ratelimit/i.test(reason);
|
|
103
|
+
return now.getTime() + SHORT_TERM_BLOCK_MS[perMinute ? "perMinute" : "load"];
|
|
104
|
+
}
|
|
105
|
+
if (/per minute|qps/i.test(reason)) return now.getTime() + SHORT_TERM_BLOCK_MS.perMinute;
|
|
106
|
+
return getNextPstMidnight(now);
|
|
107
|
+
}
|
|
108
|
+
function recordQuotaOutcome(state, input) {
|
|
109
|
+
const site = siteKey(input.api, input.site);
|
|
110
|
+
const day = getPstDate(input.now);
|
|
111
|
+
const usage = findUsage(state, input.api, site, day) ?? {
|
|
112
|
+
api: input.api,
|
|
113
|
+
site,
|
|
114
|
+
day,
|
|
115
|
+
used: 0
|
|
116
|
+
};
|
|
117
|
+
if (input.outcome.kind === "unused") return withUsage(state, {
|
|
118
|
+
...usage,
|
|
119
|
+
used: Math.max(0, usage.used - input.outcome.n)
|
|
120
|
+
});
|
|
121
|
+
return withUsage(state, {
|
|
122
|
+
...usage,
|
|
123
|
+
blockedUntil: blockUntil(input.api, input.outcome.reason, input.now),
|
|
124
|
+
reason: input.outcome.reason
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function quotaStatus(state, input) {
|
|
128
|
+
const site = siteKey(input.api, input.site);
|
|
129
|
+
const usage = findUsage(state, input.api, site, getPstDate(input.now));
|
|
130
|
+
const block = activeBlock(state, input.api, site, input.now.getTime());
|
|
131
|
+
return {
|
|
132
|
+
api: input.api,
|
|
133
|
+
site,
|
|
134
|
+
used: usage?.used ?? 0,
|
|
135
|
+
...QUOTA_CAPS[input.api].perDay !== void 0 ? { perDay: QUOTA_CAPS[input.api].perDay } : {},
|
|
136
|
+
...block ? { blocked: block } : {}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function addUsage(state, input) {
|
|
140
|
+
const site = siteKey(input.api, input.site);
|
|
141
|
+
const day = getPstDate(input.now);
|
|
142
|
+
const usage = findUsage(state, input.api, site, day) ?? {
|
|
143
|
+
api: input.api,
|
|
144
|
+
site,
|
|
145
|
+
day,
|
|
146
|
+
used: 0
|
|
147
|
+
};
|
|
148
|
+
return withUsage(state, {
|
|
149
|
+
...usage,
|
|
150
|
+
used: usage.used + input.n
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function pruneLedger(state, now) {
|
|
154
|
+
const today = getPstDate(now);
|
|
155
|
+
const keepFrom = (/* @__PURE__ */ new Date(Date.parse(`${today}T00:00:00Z`) - 864e5)).toISOString().slice(0, 10);
|
|
156
|
+
const at = now.getTime();
|
|
157
|
+
return {
|
|
158
|
+
version: 1,
|
|
159
|
+
usage: state.usage.filter((entry) => entry.day >= keepFrom || (entry.blockedUntil ?? 0) > at)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function mergeLedger(base, delta) {
|
|
163
|
+
let merged = base;
|
|
164
|
+
for (const entry of delta.usage) {
|
|
165
|
+
const prior = findUsage(merged, entry.api, entry.site, entry.day);
|
|
166
|
+
const blockedUntil = Math.max(prior?.blockedUntil ?? 0, entry.blockedUntil ?? 0);
|
|
167
|
+
const reason = (entry.blockedUntil ?? 0) >= (prior?.blockedUntil ?? 0) ? entry.reason : prior?.reason;
|
|
168
|
+
merged = withUsage(merged, {
|
|
169
|
+
api: entry.api,
|
|
170
|
+
site: entry.site,
|
|
171
|
+
day: entry.day,
|
|
172
|
+
used: Math.max(0, (prior?.used ?? 0) + entry.used),
|
|
173
|
+
...blockedUntil > 0 ? { blockedUntil } : {},
|
|
174
|
+
...reason ? { reason } : {}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return merged;
|
|
178
|
+
}
|
|
179
|
+
function parseQuotaRefusal(error) {
|
|
180
|
+
if (classifyError(error).kind !== "rate-limited") return void 0;
|
|
181
|
+
return googleErrorMessage(error);
|
|
182
|
+
}
|
|
183
|
+
function googleErrorMessage(error) {
|
|
184
|
+
const message = (error?.data)?.error?.message;
|
|
185
|
+
const status = error?.statusCode ?? error?.status;
|
|
186
|
+
if (typeof message === "string" && message.length > 0) return typeof status === "number" ? `${status} ${message}` : message;
|
|
187
|
+
return error instanceof Error ? error.message : String(error);
|
|
188
|
+
}
|
|
189
|
+
function quotaLedgerPath(dataDir) {
|
|
190
|
+
return path.join(dataDir, "quota-ledger.json");
|
|
191
|
+
}
|
|
192
|
+
function parseLedgerState(value) {
|
|
193
|
+
const usage = value?.usage;
|
|
194
|
+
if (value?.version !== 1 || !Array.isArray(usage)) throw new Error("Quota ledger has an unknown format. Delete quota-ledger.json in the data dir to reset it.");
|
|
195
|
+
return {
|
|
196
|
+
version: 1,
|
|
197
|
+
usage: usage.filter((entry) => typeof entry === "object" && entry !== null && typeof entry.api === "string" && entry.api in QUOTA_CAPS && typeof entry.site === "string" && typeof entry.day === "string" && typeof entry.used === "number")
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
async function readLedgerState(file) {
|
|
201
|
+
const body = await fs.readFile(file, "utf8").catch((error) => {
|
|
202
|
+
if (error.code === "ENOENT") return void 0;
|
|
203
|
+
throw error;
|
|
204
|
+
});
|
|
205
|
+
return body === void 0 ? emptyLedgerState() : parseLedgerState(JSON.parse(body));
|
|
206
|
+
}
|
|
207
|
+
async function writeLedgerState(file, state) {
|
|
208
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
209
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
210
|
+
await fs.writeFile(temporary, JSON.stringify(state), "utf8");
|
|
211
|
+
await fs.rename(temporary, file);
|
|
212
|
+
}
|
|
213
|
+
async function openQuotaLedger(opts) {
|
|
214
|
+
const file = quotaLedgerPath(opts.dataDir);
|
|
215
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
216
|
+
let state = pruneLedger(await readLedgerState(file), now());
|
|
217
|
+
let delta = emptyLedgerState();
|
|
218
|
+
let flushes = Promise.resolve();
|
|
219
|
+
const flush = () => {
|
|
220
|
+
const next = flushes.then(async () => {
|
|
221
|
+
state = pruneLedger(mergeLedger(await readLedgerState(file), delta), now());
|
|
222
|
+
delta = emptyLedgerState();
|
|
223
|
+
await writeLedgerState(file, state);
|
|
224
|
+
});
|
|
225
|
+
flushes = next.catch(() => {});
|
|
226
|
+
return next;
|
|
227
|
+
};
|
|
228
|
+
return {
|
|
229
|
+
reserve(api, site, n) {
|
|
230
|
+
const at = now();
|
|
231
|
+
const result = reserveQuota(state, {
|
|
232
|
+
api,
|
|
233
|
+
site,
|
|
234
|
+
n,
|
|
235
|
+
now: at
|
|
236
|
+
});
|
|
237
|
+
state = result.state;
|
|
238
|
+
if (result.decision.kind !== "exhausted") delta = addUsage(delta, {
|
|
239
|
+
api,
|
|
240
|
+
site,
|
|
241
|
+
n: result.decision.n,
|
|
242
|
+
now: at
|
|
243
|
+
});
|
|
244
|
+
return result.decision;
|
|
245
|
+
},
|
|
246
|
+
record(api, site, outcome) {
|
|
247
|
+
const at = now();
|
|
248
|
+
state = recordQuotaOutcome(state, {
|
|
249
|
+
api,
|
|
250
|
+
site,
|
|
251
|
+
outcome,
|
|
252
|
+
now: at
|
|
253
|
+
});
|
|
254
|
+
delta = outcome.kind === "unused" ? addUsage(delta, {
|
|
255
|
+
api,
|
|
256
|
+
site,
|
|
257
|
+
n: -outcome.n,
|
|
258
|
+
now: at
|
|
259
|
+
}) : recordQuotaOutcome(delta, {
|
|
260
|
+
api,
|
|
261
|
+
site,
|
|
262
|
+
outcome,
|
|
263
|
+
now: at
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
status(api, site) {
|
|
267
|
+
return quotaStatus(state, {
|
|
268
|
+
api,
|
|
269
|
+
site,
|
|
270
|
+
now: now()
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
flush
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const SEARCH_ANALYTICS_PATH_RE = /^\/webmasters\/v3\/sites\/([^/]+)\/searchAnalytics\/query$/;
|
|
277
|
+
function quotaTarget(url, body) {
|
|
278
|
+
const parsed = new URL(url);
|
|
279
|
+
if (parsed.origin === "https://searchconsole.googleapis.com") {
|
|
280
|
+
const match = parsed.pathname.match(SEARCH_ANALYTICS_PATH_RE);
|
|
281
|
+
if (match) return {
|
|
282
|
+
api: "searchAnalytics",
|
|
283
|
+
site: decodeURIComponent(match[1])
|
|
284
|
+
};
|
|
285
|
+
if (parsed.pathname === "/v1/urlInspection/index:inspect") {
|
|
286
|
+
const fields = typeof body === "string" ? JSON.parse(body) : body;
|
|
287
|
+
return typeof fields?.siteUrl === "string" ? {
|
|
288
|
+
api: "urlInspection",
|
|
289
|
+
site: fields.siteUrl
|
|
290
|
+
} : void 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (parsed.origin === "https://indexing.googleapis.com" && parsed.pathname === "/v3/urlNotifications:publish") return {
|
|
294
|
+
api: "indexing",
|
|
295
|
+
site: "*"
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function quotaStopError(stop) {
|
|
299
|
+
return Object.assign(new Error(formatQuotaStop(stop)), {
|
|
300
|
+
name: "QuotaStop",
|
|
301
|
+
quotaStop: stop
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function quotaStopOf(error) {
|
|
305
|
+
return error?.quotaStop;
|
|
306
|
+
}
|
|
307
|
+
const API_LABELS = {
|
|
308
|
+
searchAnalytics: "Search Analytics",
|
|
309
|
+
urlInspection: "URL Inspection",
|
|
310
|
+
indexing: "Indexing API"
|
|
311
|
+
};
|
|
312
|
+
function formatQuotaStop(stop, now = /* @__PURE__ */ new Date()) {
|
|
313
|
+
const scope = stop.site === "*" ? "this Google Cloud project" : stop.site;
|
|
314
|
+
const at = new Date(stop.resetsAt);
|
|
315
|
+
const sameDay = at.toDateString() === now.toDateString();
|
|
316
|
+
const when = at.toLocaleString("en-US", sameDay ? {
|
|
317
|
+
hour: "numeric",
|
|
318
|
+
minute: "2-digit"
|
|
319
|
+
} : {
|
|
320
|
+
month: "short",
|
|
321
|
+
day: "numeric",
|
|
322
|
+
hour: "numeric",
|
|
323
|
+
minute: "2-digit"
|
|
324
|
+
});
|
|
325
|
+
return `The Google ${API_LABELS[stop.api]} quota for ${scope} is used up (${stop.reason}). It resets at ${when}. Run the command again after that.`;
|
|
326
|
+
}
|
|
327
|
+
function requestUrl(request) {
|
|
328
|
+
return typeof request === "string" ? request : request instanceof URL ? request.href : request.url;
|
|
329
|
+
}
|
|
330
|
+
function asArray(hooks) {
|
|
331
|
+
return hooks === void 0 ? [] : Array.isArray(hooks) ? hooks : [hooks];
|
|
332
|
+
}
|
|
333
|
+
function quotaFetchOptions(ledger, base = {}) {
|
|
334
|
+
return {
|
|
335
|
+
...base,
|
|
336
|
+
onRequest: [async (context) => {
|
|
337
|
+
const target = quotaTarget(requestUrl(context.request), context.options.body);
|
|
338
|
+
if (!target) return;
|
|
339
|
+
const decision = ledger.reserve(target.api, target.site, 1);
|
|
340
|
+
if (decision.kind === "exhausted") throw quotaStopError({
|
|
341
|
+
...target,
|
|
342
|
+
reason: decision.reason,
|
|
343
|
+
resetsAt: decision.resetsAt
|
|
344
|
+
});
|
|
345
|
+
await ledger.flush();
|
|
346
|
+
}, ...asArray(base.onRequest)],
|
|
347
|
+
onResponseError: [...asArray(base.onResponseError), async (context) => {
|
|
348
|
+
const target = quotaTarget(requestUrl(context.request), context.options.body);
|
|
349
|
+
if (!target) return;
|
|
350
|
+
const refusal = parseQuotaRefusal({
|
|
351
|
+
statusCode: context.response.status,
|
|
352
|
+
data: context.response._data,
|
|
353
|
+
message: context.error?.message ?? ""
|
|
354
|
+
});
|
|
355
|
+
if (!refusal) return;
|
|
356
|
+
ledger.record(target.api, target.site, {
|
|
357
|
+
kind: "refused",
|
|
358
|
+
reason: refusal
|
|
359
|
+
});
|
|
360
|
+
await ledger.flush();
|
|
361
|
+
const block = ledger.status(target.api, target.site).blocked;
|
|
362
|
+
throw quotaStopError({
|
|
363
|
+
...target,
|
|
364
|
+
reason: refusal,
|
|
365
|
+
resetsAt: block?.until ?? Date.now()
|
|
366
|
+
});
|
|
367
|
+
}]
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
export { QUOTA_CAPS, blockUntil, emptyLedgerState, formatQuotaStop, googleErrorMessage, mergeLedger, openQuotaLedger, parseQuotaRefusal, pruneLedger, quotaFetchOptions, quotaLedgerPath, quotaStatus, quotaStopError, quotaStopOf, quotaTarget, readLedgerState, recordQuotaOutcome, reserveQuota };
|
package/dist/render/analysis.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { renderTable, textLines } from "./layout.mjs";
|
|
2
2
|
import { finite, formatMetric, metricLabel } from "./metrics.mjs";
|
|
3
3
|
import { barColumn, calendarBuckets, renderBars, renderShare, renderSparklines } from "./charts.mjs";
|
|
4
|
+
import { MAX_FETCH_BUDGET } from "@gscdump/engine/analysis-types";
|
|
4
5
|
import { parseGscSiteUrl } from "gscdump";
|
|
5
6
|
function asRecord(value) {
|
|
6
7
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -24,6 +25,10 @@ function columnsFor(rows, keys) {
|
|
|
24
25
|
}
|
|
25
26
|
}));
|
|
26
27
|
}
|
|
28
|
+
function coverageWarning(coverage) {
|
|
29
|
+
if (coverage?.kind !== "truncated") return void 0;
|
|
30
|
+
return `! Partial data: a fetch stopped at ${coverage.fetched.toLocaleString("en-US")} rows. To read more rows, pass --fetch-budget (max ${MAX_FETCH_BUDGET}).`;
|
|
31
|
+
}
|
|
27
32
|
function renderAnalysis(result, context, options) {
|
|
28
33
|
const rows = result.results;
|
|
29
34
|
const meta = result.meta;
|
|
@@ -31,6 +36,8 @@ function renderAnalysis(result, context, options) {
|
|
|
31
36
|
const end = typeof meta.endDate === "string" ? meta.endDate : context.end;
|
|
32
37
|
const lines = [...textLines(`${parseGscSiteUrl(context.site).hostname} / ${context.id}`, options, "accent"), ...textLines(`${start} to ${end}`, options, "muted")];
|
|
33
38
|
if (context.previous) lines.push(...textLines(`vs ${context.previous.start} to ${context.previous.end}`, options, "muted"));
|
|
39
|
+
const warning = coverageWarning(meta.coverage);
|
|
40
|
+
if (warning) lines.push(...textLines(warning, options, "warning"));
|
|
34
41
|
lines.push("");
|
|
35
42
|
if (!rows.length) {
|
|
36
43
|
lines.push(...textLines("No results for this period.", options));
|
|
@@ -95,4 +102,4 @@ function renderAnalysis(result, context, options) {
|
|
|
95
102
|
if (total !== null && total > rows.length) lines.push("", ...textLines(`${rows.length} of ${formatMetric("clicks", total)} rows`, options, "muted"));
|
|
96
103
|
return lines.join("\n");
|
|
97
104
|
}
|
|
98
|
-
export { asRecord, columnsFor, renderAnalysis, rowLabel };
|
|
105
|
+
export { asRecord, columnsFor, coverageWarning, renderAnalysis, rowLabel };
|
package/dist/render/report.mjs
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { renderTable, textLines } from "./layout.mjs";
|
|
2
2
|
import { barColumn, renderMetrics } from "./charts.mjs";
|
|
3
|
-
import { columnsFor } from "./analysis.mjs";
|
|
3
|
+
import { columnsFor, coverageWarning } from "./analysis.mjs";
|
|
4
4
|
import { parseGscSiteUrl } from "gscdump";
|
|
5
5
|
function renderCliReport(report, options) {
|
|
6
6
|
const lines = [...textLines(`${parseGscSiteUrl(report.site).hostname} / ${report.id}`, options, "accent"), ...textLines(`${report.window.start} to ${report.window.end}`, options, "muted")];
|
|
7
7
|
if (report.window.comparison) lines.push(...textLines(`vs ${report.window.comparison.start} to ${report.window.comparison.end}`, options, "muted"));
|
|
8
8
|
if (report.meta.degraded) lines.push(...textLines(`! Unavailable: ${report.meta.steps.filter((step) => step.status === "error").map((step) => step.key).join(", ")}`, options, "warning"));
|
|
9
|
+
const truncated = report.meta.steps.filter((step) => step.coverage?.kind === "truncated");
|
|
10
|
+
const largest = truncated.reduce((max, step) => Math.max(max, step.coverage?.kind === "truncated" ? step.coverage.fetched : 0), 0);
|
|
11
|
+
const warning = coverageWarning(truncated.length ? {
|
|
12
|
+
kind: "truncated",
|
|
13
|
+
fetched: largest
|
|
14
|
+
} : void 0);
|
|
15
|
+
if (warning) lines.push(...textLines(`${warning} Steps: ${truncated.map((step) => step.key).join(", ")}.`, options, "warning"));
|
|
9
16
|
const sections = report.sections.filter((section) => section.findings.length || section.coverage === "partial");
|
|
10
17
|
for (const section of sections) {
|
|
11
18
|
const status = section.coverage === "partial" ? " (partial)" : ["info", "low"].includes(section.severity) ? "" : ` [${section.severity}]`;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function createRequestPacer(opts) {
|
|
2
|
+
const now = opts.now ?? Date.now;
|
|
3
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
4
|
+
const spacing = 6e4 / opts.perMinute;
|
|
5
|
+
let nextStart = 0;
|
|
6
|
+
let inFlight = 0;
|
|
7
|
+
const waiting = [];
|
|
8
|
+
async function acquire() {
|
|
9
|
+
if (inFlight >= opts.maxInFlight) await new Promise((resolve) => waiting.push(resolve));
|
|
10
|
+
inFlight++;
|
|
11
|
+
const slot = Math.max(now(), nextStart);
|
|
12
|
+
nextStart = slot + spacing;
|
|
13
|
+
const wait = slot - now();
|
|
14
|
+
if (wait > 0) await sleep(wait);
|
|
15
|
+
}
|
|
16
|
+
function release() {
|
|
17
|
+
inFlight--;
|
|
18
|
+
waiting.shift()?.();
|
|
19
|
+
}
|
|
20
|
+
return { async run(task) {
|
|
21
|
+
await acquire();
|
|
22
|
+
try {
|
|
23
|
+
return await task();
|
|
24
|
+
} finally {
|
|
25
|
+
release();
|
|
26
|
+
}
|
|
27
|
+
} };
|
|
28
|
+
}
|
|
29
|
+
export { createRequestPacer };
|