@gscdump/cli 3.8.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/route.mjs
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { probeAuth } from "./auth.mjs";
|
|
2
|
+
import { listStoreSites } from "./store-sites.mjs";
|
|
3
|
+
import { formatSiteResolution, siteArg } from "./context.mjs";
|
|
4
|
+
import { hasSyncedDays, syncStateCoverageReader } from "./coverage.mjs";
|
|
5
|
+
import { isProcessAlive, readSyncRun, syncRunStatus } from "./sync-run.mjs";
|
|
6
|
+
import { addDays } from "gscdump/dates";
|
|
7
|
+
import { buildCoveragePlan, gapsForRange } from "@gscdump/engine/analysis-range";
|
|
8
|
+
const CONNECT_COMMAND = "gscdump init";
|
|
9
|
+
const LOGIN_COMMAND = "gscdump auth login";
|
|
10
|
+
function isCovered(need) {
|
|
11
|
+
return need.kind === "window" ? need.gaps.length === 0 : need.stored;
|
|
12
|
+
}
|
|
13
|
+
function datesOf(spans) {
|
|
14
|
+
const dates = /* @__PURE__ */ new Set();
|
|
15
|
+
for (const span of spans) for (let date = span.start; date <= span.end; date = addDays(date, 1)) dates.add(date);
|
|
16
|
+
return dates;
|
|
17
|
+
}
|
|
18
|
+
function syncCommandFor(site, coverage) {
|
|
19
|
+
const target = site ? siteArg(site) : "example.com";
|
|
20
|
+
const missing = coverage.filter((need) => !isCovered(need));
|
|
21
|
+
const gaps = missing.flatMap((need) => need.kind === "window" ? need.gaps : []);
|
|
22
|
+
const tables = [...new Set(missing.flatMap((need) => need.kind === "window" ? [need.table] : need.tables))];
|
|
23
|
+
const types = [...new Set(missing.flatMap((need) => need.searchType && need.searchType !== "web" ? [need.searchType] : []))];
|
|
24
|
+
const parts = [
|
|
25
|
+
"gscdump",
|
|
26
|
+
"sync",
|
|
27
|
+
"--site",
|
|
28
|
+
target
|
|
29
|
+
];
|
|
30
|
+
if (gaps.length > 0) {
|
|
31
|
+
parts.push("--start", gaps.reduce((min, gap) => gap.start < min ? gap.start : min, gaps[0].start));
|
|
32
|
+
parts.push("--end", gaps.reduce((max, gap) => gap.end > max ? gap.end : max, gaps[0].end));
|
|
33
|
+
}
|
|
34
|
+
if (tables.length > 0) parts.push("--tables", tables.join(","));
|
|
35
|
+
if (types.length > 0) parts.push("--types", types.join(","));
|
|
36
|
+
return parts.map(quote).join(" ");
|
|
37
|
+
}
|
|
38
|
+
function quote(part) {
|
|
39
|
+
return /^[\w:./=,@~-]+$/.test(part) ? part : `'${part.replaceAll("'", "'\\''")}'`;
|
|
40
|
+
}
|
|
41
|
+
function liveCommand(req) {
|
|
42
|
+
return [
|
|
43
|
+
"gscdump",
|
|
44
|
+
...req.argv ?? req.label.split(" "),
|
|
45
|
+
"--live"
|
|
46
|
+
].map(quote).join(" ");
|
|
47
|
+
}
|
|
48
|
+
function runsForSite(syncRun, site) {
|
|
49
|
+
return syncRun.kind === "running" && (site === void 0 || syncRun.record.sites.includes(site));
|
|
50
|
+
}
|
|
51
|
+
function windowGaps(coverage) {
|
|
52
|
+
const seen = /* @__PURE__ */ new Set();
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const need of coverage) {
|
|
55
|
+
if (need.kind !== "window" || need.gaps.length === 0) continue;
|
|
56
|
+
const key = `${need.period}|${need.table}|${need.searchType}|${need.window.start}|${need.window.end}`;
|
|
57
|
+
if (seen.has(key)) continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
out.push({
|
|
60
|
+
period: need.period,
|
|
61
|
+
table: need.table,
|
|
62
|
+
window: need.window,
|
|
63
|
+
missing: need.gaps
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function windowGapLine(gap) {
|
|
69
|
+
const expected = datesOf([gap.window]).size;
|
|
70
|
+
const missing = datesOf(gap.missing).size;
|
|
71
|
+
const first = gap.missing[0].start;
|
|
72
|
+
const last = gap.missing.at(-1).end;
|
|
73
|
+
return ` ${gap.period} window ${gap.window.start} to ${gap.window.end}: ${gap.table} misses ${missing} of ${expected} days (${first} to ${last}).`;
|
|
74
|
+
}
|
|
75
|
+
function decideRoute(req, state) {
|
|
76
|
+
const { auth, coverage, syncRun } = state;
|
|
77
|
+
const stored = coverage.some((need) => need.stored);
|
|
78
|
+
const covered = coverage.length > 0 && coverage.every(isCovered);
|
|
79
|
+
const connected = auth !== "none";
|
|
80
|
+
const syncCommand = syncCommandFor(req.site ?? req.siteHint, coverage);
|
|
81
|
+
const tables = [...new Set(coverage.flatMap((need) => need.kind === "window" ? [need.table] : need.tables))];
|
|
82
|
+
const notConnected = {
|
|
83
|
+
kind: "prompt",
|
|
84
|
+
reason: {
|
|
85
|
+
kind: "not-connected",
|
|
86
|
+
tables
|
|
87
|
+
},
|
|
88
|
+
nextCommand: CONNECT_COMMAND
|
|
89
|
+
};
|
|
90
|
+
if (req.forceLive) {
|
|
91
|
+
if (!req.liveCapable) return {
|
|
92
|
+
kind: "prompt",
|
|
93
|
+
reason: { kind: "store-only" },
|
|
94
|
+
nextCommand: syncCommand
|
|
95
|
+
};
|
|
96
|
+
return connected ? {
|
|
97
|
+
kind: "live",
|
|
98
|
+
reason: "forced"
|
|
99
|
+
} : notConnected;
|
|
100
|
+
}
|
|
101
|
+
if (!req.localCapable) return connected ? {
|
|
102
|
+
kind: "prompt",
|
|
103
|
+
reason: { kind: "live-only" },
|
|
104
|
+
nextCommand: liveCommand(req)
|
|
105
|
+
} : {
|
|
106
|
+
...notConnected,
|
|
107
|
+
reason: {
|
|
108
|
+
kind: "not-connected",
|
|
109
|
+
tables: []
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
if (covered) return { kind: "local" };
|
|
113
|
+
if (runsForSite(syncRun, req.site)) return {
|
|
114
|
+
kind: "syncing",
|
|
115
|
+
done: syncRun.record.done,
|
|
116
|
+
total: syncRun.record.planned
|
|
117
|
+
};
|
|
118
|
+
if (!stored) {
|
|
119
|
+
if (!connected) return notConnected;
|
|
120
|
+
if (req.liveCapable) return {
|
|
121
|
+
kind: "live",
|
|
122
|
+
reason: "no-store-data"
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
kind: "prompt",
|
|
126
|
+
reason: {
|
|
127
|
+
kind: "no-data",
|
|
128
|
+
tables
|
|
129
|
+
},
|
|
130
|
+
nextCommand: syncCommand
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const windows = coverage.flatMap((need) => need.kind === "window" ? [need.window] : []);
|
|
134
|
+
const gaps = coverage.flatMap((need) => need.kind === "window" ? need.gaps : []);
|
|
135
|
+
const wanted = datesOf(windows);
|
|
136
|
+
const coveredDays = /* @__PURE__ */ new Set();
|
|
137
|
+
for (const need of coverage) {
|
|
138
|
+
if (need.kind !== "window") continue;
|
|
139
|
+
const missingDays = datesOf(need.gaps);
|
|
140
|
+
for (const date of datesOf([need.window])) if (!missingDays.has(date)) coveredDays.add(date);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
kind: "prompt",
|
|
144
|
+
reason: {
|
|
145
|
+
kind: "partial",
|
|
146
|
+
done: coveredDays.size,
|
|
147
|
+
total: wanted.size,
|
|
148
|
+
missing: gaps,
|
|
149
|
+
windows: windowGaps(coverage)
|
|
150
|
+
},
|
|
151
|
+
nextCommand: connected ? syncCommand : LOGIN_COMMAND
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function siteLabel(site) {
|
|
155
|
+
return site ?? "this Site";
|
|
156
|
+
}
|
|
157
|
+
function routeMessage(route, req, auth) {
|
|
158
|
+
const site = siteLabel(req.site ?? req.siteHint);
|
|
159
|
+
if (route.kind === "syncing") {
|
|
160
|
+
const live = req.liveCapable ? ", or pass --live" : "";
|
|
161
|
+
return `Sync running: ${route.done.toLocaleString("en-US")} of ${route.total.toLocaleString("en-US")} days done. Run again when it finishes${live}.`;
|
|
162
|
+
}
|
|
163
|
+
const next = route.nextCommand;
|
|
164
|
+
switch (route.reason.kind) {
|
|
165
|
+
case "not-connected": return `${route.reason.tables.length > 0 ? `The Store has no ${route.reason.tables.join(", ")} data for ${site}, and Google is not connected.` : `\`${req.label}\` needs Search Console, and Google is not connected.`} Run \`${next}\` to connect Google and sync the Site, or \`${LOGIN_COMMAND}\` to query Search Console directly.`;
|
|
166
|
+
case "no-data": return `The Store has no ${route.reason.tables.join(", ")} data for ${site}. \`${req.label}\` reads synced data only. Run \`${next}\` first.`;
|
|
167
|
+
case "live-only": return `\`${req.label}\` runs against the live Search Console API only. Run \`${next}\`.`;
|
|
168
|
+
case "store-only": return `\`${req.label}\` reads synced data only. Remove --live. If the Store has no data for ${site}, run \`${next}\` first.`;
|
|
169
|
+
case "partial": {
|
|
170
|
+
const { done, total, windows } = route.reason;
|
|
171
|
+
const head = [`The Store has ${done.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} days for ${site}.`, ...windows.map(windowGapLine)].join("\n");
|
|
172
|
+
if (auth === "none") return `${head}\nRun \`${next}\` to connect Google, then \`${syncCommandFor(req.site ?? req.siteHint, [])}\` to sync the rest.`;
|
|
173
|
+
return `${head}\nRun \`${next}\` to sync the rest${req.liveCapable ? ", or pass --live to ask Search Console directly" : ""}.`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function liveNote(site) {
|
|
178
|
+
return `No synced data for ${site}; answering from the live Search Console API.`;
|
|
179
|
+
}
|
|
180
|
+
function stopCode(route) {
|
|
181
|
+
if (route.kind === "syncing") return "SYNC_RUNNING";
|
|
182
|
+
switch (route.reason.kind) {
|
|
183
|
+
case "not-connected": return "NOT_CONNECTED";
|
|
184
|
+
case "no-data": return "NO_SYNCED_DATA";
|
|
185
|
+
case "store-only": return "STORE_ONLY";
|
|
186
|
+
case "live-only": return "LIVE_ONLY";
|
|
187
|
+
case "partial": return "STORE_RANGE_NOT_COVERED";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function routeStopError(route, message) {
|
|
191
|
+
return Object.assign(new Error(message), {
|
|
192
|
+
name: "RouteStopError",
|
|
193
|
+
routeStop: route
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
async function needCoverage(needs, states) {
|
|
197
|
+
const reader = syncStateCoverageReader(states);
|
|
198
|
+
const plans = /* @__PURE__ */ new Map();
|
|
199
|
+
const planFor = (searchType, window, tables) => {
|
|
200
|
+
const key = `${searchType}|${window.start}|${window.end}`;
|
|
201
|
+
const plan = plans.get(key) ?? buildCoveragePlan({
|
|
202
|
+
searchType,
|
|
203
|
+
requested: window,
|
|
204
|
+
tables,
|
|
205
|
+
reader
|
|
206
|
+
});
|
|
207
|
+
plans.set(key, plan);
|
|
208
|
+
return plan;
|
|
209
|
+
};
|
|
210
|
+
const windowed = needs.filter((need) => need.kind === "window");
|
|
211
|
+
const tablesFor = (need) => [...new Set(windowed.filter((other) => other.searchType === need.searchType && other.window.start === need.window.start && other.window.end === need.window.end).map((other) => other.table))];
|
|
212
|
+
return Promise.all(needs.map(async (need) => {
|
|
213
|
+
if (need.kind === "any") {
|
|
214
|
+
const stored = need.tables.some((table) => need.searchType ? hasSyncedDays(states, table, need.searchType) : states.some((state) => state.table === table && state.state === "done"));
|
|
215
|
+
return {
|
|
216
|
+
...need,
|
|
217
|
+
stored
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const table = (await planFor(need.searchType, need.window, tablesFor(need))).tables.find((entry) => entry.table === need.table);
|
|
221
|
+
return {
|
|
222
|
+
...need,
|
|
223
|
+
stored: hasSyncedDays(states, need.table, need.searchType),
|
|
224
|
+
gaps: table ? table.gaps : gapsForRange([], need.window)
|
|
225
|
+
};
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
async function readRouteState(input) {
|
|
229
|
+
const { store, site } = input;
|
|
230
|
+
const states = input.states ?? await readSiteStates(store, site);
|
|
231
|
+
const [auth, record] = await Promise.all([input.auth ?? probeAuth(), readSyncRun(store.dataDir)]);
|
|
232
|
+
return {
|
|
233
|
+
auth,
|
|
234
|
+
coverage: await needCoverage(input.needs, states),
|
|
235
|
+
syncRun: syncRunStatus(record, {
|
|
236
|
+
now: input.now ?? Date.now(),
|
|
237
|
+
isAlive: isProcessAlive
|
|
238
|
+
})
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function readSiteStates(store, site) {
|
|
242
|
+
return store.engine.getSyncStates({
|
|
243
|
+
userId: store.userId,
|
|
244
|
+
...site ? { siteId: store.siteIdFor(site) } : {}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
async function resolveReadSite(ctx, target, opts) {
|
|
248
|
+
const auth = await probeAuth();
|
|
249
|
+
const connected = auth !== "none";
|
|
250
|
+
if (opts.forceLive && connected) return {
|
|
251
|
+
site: await (await opts.connect()).resolveSite(target, { scope: "account" }),
|
|
252
|
+
auth
|
|
253
|
+
};
|
|
254
|
+
const hint = target?.trim() || ctx.config.defaultSite;
|
|
255
|
+
if (hint) {
|
|
256
|
+
const found = await ctx.matchSite(hint, { scope: "store" });
|
|
257
|
+
if (found.kind === "resolved") return {
|
|
258
|
+
site: found.siteUrl,
|
|
259
|
+
auth
|
|
260
|
+
};
|
|
261
|
+
if (found.kind !== "not-found") throw new Error(formatSiteResolution(found, "store"));
|
|
262
|
+
} else if ((await listStoreSites(ctx.dataDir)).length > 0) return {
|
|
263
|
+
site: await ctx.resolveSite(void 0, { scope: "store" }),
|
|
264
|
+
auth
|
|
265
|
+
};
|
|
266
|
+
if (!connected) return {
|
|
267
|
+
site: void 0,
|
|
268
|
+
...hint ? { siteHint: hint } : {},
|
|
269
|
+
auth
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
site: await (await opts.connect()).resolveSite(target, { scope: "account" }),
|
|
273
|
+
auth
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function describeStop(route, req, auth) {
|
|
277
|
+
const message = routeMessage(route, req, auth);
|
|
278
|
+
return {
|
|
279
|
+
code: stopCode(route),
|
|
280
|
+
message,
|
|
281
|
+
siteUrl: req.site ?? req.siteHint ?? null,
|
|
282
|
+
nextCommand: route.kind === "prompt" ? route.nextCommand : null,
|
|
283
|
+
...route.kind === "prompt" && route.reason.kind === "partial" ? { missingDates: [...datesOf(route.reason.missing)].sort() } : {},
|
|
284
|
+
...route.kind === "syncing" ? { sync: {
|
|
285
|
+
done: route.done,
|
|
286
|
+
total: route.total
|
|
287
|
+
} } : {}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function stopAtRoute(route, req, auth, opts) {
|
|
291
|
+
const details = describeStop(route, req, auth);
|
|
292
|
+
if (opts.json) console.log(JSON.stringify({ error: details }, null, 2));
|
|
293
|
+
throw routeStopError(route, details.message);
|
|
294
|
+
}
|
|
295
|
+
export { decideRoute, describeStop, liveCommand, liveNote, needCoverage, readRouteState, readSiteStates, resolveReadSite, routeMessage, routeStopError, stopAtRoute, stopCode, syncCommandFor };
|
package/dist/sitemap.mjs
CHANGED
|
@@ -41,6 +41,10 @@ async function loadSitemapUrls(url, options = {}) {
|
|
|
41
41
|
_tag: "ok",
|
|
42
42
|
value: {
|
|
43
43
|
urls: result.entries.map((entry) => entry.loc),
|
|
44
|
+
entries: result.entries.map((entry) => entry.lastmod === void 0 ? { loc: entry.loc } : {
|
|
45
|
+
loc: entry.loc,
|
|
46
|
+
lastmod: entry.lastmod
|
|
47
|
+
}),
|
|
44
48
|
complete: result._tag === "complete",
|
|
45
49
|
documentsRead: result.documentsRead
|
|
46
50
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { allTables } from "./local-store.mjs";
|
|
2
|
+
import { readSiteMap } from "./store-sites.mjs";
|
|
3
|
+
import { exportColumns, groupTableSources } from "./table-sources.mjs";
|
|
4
|
+
import { attachParquetIndex } from "@gscdump/engine/node";
|
|
5
|
+
import { SCHEMAS } from "@gscdump/engine/schema";
|
|
6
|
+
import { DuckDBInstance, DuckDBTypeId } from "@duckdb/node-api";
|
|
7
|
+
const RAW_SCHEMA = "gsc_raw";
|
|
8
|
+
const GSC_POSITION_MACRO = "CREATE OR REPLACE MACRO gsc_position(sum_position, impressions) AS SUM(sum_position) / NULLIF(SUM(impressions), 0) + 1";
|
|
9
|
+
function referencedTables(sql, tables) {
|
|
10
|
+
const words = new Set(sql.toLowerCase().match(/[a-z_][\w$]*/g) ?? []);
|
|
11
|
+
return tables.filter((table) => words.has(table));
|
|
12
|
+
}
|
|
13
|
+
function toJsonValue(value, type) {
|
|
14
|
+
if (value === null || value === void 0) return null;
|
|
15
|
+
if (typeof value === "bigint") return value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
|
|
16
|
+
if (value instanceof Date) return type?.typeId === DuckDBTypeId.DATE ? value.toISOString().slice(0, 10) : value.toISOString();
|
|
17
|
+
if (Array.isArray(value)) return value.map((item) => toJsonValue(item));
|
|
18
|
+
if (value instanceof Uint8Array) return value;
|
|
19
|
+
if (typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toJsonValue(item)]));
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function emptyViewSql(table) {
|
|
23
|
+
return `CREATE OR REPLACE VIEW main.${table} AS SELECT ${withPageAlias(table, exportColumns(table).map((column) => `NULL::${column.type} AS ${column.name}`)).join(", ")} WHERE false`;
|
|
24
|
+
}
|
|
25
|
+
function withPageAlias(table, columns) {
|
|
26
|
+
const index = SCHEMAS[table].columns.findIndex((column) => column.name === "url");
|
|
27
|
+
if (index === -1) return columns;
|
|
28
|
+
const at = index + 3;
|
|
29
|
+
return [
|
|
30
|
+
...columns.slice(0, at),
|
|
31
|
+
"url AS page",
|
|
32
|
+
...columns.slice(at)
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
async function viewColumns(connection, table) {
|
|
36
|
+
return (await connection.runAndReadAll(`DESCRIBE main.${table}`)).getRowObjectsJS().map((row) => ({
|
|
37
|
+
name: String(row.column_name),
|
|
38
|
+
type: String(row.column_type)
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
async function createViews(connection, sources) {
|
|
42
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
43
|
+
for (const source of sources) byTable.set(source.table, [...byTable.get(source.table) ?? [], source]);
|
|
44
|
+
await attachParquetIndex(async (sql) => {
|
|
45
|
+
await connection.run(sql);
|
|
46
|
+
return [];
|
|
47
|
+
}, {
|
|
48
|
+
schema: RAW_SCHEMA,
|
|
49
|
+
tables: Object.fromEntries([...byTable].map(([table, group]) => [table, group.map((source) => ({
|
|
50
|
+
urls: source.files,
|
|
51
|
+
constants: {
|
|
52
|
+
site: source.site,
|
|
53
|
+
search_type: source.searchType
|
|
54
|
+
}
|
|
55
|
+
}))]))
|
|
56
|
+
});
|
|
57
|
+
for (const table of allTables()) {
|
|
58
|
+
if (!byTable.has(table)) {
|
|
59
|
+
await connection.run(emptyViewSql(table));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const present = new Set((await connection.runAndReadAll(`DESCRIBE ${RAW_SCHEMA}.${table}`)).getRowObjectsJS().map((row) => String(row.column_name)));
|
|
63
|
+
const columns = exportColumns(table).map((column) => {
|
|
64
|
+
if (!present.has(column.name)) return `NULL::${column.type} AS ${column.name}`;
|
|
65
|
+
return column.type === "DATE" ? `CAST(${column.name} AS DATE) AS ${column.name}` : column.name;
|
|
66
|
+
});
|
|
67
|
+
await connection.run(`CREATE OR REPLACE VIEW main.${table} AS SELECT ${withPageAlias(table, columns).join(", ")} FROM ${RAW_SCHEMA}.${table}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function openSqlViews(store, scope = {}) {
|
|
71
|
+
const entries = await store.engine.listLive({
|
|
72
|
+
userId: store.userId,
|
|
73
|
+
...scope.searchType !== void 0 ? { searchType: scope.searchType } : {}
|
|
74
|
+
});
|
|
75
|
+
const siteIds = scope.siteIds ? new Set(scope.siteIds) : void 0;
|
|
76
|
+
const siteMap = await readSiteMap(store.dataDir, store.userId);
|
|
77
|
+
const sources = groupTableSources(entries.filter((entry) => !siteIds || entry.siteId !== void 0 && siteIds.has(entry.siteId)), store.dataDir, siteMap);
|
|
78
|
+
const instance = await DuckDBInstance.create(":memory:");
|
|
79
|
+
const connection = await instance.connect();
|
|
80
|
+
const close = () => {
|
|
81
|
+
connection.closeSync();
|
|
82
|
+
instance.closeSync();
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
await createViews(connection, sources);
|
|
86
|
+
await connection.run(GSC_POSITION_MACRO);
|
|
87
|
+
const views = [];
|
|
88
|
+
for (const table of allTables()) views.push({
|
|
89
|
+
table,
|
|
90
|
+
columns: await viewColumns(connection, table),
|
|
91
|
+
sources: sources.filter((source) => source.table === table).map((source) => ({
|
|
92
|
+
site: source.site,
|
|
93
|
+
searchType: source.searchType,
|
|
94
|
+
files: source.files.length,
|
|
95
|
+
rows: source.rows
|
|
96
|
+
}))
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
views,
|
|
100
|
+
emptyTables: views.filter((view) => view.sources.length === 0).map((view) => view.table),
|
|
101
|
+
async run(sql) {
|
|
102
|
+
const reader = await connection.runAndReadAll(sql);
|
|
103
|
+
const columns = reader.columnNames();
|
|
104
|
+
const types = reader.columnTypes();
|
|
105
|
+
return {
|
|
106
|
+
columns,
|
|
107
|
+
rows: reader.getRowsJS().map((values) => Object.fromEntries(columns.map((name, index) => [name, toJsonValue(values[index], types[index])])))
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
close
|
|
111
|
+
};
|
|
112
|
+
} catch (error) {
|
|
113
|
+
close();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export { GSC_POSITION_MACRO, openSqlViews, referencedTables, toJsonValue };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { err, ok } from "gscdump/result";
|
|
7
|
+
import { decodeSiteId, encodeSiteId } from "gscdump/tenant";
|
|
8
|
+
import { lock } from "proper-lockfile";
|
|
9
|
+
const siteMapSchema = z.object({
|
|
10
|
+
version: z.literal(1),
|
|
11
|
+
sites: z.record(z.string(), z.string())
|
|
12
|
+
});
|
|
13
|
+
const SITE_ID_DIR_RE = /^[dh]_[\w.-]+$/;
|
|
14
|
+
const DECODED_ORIGIN_RE = /^https:\/\/[a-z0-9.-]+\/$/i;
|
|
15
|
+
function tenantDir(dataDir, userId) {
|
|
16
|
+
return path.join(dataDir, `u_${userId}`);
|
|
17
|
+
}
|
|
18
|
+
function siteMapPath(dataDir, userId) {
|
|
19
|
+
return path.join(tenantDir(dataDir, userId), "sites.json");
|
|
20
|
+
}
|
|
21
|
+
function ignoreMissing(fallback) {
|
|
22
|
+
return (error) => {
|
|
23
|
+
if (error.code === "ENOENT") return fallback;
|
|
24
|
+
throw error;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async function readSiteMap(dataDir, userId = "local") {
|
|
28
|
+
const file = siteMapPath(dataDir, userId);
|
|
29
|
+
const body = await fs.readFile(file, "utf8").catch(ignoreMissing(null));
|
|
30
|
+
if (body === null) return {};
|
|
31
|
+
const parsed = siteMapSchema.safeParse(await Promise.resolve().then(() => JSON.parse(body)).catch(() => void 0));
|
|
32
|
+
if (!parsed.success) throw new Error(`The Site map at ${file} is invalid. Delete it, then run gscdump sync again.`);
|
|
33
|
+
return parsed.data.sites;
|
|
34
|
+
}
|
|
35
|
+
async function listSiteIds(dataDir, userId) {
|
|
36
|
+
return (await fs.readdir(tenantDir(dataDir, userId), { withFileTypes: true }).catch(ignoreMissing([]))).filter((entry) => entry.isDirectory() && SITE_ID_DIR_RE.test(entry.name)).map((entry) => entry.name);
|
|
37
|
+
}
|
|
38
|
+
async function listStoreSites(dataDir, userId = "local") {
|
|
39
|
+
const [map, siteIds] = await Promise.all([readSiteMap(dataDir, userId), listSiteIds(dataDir, userId)]);
|
|
40
|
+
return siteIds.map((siteId) => ({
|
|
41
|
+
siteId,
|
|
42
|
+
siteUrl: map[siteId] ?? decodeSiteId(siteId)
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
function siteUrlForId(map, siteId) {
|
|
46
|
+
return map[siteId] ?? decodeSiteId(siteId);
|
|
47
|
+
}
|
|
48
|
+
function claimSiteId(map, siteUrl, hasData) {
|
|
49
|
+
const siteId = encodeSiteId(siteUrl);
|
|
50
|
+
const existing = map[siteId];
|
|
51
|
+
if (existing === void 0 && hasData) {
|
|
52
|
+
const decoded = decodeSiteId(siteId);
|
|
53
|
+
if (DECODED_ORIGIN_RE.test(decoded) && decoded !== siteUrl) return err({
|
|
54
|
+
kind: "site-id-collision",
|
|
55
|
+
siteId,
|
|
56
|
+
siteUrl,
|
|
57
|
+
existing: decoded
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (existing !== void 0 && existing !== siteUrl) return err({
|
|
61
|
+
kind: "site-id-collision",
|
|
62
|
+
siteId,
|
|
63
|
+
siteUrl,
|
|
64
|
+
existing
|
|
65
|
+
});
|
|
66
|
+
return ok(existing === siteUrl ? map : {
|
|
67
|
+
...map,
|
|
68
|
+
[siteId]: siteUrl
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async function writeSiteMap(dataDir, userId, sites) {
|
|
72
|
+
const file = siteMapPath(dataDir, userId);
|
|
73
|
+
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
74
|
+
await fs.writeFile(temp, `${JSON.stringify({
|
|
75
|
+
version: 1,
|
|
76
|
+
sites
|
|
77
|
+
}, null, 2)}\n`);
|
|
78
|
+
await fs.rename(temp, file);
|
|
79
|
+
}
|
|
80
|
+
const siteMapQueues = /* @__PURE__ */ new Map();
|
|
81
|
+
async function lockSiteMapFile(file, fn) {
|
|
82
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
83
|
+
const release = await lock(file, {
|
|
84
|
+
realpath: false,
|
|
85
|
+
stale: 3e4,
|
|
86
|
+
retries: {
|
|
87
|
+
retries: 40,
|
|
88
|
+
minTimeout: 25,
|
|
89
|
+
maxTimeout: 500,
|
|
90
|
+
factor: 1.5
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return await fn().finally(() => release().catch((error) => {
|
|
94
|
+
console.warn(`[gscdump] Could not release the Site map lock for ${file}.`, error);
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
async function withSiteMapLock(dataDir, userId, fn) {
|
|
98
|
+
const file = siteMapPath(dataDir, userId);
|
|
99
|
+
const run = (siteMapQueues.get(file) ?? Promise.resolve()).then(() => lockSiteMapFile(file, fn));
|
|
100
|
+
const tail = run.then(() => void 0, () => {});
|
|
101
|
+
siteMapQueues.set(file, tail);
|
|
102
|
+
tail.then(() => {
|
|
103
|
+
if (siteMapQueues.get(file) === tail) siteMapQueues.delete(file);
|
|
104
|
+
});
|
|
105
|
+
return await run;
|
|
106
|
+
}
|
|
107
|
+
async function recordStoreSite(dataDir, siteUrl, options = {}) {
|
|
108
|
+
const { userId = "local", write = true } = options;
|
|
109
|
+
const claim = async () => {
|
|
110
|
+
const [map, siteIds] = await Promise.all([readSiteMap(dataDir, userId), listSiteIds(dataDir, userId)]);
|
|
111
|
+
const claimed = claimSiteId(map, siteUrl, siteIds.includes(encodeSiteId(siteUrl)));
|
|
112
|
+
if (claimed.ok && write && claimed.value !== map) await writeSiteMap(dataDir, userId, claimed.value);
|
|
113
|
+
return claimed;
|
|
114
|
+
};
|
|
115
|
+
const result = write ? await withSiteMapLock(dataDir, userId, claim) : await claim();
|
|
116
|
+
return result.ok ? ok(void 0) : result;
|
|
117
|
+
}
|
|
118
|
+
async function removeStoreSite(dataDir, siteId, userId = "local") {
|
|
119
|
+
if (!SITE_ID_DIR_RE.test(siteId)) throw new Error(`Refusing to remove "${siteId}": it is not a Site ID.`);
|
|
120
|
+
await withSiteMapLock(dataDir, userId, async () => {
|
|
121
|
+
await fs.rm(path.join(tenantDir(dataDir, userId), siteId), {
|
|
122
|
+
recursive: true,
|
|
123
|
+
force: true
|
|
124
|
+
});
|
|
125
|
+
const map = await readSiteMap(dataDir, userId);
|
|
126
|
+
if (!(siteId in map)) return;
|
|
127
|
+
const { [siteId]: _removed, ...rest } = map;
|
|
128
|
+
await writeSiteMap(dataDir, userId, rest);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function formatSiteIdCollision(collision) {
|
|
132
|
+
return `The Store keeps ${collision.existing} under the same ID as ${collision.siteUrl}. The two Sites cannot share one Store. If you no longer need the data for ${collision.existing}, run \`gscdump store rm-site ${collision.existing}\`, then sync again.`;
|
|
133
|
+
}
|
|
134
|
+
export { claimSiteId, formatSiteIdCollision, listStoreSites, readSiteMap, recordStoreSite, removeStoreSite, siteUrlForId };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { searchTypeSupportsDimensions, searchTypeSupportsQueries } from "@gscdump/contracts";
|
|
2
|
+
import { addDays, getDateRange } from "gscdump/dates";
|
|
3
|
+
const TABLE_NEEDS = {
|
|
4
|
+
pages: {
|
|
5
|
+
queries: false,
|
|
6
|
+
dimensions: false
|
|
7
|
+
},
|
|
8
|
+
queries: {
|
|
9
|
+
queries: true,
|
|
10
|
+
dimensions: false
|
|
11
|
+
},
|
|
12
|
+
countries: {
|
|
13
|
+
queries: false,
|
|
14
|
+
dimensions: true
|
|
15
|
+
},
|
|
16
|
+
dates: {
|
|
17
|
+
queries: true,
|
|
18
|
+
dimensions: true
|
|
19
|
+
},
|
|
20
|
+
page_queries: {
|
|
21
|
+
queries: true,
|
|
22
|
+
dimensions: false
|
|
23
|
+
},
|
|
24
|
+
search_appearance: {
|
|
25
|
+
queries: false,
|
|
26
|
+
dimensions: true
|
|
27
|
+
},
|
|
28
|
+
search_appearance_pages: {
|
|
29
|
+
queries: false,
|
|
30
|
+
dimensions: true
|
|
31
|
+
},
|
|
32
|
+
search_appearance_queries: {
|
|
33
|
+
queries: true,
|
|
34
|
+
dimensions: true
|
|
35
|
+
},
|
|
36
|
+
search_appearance_page_queries: {
|
|
37
|
+
queries: true,
|
|
38
|
+
dimensions: true
|
|
39
|
+
},
|
|
40
|
+
hourly_pages: {
|
|
41
|
+
queries: false,
|
|
42
|
+
dimensions: false
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
function tableSupportsType(table, type) {
|
|
46
|
+
const needs = TABLE_NEEDS[table];
|
|
47
|
+
return (!needs.queries || searchTypeSupportsQueries(type)) && (!needs.dimensions || searchTypeSupportsDimensions(type));
|
|
48
|
+
}
|
|
49
|
+
function planSyncJobs(tables, types) {
|
|
50
|
+
const jobs = [];
|
|
51
|
+
const unsupported = [];
|
|
52
|
+
for (const table of tables) for (const type of types) {
|
|
53
|
+
const job = {
|
|
54
|
+
table,
|
|
55
|
+
type,
|
|
56
|
+
label: type === "web" ? table : `${table}/${type}`
|
|
57
|
+
};
|
|
58
|
+
if (tableSupportsType(table, type)) jobs.push(job);
|
|
59
|
+
else unsupported.push(job);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
jobs,
|
|
63
|
+
unsupported
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function datesForJob(table, dates, today) {
|
|
67
|
+
if (table !== "hourly_pages") return [...dates];
|
|
68
|
+
const cutoff = (/* @__PURE__ */ new Date(Date.parse(`${today}T00:00:00Z`) - 864e6)).toISOString().slice(0, 10);
|
|
69
|
+
return dates.filter((date) => date >= cutoff);
|
|
70
|
+
}
|
|
71
|
+
function jobWindowStart(window, states) {
|
|
72
|
+
if (window.kind === "range") return window.start;
|
|
73
|
+
let oldest;
|
|
74
|
+
for (const state of states) if (state.date >= window.floor && (oldest === void 0 || state.date < oldest)) oldest = state.date;
|
|
75
|
+
const start = oldest !== void 0 && oldest < window.firstStart ? oldest : window.firstStart;
|
|
76
|
+
return start < window.floor ? window.floor : start;
|
|
77
|
+
}
|
|
78
|
+
function planJobDates(input) {
|
|
79
|
+
const window = datesForJob(input.table, getDateRange(jobWindowStart(input.window, input.states), input.window.end), input.today);
|
|
80
|
+
const stateByDate = new Map(input.states.map((state) => [state.date, state.state]));
|
|
81
|
+
let skippedDone = 0;
|
|
82
|
+
return {
|
|
83
|
+
dates: window.filter((date) => {
|
|
84
|
+
const state = stateByDate.get(date);
|
|
85
|
+
if (input.mode === "force") return true;
|
|
86
|
+
if (input.mode === "retry-failed") return state === "failed";
|
|
87
|
+
if (state === "done") {
|
|
88
|
+
skippedDone++;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}).reverse(),
|
|
93
|
+
skippedDone
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function fullHistoryStart(oldestGscDate) {
|
|
97
|
+
return addDays(oldestGscDate, -14);
|
|
98
|
+
}
|
|
99
|
+
function minimumCallsPerDate(table) {
|
|
100
|
+
if (table === "dates") return 3;
|
|
101
|
+
if (table === "search_appearance" || table === "hourly_pages") return 2;
|
|
102
|
+
if (table.startsWith("search_appearance_")) return 2;
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
function resolveWindow(input) {
|
|
106
|
+
const end = input.end ?? input.latest;
|
|
107
|
+
if (input.start) return {
|
|
108
|
+
kind: "range",
|
|
109
|
+
start: input.start,
|
|
110
|
+
end
|
|
111
|
+
};
|
|
112
|
+
if (input.full) return {
|
|
113
|
+
kind: "range",
|
|
114
|
+
start: fullHistoryStart(input.floor),
|
|
115
|
+
end
|
|
116
|
+
};
|
|
117
|
+
if (input.days !== void 0) return {
|
|
118
|
+
kind: "range",
|
|
119
|
+
start: addDays(end, -(input.days - 1)),
|
|
120
|
+
end
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
kind: "catch-up",
|
|
124
|
+
firstStart: addDays(end, -27),
|
|
125
|
+
floor: input.floor,
|
|
126
|
+
end
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export { datesForJob, fullHistoryStart, jobWindowStart, minimumCallsPerDate, planJobDates, planSyncJobs, resolveWindow, tableSupportsType };
|