@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.
Files changed (73) hide show
  1. package/README.md +113 -30
  2. package/bin/gscdump.mjs +21 -1
  3. package/dist/analysis-local.mjs +172 -53
  4. package/dist/auth-state.mjs +21 -6
  5. package/dist/auth.mjs +27 -18
  6. package/dist/bing-data.mjs +6 -2
  7. package/dist/bing-hosted.mjs +1 -1
  8. package/dist/cli-args.mjs +8 -0
  9. package/dist/cli.d.mts +5 -1
  10. package/dist/cli.mjs +41 -10
  11. package/dist/cloud-google.mjs +8 -1
  12. package/dist/command-meta.mjs +4 -4
  13. package/dist/command-registry.mjs +86 -10
  14. package/dist/commands/analyze.mjs +84 -43
  15. package/dist/commands/auth.mjs +46 -37
  16. package/dist/commands/bing.mjs +3 -3
  17. package/dist/commands/compact.mjs +12 -8
  18. package/dist/commands/config.mjs +14 -14
  19. package/dist/commands/doctor.mjs +26 -26
  20. package/dist/commands/dump.mjs +203 -105
  21. package/dist/commands/entities.mjs +10 -113
  22. package/dist/commands/gc.mjs +4 -3
  23. package/dist/commands/indexing-urls.mjs +185 -0
  24. package/dist/commands/indexing.mjs +12 -8
  25. package/dist/commands/init.mjs +77 -18
  26. package/dist/commands/inspect.mjs +97 -110
  27. package/dist/commands/mcp.mjs +37 -57
  28. package/dist/commands/papercut.mjs +1 -1
  29. package/dist/commands/profile.mjs +9 -17
  30. package/dist/commands/query.mjs +219 -210
  31. package/dist/commands/report.mjs +68 -85
  32. package/dist/commands/rollups.mjs +9 -6
  33. package/dist/commands/sitemaps.mjs +51 -104
  34. package/dist/commands/sites.mjs +40 -24
  35. package/dist/commands/stats.mjs +14 -17
  36. package/dist/commands/store-purge.mjs +9 -5
  37. package/dist/commands/store.mjs +0 -12
  38. package/dist/commands/sync.mjs +774 -296
  39. package/dist/config.mjs +7 -4
  40. package/dist/context.mjs +51 -21
  41. package/dist/coverage.mjs +251 -0
  42. package/dist/dump-bing.mjs +71 -0
  43. package/dist/dump-writers.mjs +246 -0
  44. package/dist/error-handler.mjs +108 -38
  45. package/dist/filters.mjs +125 -0
  46. package/dist/hosted-site.mjs +91 -0
  47. package/dist/inspect-urls.mjs +76 -0
  48. package/dist/inspection-record.mjs +93 -0
  49. package/dist/local-entities.mjs +658 -0
  50. package/dist/mcp/errors.mjs +71 -18
  51. package/dist/mcp/handlers/diagnostics.mjs +15 -12
  52. package/dist/mcp/handlers/reports.mjs +33 -42
  53. package/dist/mcp/server/index.mjs +80 -38
  54. package/dist/mcp/types.mjs +7 -7
  55. package/dist/package.mjs +1 -1
  56. package/dist/quota-ledger.mjs +370 -0
  57. package/dist/render/analysis.mjs +8 -1
  58. package/dist/render/report.mjs +8 -1
  59. package/dist/request-pacer.mjs +29 -0
  60. package/dist/route.mjs +295 -0
  61. package/dist/sitemap.mjs +4 -0
  62. package/dist/sql-views.mjs +117 -0
  63. package/dist/store-sites.mjs +134 -0
  64. package/dist/sync-plan.mjs +129 -0
  65. package/dist/sync-run.mjs +96 -0
  66. package/dist/table-sources.mjs +57 -0
  67. package/dist/token-info.mjs +54 -0
  68. package/dist/utils.mjs +28 -19
  69. package/dist/window.mjs +159 -0
  70. package/package.json +17 -14
  71. package/skills/gscdump/SKILL.md +167 -37
  72. package/dist/commands/export.mjs +0 -76
  73. 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 };