@gscdump/cli 3.7.0 → 4.0.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 +113 -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 +295 -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 +167 -37
- package/dist/commands/export.mjs +0 -76
- package/dist/native-duckdb.mjs +0 -46
package/dist/commands/sync.mjs
CHANGED
|
@@ -1,24 +1,45 @@
|
|
|
1
1
|
import { syncCommandMeta } from "../command-meta.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { QUOTA_CAPS, googleErrorMessage, openQuotaLedger, parseQuotaRefusal } from "../quota-ledger.mjs";
|
|
3
|
+
import { OUTPUT_ARGS, applyOutputMode, clearLine, displayPath, formatAge, logger, parseIntegerOption, parseNameList, progressBar, runWithConcurrency } from "../utils.mjs";
|
|
3
4
|
import { TABLE_DIMS, allTables, assembleDatesRow, createLocalStore } from "../local-store.mjs";
|
|
4
|
-
import {
|
|
5
|
+
import { formatSiteIdCollision, readSiteMap, recordStoreSite, siteUrlForId } from "../store-sites.mjs";
|
|
6
|
+
import { createCommandContext, formatSiteResolution } from "../context.mjs";
|
|
7
|
+
import { INSPECTION_QPD_PER_PROPERTY } from "../inspection-record.mjs";
|
|
8
|
+
import { inspectionCandidates, syncInspections, syncSitemaps } from "../local-entities.mjs";
|
|
9
|
+
import { minimumCallsPerDate, planJobDates, planSyncJobs, resolveWindow } from "../sync-plan.mjs";
|
|
10
|
+
import { analyticsCoverage, readStoreCoverage, renderCoverage } from "../coverage.mjs";
|
|
11
|
+
import { isProcessAlive, readSyncRun, startSyncRun, syncRunStatus } from "../sync-run.mjs";
|
|
12
|
+
import { loadSitemapUrls } from "../sitemap.mjs";
|
|
13
|
+
import { createRequestPacer } from "../request-pacer.mjs";
|
|
5
14
|
import process from "node:process";
|
|
6
15
|
import { defineCommand } from "citty";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
7
17
|
import { SearchTypes } from "gscdump/query";
|
|
18
|
+
import { getLatestGscDate, getOldestGscDate, getPstDate, groupIntoRanges } from "gscdump/dates";
|
|
19
|
+
import { runGscSearchAppearanceContextSlice, runGscSyncSlice } from "@gscdump/engine-gsc-api";
|
|
20
|
+
import { resolveSiteInput } from "gscdump";
|
|
8
21
|
import { createRowAccumulator } from "@gscdump/engine/ingest";
|
|
9
22
|
import { createEmptyTypesStore } from "@gscdump/engine/entities";
|
|
10
|
-
import { daysAgoUtc, getDateRange } from "gscdump/dates";
|
|
11
23
|
import { DEFAULT_ROLLUPS, rebuildRollups } from "@gscdump/engine/rollups";
|
|
12
|
-
const DEFAULT_TABLES = [
|
|
13
|
-
"pages",
|
|
14
|
-
"queries",
|
|
15
|
-
"countries",
|
|
16
|
-
"dates"
|
|
17
|
-
];
|
|
18
|
-
const DEFAULT_TYPES = ["web"];
|
|
19
24
|
const ALL_SEARCH_TYPES = Object.values(SearchTypes);
|
|
20
|
-
const
|
|
25
|
+
const DEFAULT_TABLES = allTables();
|
|
26
|
+
const DEFAULT_TYPES = ALL_SEARCH_TYPES;
|
|
27
|
+
const GSC_ROW_LIMIT = 25e3;
|
|
28
|
+
const MAX_SLICE_ROWS = 5e6;
|
|
29
|
+
const SLICE_TABLES = [
|
|
30
|
+
"search_appearance",
|
|
31
|
+
"search_appearance_pages",
|
|
32
|
+
"search_appearance_queries",
|
|
33
|
+
"search_appearance_page_queries",
|
|
34
|
+
"hourly_pages"
|
|
35
|
+
];
|
|
36
|
+
function isSliceTable(table) {
|
|
37
|
+
return SLICE_TABLES.includes(table);
|
|
38
|
+
}
|
|
21
39
|
const DEFAULT_CONCURRENCY = 8;
|
|
40
|
+
const DEFAULT_MAX_IN_FLIGHT = 8;
|
|
41
|
+
const DEFAULT_REQUESTS_PER_MINUTE = 600;
|
|
42
|
+
const INSPECT_CONCURRENCY = 4;
|
|
22
43
|
const EMPTY_TYPE_PROBE_MIN_DAYS = 7;
|
|
23
44
|
const EMPTY_TYPE_PROTECTED = ["web"];
|
|
24
45
|
function createProgressTracker(total, quiet) {
|
|
@@ -48,25 +69,33 @@ function createProgressTracker(total, quiet) {
|
|
|
48
69
|
}
|
|
49
70
|
};
|
|
50
71
|
}
|
|
51
|
-
|
|
72
|
+
const SYNC_STOP = Symbol("syncStop");
|
|
73
|
+
function stopError(stop) {
|
|
74
|
+
return Object.assign(new Error(describeStop(stop)), { [SYNC_STOP]: stop });
|
|
75
|
+
}
|
|
76
|
+
function stopOf(error) {
|
|
77
|
+
return error?.[SYNC_STOP];
|
|
78
|
+
}
|
|
79
|
+
function describeStop(stop) {
|
|
80
|
+
if (stop.kind === "budget") return `Reached --max-calls ${stop.maxCalls}.`;
|
|
81
|
+
return `Google quota: ${stop.reason}`;
|
|
82
|
+
}
|
|
83
|
+
async function syncTable(opts) {
|
|
84
|
+
const { store, siteUrl, job, control, progress } = opts;
|
|
85
|
+
const { table, type: searchType } = job;
|
|
52
86
|
const dims = TABLE_DIMS[table];
|
|
53
87
|
const siteId = store.siteIdFor(siteUrl);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
await runWithConcurrency(dates, concurrency, async (date) => {
|
|
66
|
-
const prior = stateByDate.get(date);
|
|
67
|
-
if (!force && prior?.state === "done") {
|
|
68
|
-
skipped++;
|
|
69
|
-
progress.tick(`${label} ${date} (skip)`);
|
|
88
|
+
const totals = {
|
|
89
|
+
rows: 0,
|
|
90
|
+
skipped: opts.skipped,
|
|
91
|
+
failed: 0,
|
|
92
|
+
pending: 0,
|
|
93
|
+
fetched: 0
|
|
94
|
+
};
|
|
95
|
+
await runWithConcurrency(opts.dates, opts.concurrency, async (date) => {
|
|
96
|
+
if (control.stop) {
|
|
97
|
+
totals.pending++;
|
|
98
|
+
progress.tick(`${job.label} ${date} (later)`);
|
|
70
99
|
return;
|
|
71
100
|
}
|
|
72
101
|
const scope = {
|
|
@@ -77,28 +106,42 @@ async function syncTable(store, siteUrl, table, searchType, dates, client, concu
|
|
|
77
106
|
searchType
|
|
78
107
|
};
|
|
79
108
|
await store.engine.setSyncState(scope, "inflight");
|
|
80
|
-
const result = await runOneDate(store, client, siteUrl, table, searchType, dims, date).catch((err) => ({
|
|
109
|
+
const result = await runOneDate(store, opts.client, siteUrl, table, searchType, dims, date, opts.latest).catch((err) => ({
|
|
81
110
|
kind: "error",
|
|
82
111
|
error: err
|
|
83
112
|
}));
|
|
84
113
|
if (result.kind === "error") {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
114
|
+
const stop = stopOf(result.error);
|
|
115
|
+
if (stop) {
|
|
116
|
+
control.stop ??= stop;
|
|
117
|
+
await store.engine.setSyncState(scope, "pending", { error: describeStop(stop) });
|
|
118
|
+
totals.pending++;
|
|
119
|
+
progress.tick(`${job.label} ${date} (later)`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
await store.engine.setSyncState(scope, "failed", { error: googleErrorMessage(result.error) });
|
|
123
|
+
totals.failed++;
|
|
124
|
+
progress.tick(`${job.label} ${date} (fail)`);
|
|
125
|
+
opts.run.tick();
|
|
88
126
|
return;
|
|
89
127
|
}
|
|
90
|
-
await store.engine.setSyncState(scope, "done");
|
|
91
|
-
|
|
92
|
-
|
|
128
|
+
await store.engine.setSyncState(scope, result.final ? "done" : "pending");
|
|
129
|
+
totals.rows += result.rows;
|
|
130
|
+
totals.fetched++;
|
|
131
|
+
if (!result.final) totals.pending++;
|
|
132
|
+
progress.tick(`${job.label} ${date}`);
|
|
133
|
+
opts.run.tick();
|
|
93
134
|
});
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
135
|
+
return totals;
|
|
136
|
+
}
|
|
137
|
+
function isFinalDate(date, latest, metadata) {
|
|
138
|
+
if (date > latest) return false;
|
|
139
|
+
const incompleteDate = metadata?.first_incomplete_date;
|
|
140
|
+
if (incompleteDate && incompleteDate <= date) return false;
|
|
141
|
+
const incompleteHour = metadata?.first_incomplete_hour;
|
|
142
|
+
return !(incompleteHour && incompleteHour.slice(0, 10) <= date);
|
|
99
143
|
}
|
|
100
144
|
async function fetchDateRows(client, siteUrl, searchType, dims, date) {
|
|
101
|
-
const rowLimit = 25e3;
|
|
102
145
|
const rows = [];
|
|
103
146
|
let startRow = 0;
|
|
104
147
|
while (true) {
|
|
@@ -107,7 +150,7 @@ async function fetchDateRows(client, siteUrl, searchType, dims, date) {
|
|
|
107
150
|
endDate: date,
|
|
108
151
|
dimensions: dims,
|
|
109
152
|
searchType,
|
|
110
|
-
rowLimit,
|
|
153
|
+
rowLimit: GSC_ROW_LIMIT,
|
|
111
154
|
startRow
|
|
112
155
|
})).rows || [];
|
|
113
156
|
for (const apiRow of batch) rows.push({
|
|
@@ -117,12 +160,13 @@ async function fetchDateRows(client, siteUrl, searchType, dims, date) {
|
|
|
117
160
|
ctr: apiRow.ctr ?? 0,
|
|
118
161
|
position: apiRow.position ?? 0
|
|
119
162
|
});
|
|
120
|
-
if (batch.length
|
|
163
|
+
if (batch.length < GSC_ROW_LIMIT) break;
|
|
121
164
|
startRow += batch.length;
|
|
122
165
|
}
|
|
123
166
|
return rows;
|
|
124
167
|
}
|
|
125
|
-
async function runOneDate(store, client, siteUrl, table, searchType, dims, date) {
|
|
168
|
+
async function runOneDate(store, client, siteUrl, table, searchType, dims, date, latest) {
|
|
169
|
+
if (isSliceTable(table)) return runSliceDate(store, client, siteUrl, table, searchType, date, latest);
|
|
126
170
|
const apiRows = await fetchDateRows(client, siteUrl, searchType, dims, date);
|
|
127
171
|
let rows = [];
|
|
128
172
|
if (table === "dates") {
|
|
@@ -137,6 +181,63 @@ async function runOneDate(store, client, siteUrl, table, searchType, dims, date)
|
|
|
137
181
|
accumulator.push(table, apiRows);
|
|
138
182
|
rows = accumulator.drain().get(table)?.get(date) ?? [];
|
|
139
183
|
}
|
|
184
|
+
return writeDayRows(store, siteUrl, table, searchType, date, rows, isFinalDate(date, latest, void 0));
|
|
185
|
+
}
|
|
186
|
+
async function runSliceDate(store, client, siteUrl, table, searchType, date, latest) {
|
|
187
|
+
const base = {
|
|
188
|
+
client,
|
|
189
|
+
siteUrl,
|
|
190
|
+
startDate: date,
|
|
191
|
+
endDate: date,
|
|
192
|
+
searchType,
|
|
193
|
+
rowLimit: GSC_ROW_LIMIT,
|
|
194
|
+
cpuBudgetMs: Infinity
|
|
195
|
+
};
|
|
196
|
+
const drainDay = (accumulator) => {
|
|
197
|
+
if (accumulator.overflowed) throw new Error(`${table} ${date}: more rows than one sync can hold`);
|
|
198
|
+
return accumulator.drain().get(table)?.get(date) ?? [];
|
|
199
|
+
};
|
|
200
|
+
let rows;
|
|
201
|
+
let metadata;
|
|
202
|
+
if (table === "search_appearance" || table === "hourly_pages") {
|
|
203
|
+
const accumulator = createRowAccumulator({
|
|
204
|
+
maxRows: MAX_SLICE_ROWS,
|
|
205
|
+
date
|
|
206
|
+
});
|
|
207
|
+
const result = await runGscSyncSlice({
|
|
208
|
+
...base,
|
|
209
|
+
table,
|
|
210
|
+
onBatch: async (batch) => {
|
|
211
|
+
accumulator.push(table, batch);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
if (result.hasMore) throw new Error(`${table} ${date}: Google stopped before the last page`);
|
|
215
|
+
metadata = result.metadata;
|
|
216
|
+
rows = drainDay(accumulator);
|
|
217
|
+
} else {
|
|
218
|
+
const byAppearance = /* @__PURE__ */ new Map();
|
|
219
|
+
const result = await runGscSearchAppearanceContextSlice({
|
|
220
|
+
...base,
|
|
221
|
+
table,
|
|
222
|
+
onContextBatch: async ({ searchAppearance, rows: batch }) => {
|
|
223
|
+
let accumulator = byAppearance.get(searchAppearance);
|
|
224
|
+
if (!accumulator) {
|
|
225
|
+
accumulator = createRowAccumulator({
|
|
226
|
+
maxRows: MAX_SLICE_ROWS,
|
|
227
|
+
searchAppearance
|
|
228
|
+
});
|
|
229
|
+
byAppearance.set(searchAppearance, accumulator);
|
|
230
|
+
}
|
|
231
|
+
accumulator.push(table, batch);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
if (result.hasMore) throw new Error(`${table} ${date}: Google stopped before the last page`);
|
|
235
|
+
metadata = result.metadata;
|
|
236
|
+
rows = [...byAppearance.values()].flatMap(drainDay);
|
|
237
|
+
}
|
|
238
|
+
return writeDayRows(store, siteUrl, table, searchType, date, rows, isFinalDate(date, latest, metadata));
|
|
239
|
+
}
|
|
240
|
+
async function writeDayRows(store, siteUrl, table, searchType, date, rows, final) {
|
|
140
241
|
const writeCtx = {
|
|
141
242
|
userId: store.userId,
|
|
142
243
|
siteId: store.siteIdFor(siteUrl),
|
|
@@ -147,16 +248,25 @@ async function runOneDate(store, client, siteUrl, table, searchType, dims, date)
|
|
|
147
248
|
await store.engine.writeDay(writeCtx, rows);
|
|
148
249
|
return {
|
|
149
250
|
kind: "ok",
|
|
150
|
-
rows: rows.length
|
|
251
|
+
rows: rows.length,
|
|
252
|
+
final
|
|
151
253
|
};
|
|
152
254
|
}
|
|
255
|
+
const LEDGER_FETCH_OPTIONS = { onResponseError(ctx) {
|
|
256
|
+
if (ctx.options.retryStatusCodes) ctx.options.retryStatusCodes = ctx.options.retryStatusCodes.filter((status) => status !== 403);
|
|
257
|
+
} };
|
|
153
258
|
const syncCommand = defineCommand({
|
|
154
259
|
meta: syncCommandMeta,
|
|
155
260
|
args: {
|
|
156
261
|
"site": {
|
|
157
262
|
type: "string",
|
|
158
263
|
alias: "s",
|
|
159
|
-
description: "Site
|
|
264
|
+
description: "Site, for example example.com"
|
|
265
|
+
},
|
|
266
|
+
"all-sites": {
|
|
267
|
+
type: "boolean",
|
|
268
|
+
default: false,
|
|
269
|
+
description: "Sync every verified Site, one after another (they share one Google quota)"
|
|
160
270
|
},
|
|
161
271
|
"start": {
|
|
162
272
|
type: "string",
|
|
@@ -164,11 +274,11 @@ const syncCommand = defineCommand({
|
|
|
164
274
|
},
|
|
165
275
|
"end": {
|
|
166
276
|
type: "string",
|
|
167
|
-
description: "End date (YYYY-MM-DD); defaults to
|
|
277
|
+
description: "End date (YYYY-MM-DD); defaults to the latest date Google has finalized"
|
|
168
278
|
},
|
|
169
279
|
"days": {
|
|
170
280
|
type: "string",
|
|
171
|
-
description:
|
|
281
|
+
description: "Sync the last N final days instead of catching up"
|
|
172
282
|
},
|
|
173
283
|
"tables": {
|
|
174
284
|
type: "string",
|
|
@@ -184,14 +294,35 @@ const syncCommand = defineCommand({
|
|
|
184
294
|
default: false,
|
|
185
295
|
description: "Ignore stored empty-type markers and re-probe every requested type"
|
|
186
296
|
},
|
|
187
|
-
"
|
|
297
|
+
"rollups": {
|
|
188
298
|
type: "boolean",
|
|
189
|
-
default:
|
|
190
|
-
description: "
|
|
299
|
+
default: true,
|
|
300
|
+
description: "Rebuild rollups after sync (daily and weekly totals, top-N tables)",
|
|
301
|
+
negativeDescription: "Skip the post-sync rollup rebuild"
|
|
302
|
+
},
|
|
303
|
+
"sitemaps": {
|
|
304
|
+
type: "boolean",
|
|
305
|
+
default: true,
|
|
306
|
+
description: "Save the Search Console sitemap list and the URLs in each sitemap",
|
|
307
|
+
negativeDescription: "Skip saving sitemaps"
|
|
308
|
+
},
|
|
309
|
+
"inspections": {
|
|
310
|
+
type: "boolean",
|
|
311
|
+
default: true,
|
|
312
|
+
description: "Run URL Inspection on URLs that are due and save the results",
|
|
313
|
+
negativeDescription: "Skip URL Inspection"
|
|
314
|
+
},
|
|
315
|
+
"inspect-limit": {
|
|
316
|
+
type: "string",
|
|
317
|
+
description: `Most URLs to inspect in this run (default: 50; Google allows ${INSPECTION_QPD_PER_PROPERTY} per Site per day)`
|
|
191
318
|
},
|
|
192
319
|
"full": {
|
|
193
320
|
type: "boolean",
|
|
194
|
-
description:
|
|
321
|
+
description: `Backfill all the Search Console data Google keeps (16 months, plus 14 days Google often still serves)`
|
|
322
|
+
},
|
|
323
|
+
"max-calls": {
|
|
324
|
+
type: "string",
|
|
325
|
+
description: "Most Search Analytics calls this run makes. The next run continues where it stopped."
|
|
195
326
|
},
|
|
196
327
|
...OUTPUT_ARGS,
|
|
197
328
|
"force": {
|
|
@@ -202,12 +333,16 @@ const syncCommand = defineCommand({
|
|
|
202
333
|
"status": {
|
|
203
334
|
type: "boolean",
|
|
204
335
|
default: false,
|
|
205
|
-
description: "Print
|
|
336
|
+
description: "Print coverage, gaps, and the running sync instead of syncing"
|
|
206
337
|
},
|
|
207
338
|
"concurrency": {
|
|
208
339
|
type: "string",
|
|
209
340
|
alias: "c",
|
|
210
|
-
description: `Concurrent
|
|
341
|
+
description: `Concurrent day fetches per table (default: ${DEFAULT_CONCURRENCY}). All tables share ${DEFAULT_MAX_IN_FLIGHT} requests in flight.`
|
|
342
|
+
},
|
|
343
|
+
"requests-per-minute": {
|
|
344
|
+
type: "string",
|
|
345
|
+
description: `Most Search Analytics requests to start per minute, across all tables (default: ${DEFAULT_REQUESTS_PER_MINUTE}; Google allows 1,200)`
|
|
211
346
|
},
|
|
212
347
|
"serial-tables": {
|
|
213
348
|
type: "boolean",
|
|
@@ -217,7 +352,7 @@ const syncCommand = defineCommand({
|
|
|
217
352
|
"retry-failed": {
|
|
218
353
|
type: "boolean",
|
|
219
354
|
default: false,
|
|
220
|
-
description: "Only re-run dates currently in `failed` state (
|
|
355
|
+
description: "Only re-run dates currently in `failed` state (a plain sync also retries them)"
|
|
221
356
|
},
|
|
222
357
|
"dry-run": {
|
|
223
358
|
type: "boolean",
|
|
@@ -229,283 +364,618 @@ const syncCommand = defineCommand({
|
|
|
229
364
|
const { json, quiet } = applyOutputMode(args);
|
|
230
365
|
const days = parseIntegerOption(args.days, "--days");
|
|
231
366
|
const concurrency = parseIntegerOption(args.concurrency, "--concurrency") ?? DEFAULT_CONCURRENCY;
|
|
367
|
+
const maxCalls = parseIntegerOption(args["max-calls"], "--max-calls");
|
|
368
|
+
const inspectLimit = Math.min(parseIntegerOption(args["inspect-limit"], "--inspect-limit", 0) ?? 50, INSPECTION_QPD_PER_PROPERTY);
|
|
369
|
+
const tables = args.tables ? parseNameList(args.tables, allTables(), "--tables") : DEFAULT_TABLES;
|
|
370
|
+
const requestedTypes = args.types ? parseNameList(args.types, ALL_SEARCH_TYPES, "--types") : DEFAULT_TYPES;
|
|
232
371
|
if (args.status) {
|
|
233
372
|
const ctx = await createCommandContext();
|
|
373
|
+
const siteUrl = args.site ? await resolveStatusSite(ctx, String(args.site)) : void 0;
|
|
234
374
|
await printSyncStatus({
|
|
235
375
|
config: ctx.config,
|
|
236
376
|
dataDir: ctx.dataDir
|
|
237
|
-
},
|
|
377
|
+
}, siteUrl, json, inspectLimit);
|
|
238
378
|
return;
|
|
239
379
|
}
|
|
240
380
|
const ctx = await createCommandContext({
|
|
241
381
|
needsAuth: true,
|
|
242
|
-
needsStore: true
|
|
382
|
+
needsStore: true,
|
|
383
|
+
fetchOptions: LEDGER_FETCH_OPTIONS,
|
|
384
|
+
quota: false
|
|
243
385
|
});
|
|
244
|
-
const client = ctx.client;
|
|
245
|
-
const siteUrl = await ctx.resolveSite(args.site ? String(args.site) : void 0);
|
|
246
|
-
const tables = args.tables ? String(args.tables).split(",").map((t) => t.trim()).filter(isKnownTable) : DEFAULT_TABLES;
|
|
247
|
-
const requestedTypes = args.types ? String(args.types).split(",").map((t) => t.trim()).filter(isKnownSearchType) : DEFAULT_TYPES;
|
|
248
|
-
if (requestedTypes.length === 0) {
|
|
249
|
-
logger.error(`No valid search types specified. Allowed: ${ALL_SEARCH_TYPES.join(",")}`);
|
|
250
|
-
process.exit(1);
|
|
251
|
-
}
|
|
252
386
|
const store = ctx.store;
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
}
|
|
269
|
-
const forceTypes = Boolean(args["force-types"]);
|
|
270
|
-
const skippedTypes = [];
|
|
271
|
-
const types = [];
|
|
272
|
-
for (const t of requestedTypes) {
|
|
273
|
-
if (!forceTypes && emptyTypesDoc.emptyTypes.includes(t) && !EMPTY_TYPE_PROTECTED.includes(t)) {
|
|
274
|
-
skippedTypes.push(t);
|
|
387
|
+
const requestedSites = args["all-sites"] ? (await ctx.loadSites()).map((site) => site.siteUrl) : [await ctx.resolveSite(args.site ? String(args.site) : void 0)];
|
|
388
|
+
const siteUrls = [];
|
|
389
|
+
for (const siteUrl of requestedSites) {
|
|
390
|
+
const claim = await recordStoreSite(store.dataDir, siteUrl, {
|
|
391
|
+
userId: store.userId,
|
|
392
|
+
write: !args["dry-run"]
|
|
393
|
+
});
|
|
394
|
+
if (claim.ok) {
|
|
395
|
+
siteUrls.push(siteUrl);
|
|
275
396
|
continue;
|
|
276
397
|
}
|
|
277
|
-
|
|
398
|
+
if (!args["all-sites"]) {
|
|
399
|
+
logger.error(formatSiteIdCollision(claim.error));
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
logger.warn(`Skipped ${siteUrl}. ${formatSiteIdCollision(claim.error)}`);
|
|
278
403
|
}
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
404
|
+
const latest = getLatestGscDate();
|
|
405
|
+
const window = resolveWindow({
|
|
406
|
+
start: args.start ? String(args.start) : void 0,
|
|
407
|
+
end: args.end ? String(args.end) : void 0,
|
|
408
|
+
days,
|
|
409
|
+
full: Boolean(args.full),
|
|
410
|
+
latest,
|
|
411
|
+
floor: getOldestGscDate()
|
|
412
|
+
});
|
|
413
|
+
const mode = args["retry-failed"] ? "retry-failed" : args.force ? "force" : "resume";
|
|
414
|
+
if (!args["dry-run"]) {
|
|
415
|
+
const existing = syncRunStatus(await readSyncRun(store.dataDir), {
|
|
416
|
+
now: Date.now(),
|
|
417
|
+
isAlive: isProcessAlive
|
|
418
|
+
});
|
|
419
|
+
if (existing.kind === "running") {
|
|
420
|
+
logger.error(`Another sync is running (pid ${existing.record.pid}, ${existing.record.done}/${existing.record.planned} days). Wait for it to finish, or run \`gscdump sync --status\`.`);
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
290
423
|
}
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
424
|
+
const ledger = await openQuotaLedger({ dataDir: store.dataDir });
|
|
425
|
+
const pacer = createRequestPacer({
|
|
426
|
+
maxInFlight: DEFAULT_MAX_IN_FLIGHT,
|
|
427
|
+
perMinute: Math.min(parseIntegerOption(args["requests-per-minute"], "--requests-per-minute") ?? DEFAULT_REQUESTS_PER_MINUTE, QUOTA_CAPS.searchAnalytics.perMinute)
|
|
428
|
+
});
|
|
429
|
+
const common = {
|
|
430
|
+
store,
|
|
431
|
+
client: gatedClient(ctx.client, {
|
|
432
|
+
pacer,
|
|
433
|
+
ledger,
|
|
434
|
+
calls: { made: 0 },
|
|
435
|
+
maxCalls
|
|
436
|
+
}),
|
|
437
|
+
ledger,
|
|
438
|
+
tables,
|
|
439
|
+
requestedTypes,
|
|
440
|
+
window,
|
|
441
|
+
mode,
|
|
442
|
+
latest,
|
|
443
|
+
concurrency,
|
|
444
|
+
inspectLimit,
|
|
445
|
+
json,
|
|
446
|
+
quiet,
|
|
447
|
+
args
|
|
311
448
|
};
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
449
|
+
if (args["dry-run"]) {
|
|
450
|
+
const plans = [];
|
|
451
|
+
for (const siteUrl of siteUrls) plans.push(await planSite({
|
|
452
|
+
...common,
|
|
453
|
+
siteUrl
|
|
454
|
+
}));
|
|
455
|
+
if (json) console.log(JSON.stringify(plans.length === 1 ? plans[0] : { sites: plans }, null, 2));
|
|
315
456
|
return;
|
|
316
457
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
458
|
+
const run = await startSyncRun({
|
|
459
|
+
dataDir: store.dataDir,
|
|
460
|
+
sites: siteUrls,
|
|
461
|
+
onHeartbeat: () => ledger.flush()
|
|
462
|
+
});
|
|
463
|
+
const onInterrupt = () => {
|
|
464
|
+
clearLine();
|
|
465
|
+
logger.warn("Sync interrupted. Run the same command to resume.");
|
|
466
|
+
Promise.all([run.finish("interrupted"), ledger.flush()]).catch((error) => logger.warn(`Could not save sync progress: ${error.message}`)).finally(() => process.exit(130));
|
|
467
|
+
};
|
|
468
|
+
process.once("SIGINT", onInterrupt);
|
|
469
|
+
const results = [];
|
|
470
|
+
for (const siteUrl of siteUrls) {
|
|
471
|
+
run.setSite(siteUrl);
|
|
472
|
+
const result = await syncSite({
|
|
473
|
+
...common,
|
|
474
|
+
siteUrl,
|
|
475
|
+
run
|
|
324
476
|
});
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (dates.length === 0) {
|
|
328
|
-
if (!quiet) logger.success("No failed dates in range. Nothing to retry.");
|
|
329
|
-
await printCompletion("skipped", {}, "no-failed-dates");
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
args.force = true;
|
|
333
|
-
if (!quiet) logger.info(`--retry-failed: ${dates.length} date(s) to retry`);
|
|
477
|
+
results.push(result);
|
|
478
|
+
if (result.stop?.kind === "budget") break;
|
|
334
479
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
480
|
+
process.off("SIGINT", onInterrupt);
|
|
481
|
+
const failed = results.some((result) => result.status === "failed");
|
|
482
|
+
const stopped = results.some((result) => result.status === "partial");
|
|
483
|
+
await ledger.flush();
|
|
484
|
+
await run.finish(failed ? "failed" : stopped ? "stopped" : "completed");
|
|
485
|
+
if (json) {
|
|
486
|
+
const skippedSites = siteUrls.slice(results.length);
|
|
487
|
+
const reports = results.map((result) => result.report);
|
|
488
|
+
console.log(JSON.stringify(siteUrls.length === 1 ? reports[0] : {
|
|
489
|
+
sites: reports,
|
|
490
|
+
notStarted: skippedSites
|
|
491
|
+
}, null, 2));
|
|
492
|
+
} else if (siteUrls.length > results.length && !quiet) logger.info(`Not started: ${siteUrls.slice(results.length).join(", ")}. The next sync covers them.`);
|
|
493
|
+
if (failed) process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
async function resolveTypes(opts, dryRun) {
|
|
497
|
+
const { store, siteUrl, requestedTypes, quiet } = opts;
|
|
498
|
+
const scope = {
|
|
499
|
+
userId: store.userId,
|
|
500
|
+
siteId: store.siteIdFor(siteUrl)
|
|
501
|
+
};
|
|
502
|
+
const emptyTypesStore = createEmptyTypesStore({ dataSource: store.dataSource });
|
|
503
|
+
let emptyTypesDoc = await emptyTypesStore.load(scope);
|
|
504
|
+
if (emptyTypesDoc.emptyTypes.length > 0) {
|
|
505
|
+
const entries = await store.engine.listLive(scope);
|
|
506
|
+
const populated = new Set(entries.filter((entry) => entry.rowCount > 0).map((entry) => entry.searchType ?? "web"));
|
|
507
|
+
const toClear = requestedTypes.filter((type) => emptyTypesDoc.emptyTypes.includes(type) && populated.has(type));
|
|
508
|
+
if (toClear.length > 0) {
|
|
509
|
+
if (!dryRun) emptyTypesDoc = await emptyTypesStore.clear(scope, toClear);
|
|
510
|
+
else emptyTypesDoc.emptyTypes = emptyTypesDoc.emptyTypes.filter((type) => !toClear.includes(type));
|
|
364
511
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
512
|
+
}
|
|
513
|
+
const forceTypes = Boolean(opts.args["force-types"]);
|
|
514
|
+
const skippedTypes = [];
|
|
515
|
+
const types = [];
|
|
516
|
+
for (const t of requestedTypes) {
|
|
517
|
+
if (!forceTypes && emptyTypesDoc.emptyTypes.includes(t) && !EMPTY_TYPE_PROTECTED.includes(t)) {
|
|
518
|
+
skippedTypes.push(t);
|
|
519
|
+
continue;
|
|
368
520
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
521
|
+
types.push(t);
|
|
522
|
+
}
|
|
523
|
+
if (skippedTypes.length > 0 && !quiet) logger.info(`Skipping ${skippedTypes.join(", ")} (marked empty for this site; pass --force-types to re-probe).`);
|
|
524
|
+
return {
|
|
525
|
+
types,
|
|
526
|
+
skippedTypes,
|
|
527
|
+
emptyTypes: emptyTypesDoc.emptyTypes
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
async function buildSitePlan(opts, types, skippedTypes, emptyTypes) {
|
|
531
|
+
const { store, siteUrl, tables, quiet, args } = opts;
|
|
532
|
+
const { jobs, unsupported } = planSyncJobs(tables, types);
|
|
533
|
+
if (unsupported.length > 0 && !quiet && (args.tables || args.types)) logger.info(`Skipping ${unsupported.map((job) => job.label).join(", ")}: Google has no such breakdown for that search type.`);
|
|
534
|
+
const states = await store.engine.getSyncStates({
|
|
535
|
+
userId: store.userId,
|
|
536
|
+
siteId: store.siteIdFor(siteUrl)
|
|
537
|
+
});
|
|
538
|
+
const today = getPstDate();
|
|
539
|
+
const forceTypes = Boolean(args["force-types"]);
|
|
540
|
+
return {
|
|
541
|
+
types,
|
|
542
|
+
skippedTypes,
|
|
543
|
+
jobs: jobs.map((job) => {
|
|
544
|
+
const jobStates = states.filter((state) => state.table === job.table && (state.searchType ?? "web") === job.type);
|
|
545
|
+
const mode = forceTypes && opts.mode === "resume" && emptyTypes.includes(job.type) ? "force" : opts.mode;
|
|
546
|
+
const planned = planJobDates({
|
|
547
|
+
table: job.table,
|
|
548
|
+
window: opts.window,
|
|
549
|
+
states: jobStates,
|
|
550
|
+
today,
|
|
551
|
+
mode
|
|
379
552
|
});
|
|
553
|
+
return {
|
|
554
|
+
job,
|
|
555
|
+
dates: planned.dates,
|
|
556
|
+
skipped: planned.skippedDone
|
|
557
|
+
};
|
|
558
|
+
})
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function windowLabel(window) {
|
|
562
|
+
return window.kind === "range" ? `${window.start} → ${window.end}` : `catch up to ${window.end} (new tables start ${window.firstStart})`;
|
|
563
|
+
}
|
|
564
|
+
async function planSite(opts) {
|
|
565
|
+
const { types, skippedTypes, emptyTypes } = await resolveTypes(opts, true);
|
|
566
|
+
const items = (await buildSitePlan(opts, types, skippedTypes, emptyTypes)).jobs.flatMap(({ job, dates }) => dates.map((date) => ({
|
|
567
|
+
table: job.table,
|
|
568
|
+
searchType: job.type,
|
|
569
|
+
date,
|
|
570
|
+
calls: minimumCallsPerDate(job.table)
|
|
571
|
+
})));
|
|
572
|
+
const minimumCalls = items.reduce((sum, item) => sum + item.calls, 0);
|
|
573
|
+
const ranges = groupIntoRanges([...new Set(items.map((item) => item.date))]);
|
|
574
|
+
if (!opts.json) {
|
|
575
|
+
console.log();
|
|
576
|
+
logger.info(`Plan: at least ${minimumCalls.toLocaleString("en-US")} Search Analytics call(s) for ${opts.siteUrl}`);
|
|
577
|
+
console.log(` Tables: ${opts.tables.join(", ")}`);
|
|
578
|
+
console.log(` Types: ${types.join(", ")}`);
|
|
579
|
+
console.log(` Window: ${windowLabel(opts.window)}`);
|
|
580
|
+
console.log(` Dates: ${ranges.length === 0 ? "none; every date is done" : ranges.map((range) => range.startDate === range.endDate ? range.startDate : `${range.startDate}..${range.endDate}`).join(", ")}`);
|
|
581
|
+
console.log();
|
|
582
|
+
logger.info("Pass without --dry-run to execute. Google may need more calls for large days and search appearances.");
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
siteUrl: opts.siteUrl,
|
|
586
|
+
window: opts.window,
|
|
587
|
+
tables: opts.tables,
|
|
588
|
+
types,
|
|
589
|
+
skippedTypes,
|
|
590
|
+
ranges,
|
|
591
|
+
minimumCalls,
|
|
592
|
+
totalCalls: minimumCalls,
|
|
593
|
+
plan: items
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
async function syncSite(opts) {
|
|
597
|
+
const { store, siteUrl, client, quiet, run } = opts;
|
|
598
|
+
const siteId = store.siteIdFor(siteUrl);
|
|
599
|
+
const scope = {
|
|
600
|
+
userId: store.userId,
|
|
601
|
+
siteId
|
|
602
|
+
};
|
|
603
|
+
const { types, skippedTypes, emptyTypes } = await resolveTypes(opts, false);
|
|
604
|
+
const forceTypes = Boolean(opts.args["force-types"]);
|
|
605
|
+
const report = async (status, totals, extra = {}) => ({
|
|
606
|
+
status,
|
|
607
|
+
siteUrl,
|
|
608
|
+
window: opts.window,
|
|
609
|
+
tables: opts.tables,
|
|
610
|
+
types,
|
|
611
|
+
skippedTypes,
|
|
612
|
+
totals,
|
|
613
|
+
watermarks: await store.engine.getWatermarks(scope),
|
|
614
|
+
...extra
|
|
615
|
+
});
|
|
616
|
+
if (types.length === 0) {
|
|
617
|
+
if (!quiet) logger.warn(`All requested types are marked empty. Pass --force-types to check them again.`);
|
|
618
|
+
return {
|
|
619
|
+
status: "skipped",
|
|
620
|
+
report: await report("skipped", {}, { reason: "empty-types" })
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const plan = await buildSitePlan(opts, types, skippedTypes, emptyTypes);
|
|
624
|
+
const plannedDates = plan.jobs.reduce((sum, entry) => sum + entry.dates.length, 0);
|
|
625
|
+
if (opts.mode === "retry-failed" && plannedDates === 0) {
|
|
626
|
+
if (!quiet) logger.success("No failed dates in range. Nothing to retry.");
|
|
627
|
+
return {
|
|
628
|
+
status: "skipped",
|
|
629
|
+
report: await report("skipped", {}, { reason: "no-failed-dates" })
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
await store.engine.setSyncStates(plan.jobs.flatMap(({ job, dates }) => dates.map((date) => ({
|
|
633
|
+
userId: store.userId,
|
|
634
|
+
siteId,
|
|
635
|
+
table: job.table,
|
|
636
|
+
date,
|
|
637
|
+
searchType: job.type
|
|
638
|
+
}))), "pending");
|
|
639
|
+
run.addPlanned(plannedDates);
|
|
640
|
+
if (!quiet) {
|
|
641
|
+
const ranges = groupIntoRanges([...new Set(plan.jobs.flatMap((entry) => entry.dates))]);
|
|
642
|
+
logger.info(`Syncing ${siteUrl} (${opts.tables.join(", ")}) [${types.join(", ")}] → ${displayPath(store.dataDir)}`);
|
|
643
|
+
logger.info(ranges.length === 0 ? `Every date is done up to ${opts.latest}.` : `${plannedDates.toLocaleString("en-US")} table days, newest first: ${ranges.slice(-3).reverse().map((range) => range.startDate === range.endDate ? range.startDate : `${range.startDate}..${range.endDate}`).join(", ")}${ranges.length > 3 ? `, and ${ranges.length - 3} more range(s)` : ""}`);
|
|
644
|
+
}
|
|
645
|
+
const control = {};
|
|
646
|
+
const start = Date.now();
|
|
647
|
+
const totals = {};
|
|
648
|
+
const progress = createProgressTracker(plannedDates, quiet);
|
|
649
|
+
const runJob = (entry) => syncTable({
|
|
650
|
+
store,
|
|
651
|
+
siteUrl,
|
|
652
|
+
job: entry.job,
|
|
653
|
+
dates: entry.dates,
|
|
654
|
+
skipped: entry.skipped,
|
|
655
|
+
client,
|
|
656
|
+
concurrency: opts.concurrency,
|
|
657
|
+
latest: opts.latest,
|
|
658
|
+
control,
|
|
659
|
+
progress,
|
|
660
|
+
run
|
|
661
|
+
});
|
|
662
|
+
if (opts.args["serial-tables"]) for (const entry of plan.jobs) totals[entry.job.label] = await runJob(entry);
|
|
663
|
+
else {
|
|
664
|
+
const results = await Promise.all(plan.jobs.map(runJob));
|
|
665
|
+
plan.jobs.forEach((entry, i) => {
|
|
666
|
+
totals[entry.job.label] = results[i];
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
progress.done();
|
|
670
|
+
await run.save();
|
|
671
|
+
const seconds = ((Date.now() - start) / 1e3).toFixed(1);
|
|
672
|
+
if (!quiet) {
|
|
673
|
+
logger.success(`Synced ${siteUrl} in ${seconds}s`);
|
|
674
|
+
for (const [t, n] of Object.entries(totals)) {
|
|
675
|
+
const suffix = [
|
|
676
|
+
n.skipped > 0 ? `${n.skipped} already done` : null,
|
|
677
|
+
n.pending > 0 ? `${n.pending} for the next sync` : null,
|
|
678
|
+
n.failed > 0 ? `\x1B[31m${n.failed} failed\x1B[0m` : null
|
|
679
|
+
].filter(Boolean).join(", ");
|
|
680
|
+
const tail = suffix ? ` (${suffix})` : "";
|
|
681
|
+
console.log(` ${t}: ${n.rows.toLocaleString()} rows${tail}`);
|
|
380
682
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
683
|
+
console.log();
|
|
684
|
+
}
|
|
685
|
+
if (control.stop?.kind === "budget" && !quiet) logger.info(`${describeStop(control.stop)} The next sync continues from here.`);
|
|
686
|
+
else if (control.stop?.kind === "quota" && !opts.json) logger.warn(`${describeStop(control.stop)} The next sync continues after ${clockTime(control.stop.resetsAt)}.`);
|
|
687
|
+
const anyFailed = Object.values(totals).some((t) => t.failed > 0);
|
|
688
|
+
if (!forceTypes && opts.tables.length > 0 && !control.stop) {
|
|
689
|
+
const toMark = [];
|
|
690
|
+
for (const type of types) {
|
|
691
|
+
if (EMPTY_TYPE_PROTECTED.includes(type)) continue;
|
|
692
|
+
const typeTotals = plan.jobs.filter((entry) => entry.job.type === type).map((entry) => totals[entry.job.label]);
|
|
693
|
+
if (!(typeTotals.length > 0 && typeTotals.every((t) => t.fetched >= EMPTY_TYPE_PROBE_MIN_DAYS && t.skipped === 0 && t.failed === 0 && t.rows === 0))) continue;
|
|
694
|
+
if ((await store.engine.listLive({
|
|
695
|
+
...scope,
|
|
696
|
+
searchType: type
|
|
697
|
+
})).some((entry) => entry.rowCount > 0)) continue;
|
|
698
|
+
toMark.push(type);
|
|
388
699
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
logger.success(`Synced ${siteUrl} in ${seconds}s`);
|
|
393
|
-
for (const [t, n] of Object.entries(totals)) {
|
|
394
|
-
const suffix = [n.skipped > 0 ? `${n.skipped} skipped` : null, n.failed > 0 ? `\x1B[31m${n.failed} failed\x1B[0m` : null].filter(Boolean).join(", ");
|
|
395
|
-
const tail = suffix ? ` (${suffix})` : "";
|
|
396
|
-
console.log(` ${t}: ${n.rows.toLocaleString()} rows${tail}`);
|
|
397
|
-
}
|
|
398
|
-
console.log();
|
|
700
|
+
if (toMark.length > 0) {
|
|
701
|
+
await createEmptyTypesStore({ dataSource: store.dataSource }).mark(scope, toMark);
|
|
702
|
+
if (!quiet) logger.info(`Marked empty for future syncs: ${toMark.join(", ")} (0 rows across ${EMPTY_TYPE_PROBE_MIN_DAYS}+ days; pass --force-types to re-probe).`);
|
|
399
703
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
rowsByType.set(job.type, (rowsByType.get(job.type) ?? 0) + t.rows);
|
|
407
|
-
failedByType.set(job.type, (failedByType.get(job.type) ?? 0) + t.failed);
|
|
408
|
-
skippedByType.set(job.type, (skippedByType.get(job.type) ?? 0) + t.skipped);
|
|
704
|
+
}
|
|
705
|
+
if (forceTypes && emptyTypes.length > 0) {
|
|
706
|
+
const toClear = types.filter((type) => emptyTypes.includes(type) && plan.jobs.some((entry) => entry.job.type === type && totals[entry.job.label].rows > 0));
|
|
707
|
+
if (toClear.length > 0) {
|
|
708
|
+
await createEmptyTypesStore({ dataSource: store.dataSource }).clear(scope, toClear);
|
|
709
|
+
if (!quiet) logger.info(`Cleared empty markers for: ${toClear.join(", ")} (re-probe found data).`);
|
|
409
710
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
711
|
+
}
|
|
712
|
+
const noRollups = opts.args.rollups === false;
|
|
713
|
+
let rollupError;
|
|
714
|
+
const anyRowsSynced = Object.values(totals).some((t) => t.rows > 0);
|
|
715
|
+
if (!noRollups && anyRowsSynced) {
|
|
716
|
+
if (!quiet) logger.info(`Rebuilding rollups for [${siteId}] (${DEFAULT_ROLLUPS.length} rollups)…`);
|
|
717
|
+
const rollupStart = Date.now();
|
|
718
|
+
const results = await rebuildRollups({
|
|
719
|
+
engine: {
|
|
720
|
+
runSQL: (sqlOpts) => store.engine.runSQL(sqlOpts),
|
|
721
|
+
listPartitions: async ({ ctx, table, searchType }) => {
|
|
722
|
+
return (await store.engine.listLive({
|
|
723
|
+
userId: ctx.userId,
|
|
724
|
+
...ctx.siteId !== void 0 ? { siteId: ctx.siteId } : {},
|
|
725
|
+
table,
|
|
726
|
+
...searchType !== void 0 ? { searchType } : {}
|
|
727
|
+
})).map((e) => ({
|
|
728
|
+
partition: e.partition,
|
|
729
|
+
bytes: e.bytes
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
dataSource: store.dataSource,
|
|
734
|
+
ctx: scope,
|
|
735
|
+
defs: DEFAULT_ROLLUPS
|
|
736
|
+
}).catch((err) => {
|
|
737
|
+
rollupError = err.message;
|
|
738
|
+
logger.warn(`Rollup rebuild failed: ${err.message}`);
|
|
739
|
+
return [];
|
|
740
|
+
});
|
|
741
|
+
if (!quiet && results.length > 0) {
|
|
742
|
+
const kb = results.reduce((a, r) => a + r.bytes, 0) / 1024;
|
|
743
|
+
const ms = Date.now() - rollupStart;
|
|
744
|
+
logger.success(`Rebuilt ${results.length} rollup(s) in ${ms}ms — ${kb.toFixed(1)} KB`);
|
|
429
745
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
746
|
+
}
|
|
747
|
+
const entities = await syncEntities({
|
|
748
|
+
store,
|
|
749
|
+
client,
|
|
750
|
+
siteUrl,
|
|
751
|
+
ledger: opts.ledger,
|
|
752
|
+
sitemaps: opts.args.sitemaps !== false,
|
|
753
|
+
inspections: opts.args.inspections !== false,
|
|
754
|
+
inspectLimit: opts.inspectLimit,
|
|
755
|
+
quiet
|
|
756
|
+
});
|
|
757
|
+
await opts.ledger.flush();
|
|
758
|
+
const coverage = await readStoreCoverage({
|
|
759
|
+
store,
|
|
760
|
+
site: siteUrl,
|
|
761
|
+
inspectLimit: opts.inspectLimit,
|
|
762
|
+
run: { kind: "none" }
|
|
763
|
+
});
|
|
764
|
+
if (!quiet) {
|
|
765
|
+
for (const line of renderCoverage(coverage)) console.log(` ${line}`);
|
|
766
|
+
console.log();
|
|
767
|
+
}
|
|
768
|
+
const status = anyFailed || rollupError ? "failed" : control.stop ? "partial" : "completed";
|
|
769
|
+
return {
|
|
770
|
+
status,
|
|
771
|
+
...control.stop ? { stop: control.stop } : {},
|
|
772
|
+
report: await report(status, totals, {
|
|
773
|
+
...control.stop ? { stopped: control.stop } : {},
|
|
774
|
+
...rollupError ? { rollupError } : {},
|
|
775
|
+
...entities,
|
|
776
|
+
coverage
|
|
777
|
+
})
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
function failureReason(error) {
|
|
781
|
+
return error instanceof Error ? error.message : String(error);
|
|
782
|
+
}
|
|
783
|
+
function clockTime(at) {
|
|
784
|
+
return new Date(at).toLocaleString("en-US", {
|
|
785
|
+
hour: "numeric",
|
|
786
|
+
minute: "2-digit",
|
|
787
|
+
month: "short",
|
|
788
|
+
day: "numeric"
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
async function syncEntities(opts) {
|
|
792
|
+
const { store, client, siteUrl, quiet } = opts;
|
|
793
|
+
const ctx = {
|
|
794
|
+
userId: store.userId,
|
|
795
|
+
siteId: store.siteIdFor(siteUrl)
|
|
796
|
+
};
|
|
797
|
+
const now = () => /* @__PURE__ */ new Date();
|
|
798
|
+
let sitemaps = { _tag: "disabled" };
|
|
799
|
+
if (opts.sitemaps) {
|
|
800
|
+
if (!quiet) logger.info("Saving sitemaps…");
|
|
801
|
+
sitemaps = await syncSitemaps({
|
|
802
|
+
client,
|
|
803
|
+
dataSource: store.dataSource,
|
|
804
|
+
ctx,
|
|
805
|
+
siteUrl,
|
|
806
|
+
now,
|
|
807
|
+
generationId: randomUUID,
|
|
808
|
+
loadFeed: async (url) => {
|
|
809
|
+
const loaded = await loadSitemapUrls(url, {
|
|
810
|
+
maxUrls: 5e5,
|
|
811
|
+
maxDocuments: 1e3
|
|
812
|
+
});
|
|
813
|
+
return loaded._tag === "ok" ? {
|
|
814
|
+
_tag: "ok",
|
|
815
|
+
entries: loaded.value.entries,
|
|
816
|
+
complete: loaded.value.complete
|
|
817
|
+
} : loaded;
|
|
439
818
|
}
|
|
819
|
+
}).catch((error) => ({
|
|
820
|
+
_tag: "failed",
|
|
821
|
+
reason: failureReason(error)
|
|
822
|
+
}));
|
|
823
|
+
if (sitemaps._tag === "failed") logger.warn(`Sitemaps not saved: ${sitemaps.reason}`);
|
|
824
|
+
else if (sitemaps._tag === "list_only") logger.warn(`Saved ${sitemaps.sitemaps} sitemap(s). Kept the stored sitemap URLs: ${sitemaps.reason}`);
|
|
825
|
+
else if (!quiet) logger.success(`Saved ${sitemaps.sitemaps} sitemap(s) with ${sitemaps.urls.toLocaleString()} URL(s)`);
|
|
826
|
+
if (sitemaps._tag === "saved" || sitemaps._tag === "list_only") {
|
|
827
|
+
for (const feed of sitemaps.feeds) if (feed._tag === "unreachable") logger.warn(` ${feed.path}: ${feed.message}`);
|
|
828
|
+
else if (!feed.complete) logger.warn(` ${feed.path}: read stopped early; saved ${feed.urls.toLocaleString()} URL(s)`);
|
|
440
829
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
runSQL: (opts) => store.engine.runSQL(opts),
|
|
450
|
-
listPartitions: async ({ ctx, table, searchType }) => {
|
|
451
|
-
return (await store.engine.listLive({
|
|
452
|
-
userId: ctx.userId,
|
|
453
|
-
...ctx.siteId !== void 0 ? { siteId: ctx.siteId } : {},
|
|
454
|
-
table,
|
|
455
|
-
...searchType !== void 0 ? { searchType } : {}
|
|
456
|
-
})).map((e) => ({
|
|
457
|
-
partition: e.partition,
|
|
458
|
-
bytes: e.bytes
|
|
459
|
-
}));
|
|
460
|
-
}
|
|
461
|
-
},
|
|
830
|
+
}
|
|
831
|
+
let inspections = { _tag: "disabled" };
|
|
832
|
+
if (opts.inspections && opts.inspectLimit > 0) {
|
|
833
|
+
inspections = await (async () => {
|
|
834
|
+
const candidates = await inspectionCandidates(store, siteUrl);
|
|
835
|
+
const progress = createProgressTracker(Math.min(opts.inspectLimit, candidates.length), quiet);
|
|
836
|
+
return await syncInspections({
|
|
837
|
+
client,
|
|
462
838
|
dataSource: store.dataSource,
|
|
463
|
-
ctx
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
839
|
+
ctx,
|
|
840
|
+
siteUrl,
|
|
841
|
+
candidates,
|
|
842
|
+
limit: opts.inspectLimit,
|
|
843
|
+
concurrency: INSPECT_CONCURRENCY,
|
|
844
|
+
ledger: opts.ledger,
|
|
845
|
+
now,
|
|
846
|
+
onProgress: () => progress.tick("inspect")
|
|
847
|
+
}).finally(() => progress.done());
|
|
848
|
+
})().catch((error) => ({
|
|
849
|
+
_tag: "failed",
|
|
850
|
+
reason: failureReason(error)
|
|
851
|
+
}));
|
|
852
|
+
if (inspections._tag === "failed") logger.warn(`URL Inspection not saved: ${inspections.reason}`);
|
|
853
|
+
else if (inspections._tag === "quota_exhausted") logger.warn(`URL Inspection paused until ${clockTime(inspections.resetsAt)}: ${inspections.reason}`);
|
|
854
|
+
else if (inspections._tag === "inspected") {
|
|
855
|
+
if (!quiet) logger.success(`Inspected ${inspections.inspected} URL(s); ${inspections.deferred} due URL(s) left for later runs`);
|
|
856
|
+
if (inspections.stopped) logger.warn(`URL Inspection paused until ${clockTime(inspections.stopped.resetsAt)}: ${inspections.stopped.reason}`);
|
|
857
|
+
if (inspections.failed > 0) {
|
|
858
|
+
logger.warn(`${inspections.failed} URL Inspection call(s) failed:`);
|
|
859
|
+
for (const failure of inspections.failures.slice(0, 5)) logger.warn(` ${failure.url}: ${failure.error}`);
|
|
477
860
|
}
|
|
478
|
-
}
|
|
479
|
-
await printCompletion(anyFailed || rollupError ? "failed" : "completed", totals, void 0, rollupError);
|
|
480
|
-
if (anyFailed || rollupError) process.exit(1);
|
|
861
|
+
} else if (!quiet) logger.info(inspections.quotaLeft === 0 ? "URL Inspection quota is used up for today" : "No URLs are due for URL Inspection");
|
|
481
862
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
863
|
+
return {
|
|
864
|
+
sitemaps,
|
|
865
|
+
inspections
|
|
866
|
+
};
|
|
485
867
|
}
|
|
486
|
-
function
|
|
487
|
-
|
|
868
|
+
function gatedClient(client, gate) {
|
|
869
|
+
const query = client.searchAnalytics.query;
|
|
870
|
+
return {
|
|
871
|
+
...client,
|
|
872
|
+
searchAnalytics: {
|
|
873
|
+
...client.searchAnalytics,
|
|
874
|
+
query: async (...args) => {
|
|
875
|
+
const [siteUrl] = args;
|
|
876
|
+
if (gate.maxCalls !== void 0 && gate.calls.made >= gate.maxCalls) throw stopError({
|
|
877
|
+
kind: "budget",
|
|
878
|
+
maxCalls: gate.maxCalls
|
|
879
|
+
});
|
|
880
|
+
const decision = gate.ledger.reserve("searchAnalytics", siteUrl, 1);
|
|
881
|
+
if (decision.kind === "exhausted") throw stopError({
|
|
882
|
+
kind: "quota",
|
|
883
|
+
reason: decision.reason,
|
|
884
|
+
resetsAt: decision.resetsAt
|
|
885
|
+
});
|
|
886
|
+
gate.calls.made++;
|
|
887
|
+
return pacer(gate.pacer, () => query(...args)).catch((error) => {
|
|
888
|
+
const refusal = parseQuotaRefusal(error);
|
|
889
|
+
if (!refusal) throw error;
|
|
890
|
+
gate.ledger.record("searchAnalytics", siteUrl, {
|
|
891
|
+
kind: "refused",
|
|
892
|
+
reason: refusal
|
|
893
|
+
});
|
|
894
|
+
throw stopError({
|
|
895
|
+
kind: "quota",
|
|
896
|
+
reason: refusal,
|
|
897
|
+
resetsAt: gate.ledger.status("searchAnalytics", siteUrl).blocked?.until ?? Date.now()
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
};
|
|
488
903
|
}
|
|
489
|
-
|
|
904
|
+
function pacer(requestPacer, task) {
|
|
905
|
+
return requestPacer.run(task);
|
|
906
|
+
}
|
|
907
|
+
async function resolveStatusSite(ctx, input) {
|
|
908
|
+
const inStore = await ctx.matchSite(input, { scope: "store" });
|
|
909
|
+
if (inStore.kind === "resolved") return inStore.siteUrl;
|
|
910
|
+
const record = await readSyncRun(ctx.dataDir);
|
|
911
|
+
const inRun = record ? resolveSiteInput(input, record.sites.map((siteUrl) => ({ siteUrl }))) : void 0;
|
|
912
|
+
if (inRun?.kind === "resolved") return inRun.siteUrl;
|
|
913
|
+
throw new Error(formatSiteResolution(inStore, "store"));
|
|
914
|
+
}
|
|
915
|
+
async function printSyncStatus(resolved, siteFilter, asJson, inspectLimit) {
|
|
490
916
|
const store = createLocalStore({ dataDir: resolved.dataDir });
|
|
491
917
|
const siteId = siteFilter ? store.siteIdFor(siteFilter) : void 0;
|
|
492
|
-
const
|
|
918
|
+
const run = syncRunStatus(await readSyncRun(store.dataDir), {
|
|
919
|
+
now: Date.now(),
|
|
920
|
+
isAlive: isProcessAlive
|
|
921
|
+
});
|
|
922
|
+
const siteMap = await readSiteMap(store.dataDir, store.userId);
|
|
923
|
+
const siteLabel = (id) => id ? `@${siteUrlForId(siteMap, id)}` : "";
|
|
924
|
+
const watermarks = (await store.engine.getWatermarks({
|
|
493
925
|
userId: store.userId,
|
|
494
926
|
siteId
|
|
495
|
-
})
|
|
927
|
+
})).map((w) => ({
|
|
928
|
+
...w,
|
|
929
|
+
siteUrl: w.siteId ? siteUrlForId(siteMap, w.siteId) : null
|
|
930
|
+
}));
|
|
496
931
|
const states = await store.engine.getSyncStates({
|
|
497
932
|
userId: store.userId,
|
|
498
933
|
siteId
|
|
499
934
|
});
|
|
500
935
|
const failed = states.filter((s) => s.state === "failed");
|
|
501
936
|
const inflight = states.filter((s) => s.state === "inflight");
|
|
937
|
+
const staleInflight = run.kind === "running" ? [] : inflight;
|
|
938
|
+
const bySite = /* @__PURE__ */ new Map();
|
|
939
|
+
for (const state of states) {
|
|
940
|
+
const key = state.siteId ?? "";
|
|
941
|
+
bySite.set(key, [...bySite.get(key) ?? [], state]);
|
|
942
|
+
}
|
|
943
|
+
const latest = getLatestGscDate();
|
|
944
|
+
const gaps = [...bySite].flatMap(([id, siteStates]) => analyticsCoverage({
|
|
945
|
+
states: siteStates,
|
|
946
|
+
latest,
|
|
947
|
+
floor: getOldestGscDate(),
|
|
948
|
+
today: getPstDate()
|
|
949
|
+
}).jobs.map((job) => ({
|
|
950
|
+
siteId: id,
|
|
951
|
+
siteUrl: id ? siteUrlForId(siteMap, id) : null,
|
|
952
|
+
table: job.table,
|
|
953
|
+
searchType: job.searchType,
|
|
954
|
+
from: job.from,
|
|
955
|
+
to: job.to,
|
|
956
|
+
done: job.coverage.kind === "complete" ? job.coverage.done : job.coverage.kind === "partial" ? job.coverage.done : 0,
|
|
957
|
+
missing: job.coverage.kind === "partial" ? job.coverage.pending : 0,
|
|
958
|
+
failed: job.coverage.kind === "partial" ? job.coverage.failed : 0
|
|
959
|
+
})));
|
|
960
|
+
const coverageSites = siteFilter ? [siteFilter] : [.../* @__PURE__ */ new Set([...run.kind === "none" ? [] : run.record.sites, ...resolved.config.defaultSite ? [resolved.config.defaultSite] : []])];
|
|
961
|
+
const coverages = [];
|
|
962
|
+
for (const site of coverageSites) coverages.push(await readStoreCoverage({
|
|
963
|
+
store,
|
|
964
|
+
site,
|
|
965
|
+
inspectLimit,
|
|
966
|
+
run
|
|
967
|
+
}));
|
|
502
968
|
if (asJson) {
|
|
503
969
|
console.log(JSON.stringify({
|
|
504
970
|
dataDir: store.dataDir,
|
|
505
971
|
siteFilter: siteFilter ?? null,
|
|
972
|
+
run,
|
|
973
|
+
gaps,
|
|
974
|
+
coverage: coverages,
|
|
506
975
|
watermarks,
|
|
507
976
|
failed,
|
|
508
|
-
inflight
|
|
977
|
+
inflight,
|
|
978
|
+
staleInflight: staleInflight.length
|
|
509
979
|
}, null, 2));
|
|
510
980
|
return;
|
|
511
981
|
}
|
|
@@ -513,31 +983,39 @@ async function printSyncStatus(resolved, siteFilter, asJson) {
|
|
|
513
983
|
console.log(` \x1B[1m${displayPath(store.dataDir)}\x1B[0m`);
|
|
514
984
|
if (siteFilter) console.log(` \x1B[90mSite: ${siteFilter}\x1B[0m`);
|
|
515
985
|
console.log();
|
|
516
|
-
if (
|
|
517
|
-
|
|
986
|
+
if (run.kind === "running") console.log(` Sync running: ${run.record.done}/${run.record.planned} days${run.record.site ? ` for ${run.record.site}` : ""}, pid ${run.record.pid}, started ${formatAge(run.record.startedAt)}.`);
|
|
987
|
+
else if (run.kind === "stale") console.log(` \x1B[33mThe last sync stopped without finishing (pid ${run.record.pid}, ${run.reason === "process-gone" ? "process gone" : "no heartbeat"}). The next sync retries its dates.\x1B[0m`);
|
|
988
|
+
if (gaps.length === 0 && watermarks.length === 0) {
|
|
989
|
+
console.log(` No synced data. Run \`gscdump sync\` to fill the Store.`);
|
|
518
990
|
console.log();
|
|
519
991
|
return;
|
|
520
992
|
}
|
|
521
|
-
console.log(` \x1B[
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
console.log(` ${scope.padEnd(28)} \x1B[36m${w.oldestDateSynced}\x1B[0m → \x1B[36m${w.newestDateSynced}\x1B[0m \x1B[90m(last ${formatAge(w.lastSyncAt)})\x1B[0m`);
|
|
993
|
+
console.log(` \x1B[1mTables:\x1B[0m`);
|
|
994
|
+
for (const gap of gaps) {
|
|
995
|
+
const label = `${gap.searchType === "web" ? gap.table : `${gap.table}/${gap.searchType}`}${siteFilter ? "" : siteLabel(gap.siteId)}`;
|
|
996
|
+
const parts = [`${gap.done} done`];
|
|
997
|
+
if (gap.missing > 0) parts.push(`${gap.missing} missing`);
|
|
998
|
+
if (gap.failed > 0) parts.push(`\x1B[31m${gap.failed} failed\x1B[0m`);
|
|
999
|
+
console.log(` ${label.padEnd(34)} \x1B[36m${gap.from}\x1B[0m → \x1B[36m${gap.to}\x1B[0m ${parts.join(", ")}`);
|
|
529
1000
|
}
|
|
530
|
-
if (
|
|
1001
|
+
if (staleInflight.length > 0) {
|
|
531
1002
|
console.log();
|
|
532
|
-
console.log(` \x1B[33m${
|
|
533
|
-
for (const s of inflight) console.log(` ${s.table}${s.siteId ? `@${s.siteId}` : ""} ${s.date} (attempt ${s.attempts}, started ${formatAge(s.updatedAt)})`);
|
|
1003
|
+
console.log(` \x1B[33m${staleInflight.length} date(s) stale from a stopped sync. The next sync retries them.\x1B[0m`);
|
|
534
1004
|
}
|
|
535
1005
|
if (failed.length > 0) {
|
|
536
1006
|
console.log();
|
|
537
1007
|
console.log(` \x1B[31m${failed.length} failed:\x1B[0m`);
|
|
538
|
-
for (const s of failed) console.log(` ${s.table}${s.siteId
|
|
1008
|
+
for (const s of failed.slice(0, 20)) console.log(` ${s.table}${siteLabel(s.siteId)} ${s.date}: ${s.error ?? "unknown"}`);
|
|
1009
|
+
if (failed.length > 20) console.log(` and ${failed.length - 20} more`);
|
|
1010
|
+
}
|
|
1011
|
+
for (const coverage of coverages) {
|
|
1012
|
+
console.log();
|
|
1013
|
+
console.log(` \x1B[1m${coverage.site}\x1B[0m`);
|
|
1014
|
+
for (const line of renderCoverage(coverage)) console.log(` ${line}`);
|
|
1015
|
+
}
|
|
1016
|
+
if (coverages.length === 0) {
|
|
539
1017
|
console.log();
|
|
540
|
-
console.log(`
|
|
1018
|
+
console.log(` Pass --site to see inspection and sitemap coverage.`);
|
|
541
1019
|
}
|
|
542
1020
|
console.log();
|
|
543
1021
|
}
|