@gscdump/engine 1.4.10 → 1.5.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/dist/entities/empty-types.d.mts +22 -0
- package/dist/entities/empty-types.mjs +58 -0
- package/dist/entities/indexing-metadata.d.mts +26 -0
- package/dist/entities/indexing-metadata.mjs +31 -0
- package/dist/entities/inspection.d.mts +240 -0
- package/dist/entities/inspection.mjs +443 -0
- package/dist/entities/io.mjs +16 -0
- package/dist/{query-dim.d.mts → entities/query-dim.d.mts} +3 -3
- package/dist/{query-dim.mjs → entities/query-dim.mjs} +4 -4
- package/dist/{sitemap-projection.mjs → entities/sitemap-projection.mjs} +1 -1
- package/dist/entities/sitemap-shared.d.mts +199 -0
- package/dist/entities/sitemap-shared.mjs +243 -0
- package/dist/entities/sitemap-write.d.mts +3 -0
- package/dist/entities/sitemap-write.mjs +528 -0
- package/dist/entities/sitemap.d.mts +3 -0
- package/dist/entities/sitemap.mjs +91 -0
- package/dist/entities.d.mts +9 -481
- package/dist/entities.mjs +7 -1380
- package/dist/rollups/canonical.d.mts +71 -0
- package/dist/rollups/canonical.mjs +335 -0
- package/dist/rollups/core.d.mts +201 -0
- package/dist/rollups/core.mjs +116 -0
- package/dist/rollups/dates.mjs +11 -0
- package/dist/rollups/defaults.d.mts +11 -0
- package/dist/rollups/defaults.mjs +17 -0
- package/dist/rollups/hourly.d.mts +38 -0
- package/dist/rollups/hourly.mjs +38 -0
- package/dist/rollups/indexing.d.mts +46 -0
- package/dist/rollups/indexing.mjs +357 -0
- package/dist/rollups/traffic.d.mts +38 -0
- package/dist/rollups/traffic.mjs +289 -0
- package/dist/rollups/windows.d.mts +90 -0
- package/dist/rollups/windows.mjs +176 -0
- package/dist/rollups.d.mts +8 -471
- package/dist/rollups.mjs +7 -1316
- package/package.json +4 -4
- /package/dist/{sitemap-projection.d.mts → entities/sitemap-projection.d.mts} +0 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { inspectionBaseKey, inspectionEventKey, inspectionEventsPrefix, inspectionHistoryPrefix, inspectionHistoryShardKey, inspectionParquetKey, inspectionTransitionsMonthKey } from "../entity-keys.mjs";
|
|
2
|
+
import { readOptional } from "../adapters/read-optional.mjs";
|
|
3
|
+
import { decodeParquetToRows, encodeRowsToParquetFlex } from "../adapters/hyparquet.mjs";
|
|
4
|
+
import { mapEntityIo } from "./io.mjs";
|
|
5
|
+
import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
|
|
6
|
+
import { GSCDUMP_INDEXING_TRANSITION_FIELDS } from "@gscdump/contracts";
|
|
7
|
+
import { classifyCanonicalDifference } from "gscdump";
|
|
8
|
+
const YEAR_MONTH_RE = /^(\d{4})-(\d{2})-/;
|
|
9
|
+
const INSPECTION_EVENT_KEY_RE = /\/inspections\/events\/\d{4}-\d{2}\/[^/]+\.parquet$/;
|
|
10
|
+
const INSPECTION_HISTORY_MAX_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
const INSPECTION_PARQUET_COLUMNS = [
|
|
12
|
+
{
|
|
13
|
+
name: "urlHash",
|
|
14
|
+
type: "VARCHAR",
|
|
15
|
+
nullable: false
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "url",
|
|
19
|
+
type: "VARCHAR",
|
|
20
|
+
nullable: false
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "inspectedAt",
|
|
24
|
+
type: "VARCHAR",
|
|
25
|
+
nullable: false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "indexStatus",
|
|
29
|
+
type: "VARCHAR",
|
|
30
|
+
nullable: true
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "lastCrawlTime",
|
|
34
|
+
type: "VARCHAR",
|
|
35
|
+
nullable: true
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "googleCanonical",
|
|
39
|
+
type: "VARCHAR",
|
|
40
|
+
nullable: true
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "userCanonical",
|
|
44
|
+
type: "VARCHAR",
|
|
45
|
+
nullable: true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "coverageState",
|
|
49
|
+
type: "VARCHAR",
|
|
50
|
+
nullable: true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "robotsTxtState",
|
|
54
|
+
type: "VARCHAR",
|
|
55
|
+
nullable: true
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "indexingState",
|
|
59
|
+
type: "VARCHAR",
|
|
60
|
+
nullable: true
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "pageFetchState",
|
|
64
|
+
type: "VARCHAR",
|
|
65
|
+
nullable: true
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "mobileUsabilityVerdict",
|
|
69
|
+
type: "VARCHAR",
|
|
70
|
+
nullable: true
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "richResultsVerdict",
|
|
74
|
+
type: "VARCHAR",
|
|
75
|
+
nullable: true
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "scheduleNextAt",
|
|
79
|
+
type: "BIGINT",
|
|
80
|
+
nullable: true
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "scheduleConsecutiveUnchanged",
|
|
84
|
+
type: "INTEGER",
|
|
85
|
+
nullable: true
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "schedulePolicyVersion",
|
|
89
|
+
type: "INTEGER",
|
|
90
|
+
nullable: true
|
|
91
|
+
}
|
|
92
|
+
];
|
|
93
|
+
const INSPECTION_EVENT_COLUMNS = [
|
|
94
|
+
...INSPECTION_PARQUET_COLUMNS,
|
|
95
|
+
{
|
|
96
|
+
name: "canonicalMismatchKind",
|
|
97
|
+
type: "VARCHAR",
|
|
98
|
+
nullable: false
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "crawlingUserAgent",
|
|
102
|
+
type: "VARCHAR",
|
|
103
|
+
nullable: true
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "richResultsItems",
|
|
107
|
+
type: "VARCHAR",
|
|
108
|
+
nullable: true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "sitemaps",
|
|
112
|
+
type: "VARCHAR",
|
|
113
|
+
nullable: true
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "referringUrls",
|
|
117
|
+
type: "VARCHAR",
|
|
118
|
+
nullable: true
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "mobileIssues",
|
|
122
|
+
type: "VARCHAR",
|
|
123
|
+
nullable: true
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "inspectionResultLink",
|
|
127
|
+
type: "VARCHAR",
|
|
128
|
+
nullable: true
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "firstCheckedAt",
|
|
132
|
+
type: "VARCHAR",
|
|
133
|
+
nullable: true
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "checkCount",
|
|
137
|
+
type: "INTEGER",
|
|
138
|
+
nullable: true
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "nextCheckAfter",
|
|
142
|
+
type: "BIGINT",
|
|
143
|
+
nullable: true
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "nextCheckPriority",
|
|
147
|
+
type: "VARCHAR",
|
|
148
|
+
nullable: true
|
|
149
|
+
}
|
|
150
|
+
];
|
|
151
|
+
const CANONICAL_DIFFERENCE_KINDS = /* @__PURE__ */ new Set([
|
|
152
|
+
"none",
|
|
153
|
+
"formatting",
|
|
154
|
+
"path",
|
|
155
|
+
"cross_domain"
|
|
156
|
+
]);
|
|
157
|
+
function populateCanonicalMismatchKind(row) {
|
|
158
|
+
const current = row.canonicalMismatchKind;
|
|
159
|
+
const expected = classifyCanonicalDifference(typeof row.userCanonical === "string" ? row.userCanonical : null, typeof row.googleCanonical === "string" ? row.googleCanonical : null);
|
|
160
|
+
const missingOrInvalid = typeof current !== "string" || !CANONICAL_DIFFERENCE_KINDS.has(current);
|
|
161
|
+
row.canonicalMismatchKind = expected;
|
|
162
|
+
return missingOrInvalid || current !== expected;
|
|
163
|
+
}
|
|
164
|
+
const INSPECTION_TRANSITION_COLUMNS = [
|
|
165
|
+
{
|
|
166
|
+
name: "urlHash",
|
|
167
|
+
type: "VARCHAR",
|
|
168
|
+
nullable: false
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "url",
|
|
172
|
+
type: "VARCHAR",
|
|
173
|
+
nullable: false
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "changedAfter",
|
|
177
|
+
type: "VARCHAR",
|
|
178
|
+
nullable: false
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "changedBefore",
|
|
182
|
+
type: "VARCHAR",
|
|
183
|
+
nullable: false
|
|
184
|
+
},
|
|
185
|
+
...GSCDUMP_INDEXING_TRANSITION_FIELDS.flatMap((field) => {
|
|
186
|
+
const capped = field[0].toUpperCase() + field.slice(1);
|
|
187
|
+
return [{
|
|
188
|
+
name: `from${capped}`,
|
|
189
|
+
type: "VARCHAR",
|
|
190
|
+
nullable: true
|
|
191
|
+
}, {
|
|
192
|
+
name: `to${capped}`,
|
|
193
|
+
type: "VARCHAR",
|
|
194
|
+
nullable: true
|
|
195
|
+
}];
|
|
196
|
+
})
|
|
197
|
+
];
|
|
198
|
+
function isStateTransition(before, after) {
|
|
199
|
+
return GSCDUMP_INDEXING_TRANSITION_FIELDS.some((field) => (before[field] ?? null) !== (after[field] ?? null));
|
|
200
|
+
}
|
|
201
|
+
function buildTransitionRow(before, after) {
|
|
202
|
+
const row = {
|
|
203
|
+
urlHash: String(after.urlHash),
|
|
204
|
+
url: String(after.url ?? before.url ?? ""),
|
|
205
|
+
changedAfter: String(before.inspectedAt ?? ""),
|
|
206
|
+
changedBefore: String(after.inspectedAt ?? "")
|
|
207
|
+
};
|
|
208
|
+
for (const field of GSCDUMP_INDEXING_TRANSITION_FIELDS) {
|
|
209
|
+
const capped = field[0].toUpperCase() + field.slice(1);
|
|
210
|
+
row[`from${capped}`] = before[field] ?? null;
|
|
211
|
+
row[`to${capped}`] = after[field] ?? null;
|
|
212
|
+
}
|
|
213
|
+
return row;
|
|
214
|
+
}
|
|
215
|
+
async function appendTransitions(ds, ctx, rows) {
|
|
216
|
+
if (rows.length === 0) return 0;
|
|
217
|
+
const byMonth = /* @__PURE__ */ new Map();
|
|
218
|
+
for (const row of rows) {
|
|
219
|
+
const month = transitionMonth(row);
|
|
220
|
+
const bucket = byMonth.get(month);
|
|
221
|
+
if (bucket) bucket.push(row);
|
|
222
|
+
else byMonth.set(month, [row]);
|
|
223
|
+
}
|
|
224
|
+
let written = 0;
|
|
225
|
+
for (const [month, monthRows] of byMonth) {
|
|
226
|
+
const key = inspectionTransitionsMonthKey(ctx, month);
|
|
227
|
+
const existingBytes = await readOptional(ds, key);
|
|
228
|
+
const existing = existingBytes ? await decodeParquetToRows(existingBytes) : [];
|
|
229
|
+
const seen = new Set(existing.map((r) => `${String(r.urlHash)}\u0000${String(r.changedBefore)}`));
|
|
230
|
+
const fresh = monthRows.filter((r) => {
|
|
231
|
+
const id = `${String(r.urlHash)}\u0000${String(r.changedBefore)}`;
|
|
232
|
+
if (seen.has(id)) return false;
|
|
233
|
+
seen.add(id);
|
|
234
|
+
return true;
|
|
235
|
+
});
|
|
236
|
+
if (fresh.length === 0) continue;
|
|
237
|
+
const merged = [...existing, ...fresh];
|
|
238
|
+
await ds.write(key, encodeRowsToParquetFlex(merged, {
|
|
239
|
+
columns: INSPECTION_TRANSITION_COLUMNS,
|
|
240
|
+
sortKey: ["urlHash"]
|
|
241
|
+
}));
|
|
242
|
+
written += fresh.length;
|
|
243
|
+
}
|
|
244
|
+
return written;
|
|
245
|
+
}
|
|
246
|
+
function transitionMonth(row) {
|
|
247
|
+
return String(row.changedBefore ?? "").slice(0, 7) || "unknown";
|
|
248
|
+
}
|
|
249
|
+
function createInspectionStore(opts) {
|
|
250
|
+
const ds = opts.dataSource;
|
|
251
|
+
function shardFor(record) {
|
|
252
|
+
const m = YEAR_MONTH_RE.exec(record.inspectedAt);
|
|
253
|
+
return m ? `${m[1]}-${m[2]}` : "unknown";
|
|
254
|
+
}
|
|
255
|
+
function randomBatchId() {
|
|
256
|
+
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
async appendHistory(ctx, records, options) {
|
|
260
|
+
if (records.length === 0) return;
|
|
261
|
+
const batchId = options?.batchId ?? randomBatchId();
|
|
262
|
+
const byMonth = /* @__PURE__ */ new Map();
|
|
263
|
+
for (const r of records) {
|
|
264
|
+
const month = shardFor(r);
|
|
265
|
+
if (!byMonth.has(month)) byMonth.set(month, []);
|
|
266
|
+
byMonth.get(month).push(r);
|
|
267
|
+
}
|
|
268
|
+
await mapEntityIo([...byMonth].map(([yearMonth, batch]) => {
|
|
269
|
+
const bytes = encodeJsonBigintSafe({
|
|
270
|
+
version: 1,
|
|
271
|
+
records: batch
|
|
272
|
+
});
|
|
273
|
+
if (bytes.byteLength > 5242880) throw new Error(`inspection history shard exceeds ${INSPECTION_HISTORY_MAX_BYTES} bytes (got ${bytes.byteLength}); split the batch`);
|
|
274
|
+
return {
|
|
275
|
+
key: inspectionHistoryShardKey(ctx, yearMonth, batchId),
|
|
276
|
+
bytes
|
|
277
|
+
};
|
|
278
|
+
}), (shard) => ds.write(shard.key, shard.bytes));
|
|
279
|
+
},
|
|
280
|
+
async loadHistory(ctx, yearMonth) {
|
|
281
|
+
const keys = await ds.list(inspectionHistoryPrefix(ctx, yearMonth));
|
|
282
|
+
if (keys.length === 0) return void 0;
|
|
283
|
+
return {
|
|
284
|
+
version: 1,
|
|
285
|
+
records: (await mapEntityIo(keys, async (key) => {
|
|
286
|
+
const bytes = await readOptional(ds, key);
|
|
287
|
+
if (!bytes) return [];
|
|
288
|
+
return (await Promise.resolve().then(() => JSON.parse(new TextDecoder().decode(bytes))).catch((err) => {
|
|
289
|
+
console.warn("[inspection.loadHistory] failed to decode shard", {
|
|
290
|
+
key,
|
|
291
|
+
error: err.message
|
|
292
|
+
});
|
|
293
|
+
}))?.records ?? [];
|
|
294
|
+
})).flat()
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
async materialize(ctx, rowIter) {
|
|
298
|
+
const rows = Array.from(rowIter);
|
|
299
|
+
rows.sort((a, b) => a.urlHash < b.urlHash ? -1 : a.urlHash > b.urlHash ? 1 : 0);
|
|
300
|
+
const bytes = encodeRowsToParquetFlex(rows, {
|
|
301
|
+
columns: INSPECTION_PARQUET_COLUMNS,
|
|
302
|
+
sortKey: ["urlHash"]
|
|
303
|
+
});
|
|
304
|
+
const key = inspectionParquetKey(ctx);
|
|
305
|
+
await ds.write(key, bytes);
|
|
306
|
+
return {
|
|
307
|
+
key,
|
|
308
|
+
rowCount: rows.length,
|
|
309
|
+
bytes: bytes.byteLength
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
async appendInspectionEvents(ctx, rows, options) {
|
|
313
|
+
if (rows.length === 0) return {
|
|
314
|
+
keys: [],
|
|
315
|
+
rowCount: 0
|
|
316
|
+
};
|
|
317
|
+
const batchId = options?.batchId ?? randomBatchId();
|
|
318
|
+
const byMonth = /* @__PURE__ */ new Map();
|
|
319
|
+
for (const r of rows) {
|
|
320
|
+
const m = YEAR_MONTH_RE.exec(r.inspectedAt);
|
|
321
|
+
const month = m ? `${m[1]}-${m[2]}` : "unknown";
|
|
322
|
+
const bucket = byMonth.get(month) ?? [];
|
|
323
|
+
bucket.push(r);
|
|
324
|
+
byMonth.set(month, bucket);
|
|
325
|
+
}
|
|
326
|
+
const files = [...byMonth].map(([month, batch]) => {
|
|
327
|
+
const bytes = encodeRowsToParquetFlex(batch, {
|
|
328
|
+
columns: INSPECTION_EVENT_COLUMNS,
|
|
329
|
+
sortKey: ["urlHash"]
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
key: inspectionEventKey(ctx, month, batchId),
|
|
333
|
+
bytes
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
await mapEntityIo(files, (file) => ds.write(file.key, file.bytes));
|
|
337
|
+
return {
|
|
338
|
+
keys: files.map((file) => file.key),
|
|
339
|
+
rowCount: rows.length
|
|
340
|
+
};
|
|
341
|
+
},
|
|
342
|
+
async compactInspections(ctx, opts) {
|
|
343
|
+
const eventKeys = (await ds.list(`${inspectionEventsPrefix(ctx)}/`)).filter((k) => INSPECTION_EVENT_KEY_RE.test(k));
|
|
344
|
+
if (eventKeys.length === 0) return {
|
|
345
|
+
baseRowCount: 0,
|
|
346
|
+
eventsFolded: 0,
|
|
347
|
+
eventFilesDeleted: 0,
|
|
348
|
+
transitionsWritten: 0
|
|
349
|
+
};
|
|
350
|
+
const baseKey = inspectionBaseKey(ctx);
|
|
351
|
+
const baseBytes = await readOptional(ds, baseKey);
|
|
352
|
+
const baseRows = baseBytes ? await decodeParquetToRows(baseBytes) : [];
|
|
353
|
+
const latest = /* @__PURE__ */ new Map();
|
|
354
|
+
const earliestChecked = /* @__PURE__ */ new Map();
|
|
355
|
+
const transitions = [];
|
|
356
|
+
const consider = (row) => {
|
|
357
|
+
const h = String(row.urlHash);
|
|
358
|
+
const prev = latest.get(h);
|
|
359
|
+
if (!prev || String(row.inspectedAt ?? "") > String(prev.inspectedAt ?? "")) {
|
|
360
|
+
if (opts?.transitions && prev && isStateTransition(prev, row)) transitions.push(buildTransitionRow(prev, row));
|
|
361
|
+
latest.set(h, row);
|
|
362
|
+
}
|
|
363
|
+
const fc = row.firstCheckedAt;
|
|
364
|
+
if (fc != null) {
|
|
365
|
+
const fcStr = String(fc);
|
|
366
|
+
const cur = earliestChecked.get(h);
|
|
367
|
+
if (cur === void 0 || fcStr < cur) earliestChecked.set(h, fcStr);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
for (const row of baseRows) consider(row);
|
|
371
|
+
let eventsFolded = 0;
|
|
372
|
+
const consumed = [];
|
|
373
|
+
const eventRows = [];
|
|
374
|
+
const eventFiles = await mapEntityIo(eventKeys.sort(), async (key) => {
|
|
375
|
+
const bytes = await readOptional(ds, key);
|
|
376
|
+
if (!bytes) return void 0;
|
|
377
|
+
return {
|
|
378
|
+
key,
|
|
379
|
+
rows: await decodeParquetToRows(bytes)
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
for (const file of eventFiles) {
|
|
383
|
+
if (!file) continue;
|
|
384
|
+
const { key, rows } = file;
|
|
385
|
+
consumed.push(key);
|
|
386
|
+
eventRows.push(...rows);
|
|
387
|
+
eventsFolded += rows.length;
|
|
388
|
+
}
|
|
389
|
+
eventRows.sort((a, b) => String(a.urlHash).localeCompare(String(b.urlHash)) || String(a.inspectedAt ?? "").localeCompare(String(b.inspectedAt ?? "")));
|
|
390
|
+
for (const row of eventRows) consider(row);
|
|
391
|
+
const merged = [];
|
|
392
|
+
for (const [h, row] of latest) {
|
|
393
|
+
const fc = earliestChecked.get(h);
|
|
394
|
+
if (fc !== void 0) row.firstCheckedAt = fc;
|
|
395
|
+
populateCanonicalMismatchKind(row);
|
|
396
|
+
merged.push(row);
|
|
397
|
+
}
|
|
398
|
+
const bytes = encodeRowsToParquetFlex(merged, {
|
|
399
|
+
columns: INSPECTION_EVENT_COLUMNS,
|
|
400
|
+
sortKey: ["urlHash"]
|
|
401
|
+
});
|
|
402
|
+
const transitionsWritten = opts?.transitions ? await appendTransitions(ds, ctx, transitions) : 0;
|
|
403
|
+
await ds.write(baseKey, bytes);
|
|
404
|
+
if (consumed.length > 0) await ds.delete(consumed);
|
|
405
|
+
return {
|
|
406
|
+
baseRowCount: merged.length,
|
|
407
|
+
eventsFolded,
|
|
408
|
+
eventFilesDeleted: consumed.length,
|
|
409
|
+
transitionsWritten
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
async backfillCanonicalMismatchKinds(ctx) {
|
|
413
|
+
const baseKey = inspectionBaseKey(ctx);
|
|
414
|
+
const baseBytes = await readOptional(ds, baseKey);
|
|
415
|
+
if (!baseBytes) return {
|
|
416
|
+
baseRowCount: 0,
|
|
417
|
+
rowsBackfilled: 0,
|
|
418
|
+
rewritten: false
|
|
419
|
+
};
|
|
420
|
+
const rows = await decodeParquetToRows(baseBytes);
|
|
421
|
+
let rowsBackfilled = 0;
|
|
422
|
+
for (const row of rows) if (populateCanonicalMismatchKind(row)) rowsBackfilled++;
|
|
423
|
+
if (rowsBackfilled === 0) return {
|
|
424
|
+
baseRowCount: rows.length,
|
|
425
|
+
rowsBackfilled: 0,
|
|
426
|
+
rewritten: false
|
|
427
|
+
};
|
|
428
|
+
await ds.write(baseKey, encodeRowsToParquetFlex(rows, {
|
|
429
|
+
columns: INSPECTION_EVENT_COLUMNS,
|
|
430
|
+
sortKey: ["urlHash"]
|
|
431
|
+
}));
|
|
432
|
+
return {
|
|
433
|
+
baseRowCount: rows.length,
|
|
434
|
+
rowsBackfilled,
|
|
435
|
+
rewritten: true
|
|
436
|
+
};
|
|
437
|
+
},
|
|
438
|
+
parquetUri(ctx) {
|
|
439
|
+
return ds.uri?.(inspectionParquetKey(ctx));
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
export { INSPECTION_HISTORY_MAX_BYTES, createInspectionStore };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const ENTITY_IO_CONCURRENCY = 8;
|
|
2
|
+
async function mapEntityIo(items, fn) {
|
|
3
|
+
if (items.length === 0) return [];
|
|
4
|
+
const results = Array.from({ length: items.length }, () => void 0);
|
|
5
|
+
let next = 0;
|
|
6
|
+
async function worker() {
|
|
7
|
+
while (true) {
|
|
8
|
+
const index = next++;
|
|
9
|
+
if (index >= items.length) return;
|
|
10
|
+
results[index] = await fn(items[index], index);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
await Promise.all(Array.from({ length: Math.min(ENTITY_IO_CONCURRENCY, items.length) }, worker));
|
|
14
|
+
return results;
|
|
15
|
+
}
|
|
16
|
+
export { mapEntityIo };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { DataSource } from "
|
|
2
|
-
import { queryDimMetaKey, queryDimParquetKey } from "
|
|
1
|
+
import { DataSource } from "../storage.mjs";
|
|
2
|
+
import { queryDimMetaKey, queryDimParquetKey } from "../entity-keys.mjs";
|
|
3
3
|
import { TenantCtx } from "@gscdump/contracts";
|
|
4
4
|
interface QueryDimRecord {
|
|
5
5
|
query: string;
|
|
@@ -51,4 +51,4 @@ interface QueryDimStore {
|
|
|
51
51
|
declare function createQueryDimStore({ dataSource }: {
|
|
52
52
|
dataSource: DataSource;
|
|
53
53
|
}): QueryDimStore;
|
|
54
|
-
export { QueryDimDeps, QueryDimMeta, QueryDimRecord, QueryDimStore, buildQueryDimRecords, createQueryDimStore };
|
|
54
|
+
export { QueryDimDeps, QueryDimMeta, QueryDimRecord, QueryDimStore, buildQueryDimRecords, createQueryDimStore, queryDimMetaKey, queryDimParquetKey };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { queryDimMetaKey, queryDimParquetKey } from "
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { queryDimMetaKey, queryDimParquetKey } from "../entity-keys.mjs";
|
|
2
|
+
import { readOptional } from "../adapters/read-optional.mjs";
|
|
3
|
+
import { decodeParquetToRows, encodeRowsToParquetFlex } from "../adapters/hyparquet.mjs";
|
|
4
4
|
import { encodeJsonBigintSafe } from "@gscdump/lakehouse/bigint";
|
|
5
5
|
const QUERY_DIM_COLUMNS = [
|
|
6
6
|
{
|
|
@@ -88,4 +88,4 @@ function createQueryDimStore({ dataSource }) {
|
|
|
88
88
|
}
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
-
export { buildQueryDimRecords, createQueryDimStore };
|
|
91
|
+
export { buildQueryDimRecords, createQueryDimStore, queryDimMetaKey, queryDimParquetKey };
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { DataSource } from "../storage.mjs";
|
|
2
|
+
import { ScheduleState } from "../schedule.mjs";
|
|
3
|
+
import { Row, TenantCtx } from "@gscdump/contracts";
|
|
4
|
+
/** GSC sitemap record we persist. Matches `Schema$WmxSitemap` but as plain JSON. */
|
|
5
|
+
interface SitemapRecord {
|
|
6
|
+
/** The sitemap URL (feedpath) as returned by GSC. */
|
|
7
|
+
path: string;
|
|
8
|
+
/** ISO-8601 timestamp of the snapshot run that captured this record. */
|
|
9
|
+
capturedAt: string;
|
|
10
|
+
/** Last time Google downloaded this sitemap (RFC 3339, from the API). */
|
|
11
|
+
lastDownloaded?: string;
|
|
12
|
+
/** Last time the sitemap was submitted. */
|
|
13
|
+
lastSubmitted?: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
isPending?: boolean;
|
|
16
|
+
isSitemapsIndex?: boolean;
|
|
17
|
+
errors?: string;
|
|
18
|
+
warnings?: string;
|
|
19
|
+
/** Per-content-type counts (web, image, video, news). */
|
|
20
|
+
contents?: Array<{
|
|
21
|
+
type?: string;
|
|
22
|
+
submitted?: string;
|
|
23
|
+
indexed?: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** Raw payload for fields we don't promote to first-class columns. */
|
|
26
|
+
raw?: unknown;
|
|
27
|
+
/** Number of URLs observed in this feedpath at last snapshot. */
|
|
28
|
+
urlCount?: number;
|
|
29
|
+
/** Stable hash of the sorted normalized loc list at last snapshot. */
|
|
30
|
+
contentHash?: string;
|
|
31
|
+
/** Adaptive cadence state owned by `sitemapPolicy`. */
|
|
32
|
+
schedule?: ScheduleState;
|
|
33
|
+
}
|
|
34
|
+
interface SitemapIndex {
|
|
35
|
+
version: 1;
|
|
36
|
+
/** Map of feedpathHash → latest SitemapRecord. */
|
|
37
|
+
records: Record<string, SitemapRecord>;
|
|
38
|
+
}
|
|
39
|
+
interface SitemapHistoryDoc {
|
|
40
|
+
version: 1;
|
|
41
|
+
path: string;
|
|
42
|
+
capturedAt: string;
|
|
43
|
+
record: SitemapRecord;
|
|
44
|
+
}
|
|
45
|
+
/** Parsed URL entry from a sitemap XML. */
|
|
46
|
+
interface ParsedUrl {
|
|
47
|
+
loc: string;
|
|
48
|
+
/** ISO-8601 lastmod from the sitemap, if present. */
|
|
49
|
+
lastmod?: string;
|
|
50
|
+
}
|
|
51
|
+
/** A single URL row in the urls/index.parquet partition. */
|
|
52
|
+
interface SitemapUrlRecord {
|
|
53
|
+
feedpath: string;
|
|
54
|
+
feedpathHash: string;
|
|
55
|
+
urlHash: string;
|
|
56
|
+
loc: string;
|
|
57
|
+
lastmod?: string;
|
|
58
|
+
firstSeenAt: number;
|
|
59
|
+
lastSeenAt: number;
|
|
60
|
+
/** Set when the URL has been removed. Null/undefined = currently live. */
|
|
61
|
+
removedAt?: number;
|
|
62
|
+
}
|
|
63
|
+
interface SnapshotUrlsResult {
|
|
64
|
+
added: number;
|
|
65
|
+
removed: number;
|
|
66
|
+
kept: number;
|
|
67
|
+
contentHash: string;
|
|
68
|
+
/** True when current membership did not change. Initial history seeding may write. */
|
|
69
|
+
unchanged: boolean;
|
|
70
|
+
}
|
|
71
|
+
interface CompleteSitemapGeneration {
|
|
72
|
+
_tag: 'complete';
|
|
73
|
+
id: string;
|
|
74
|
+
/** Unix epoch milliseconds. */
|
|
75
|
+
observedAt: number;
|
|
76
|
+
}
|
|
77
|
+
interface ReconcileResult {
|
|
78
|
+
/** Feedpaths that were absent from the live set and had their live URLs pruned. */
|
|
79
|
+
feedpathsPruned: number;
|
|
80
|
+
/** Total URL rows transitioned live → removed across pruned feedpaths. */
|
|
81
|
+
urlsRemoved: number;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Bounds on a single `compactUrls` call. `compactUrls` is memory-bounded (one
|
|
85
|
+
* feedpath at a time) but was previously unbounded in TIME: a tenant with a
|
|
86
|
+
* large accumulated delta backlog could run past a caller's execution budget.
|
|
87
|
+
* Both bounds are checked BETWEEN feedpaths and only after at least one has
|
|
88
|
+
* been compacted, so a call always makes forward progress.
|
|
89
|
+
*/
|
|
90
|
+
interface CompactUrlsOptions {
|
|
91
|
+
/** Stop before starting another feedpath once this many ms have elapsed. */
|
|
92
|
+
deadlineMs?: number;
|
|
93
|
+
/** Stop after compacting this many feedpaths. */
|
|
94
|
+
maxFeedpaths?: number;
|
|
95
|
+
}
|
|
96
|
+
interface CompactUrlsResult {
|
|
97
|
+
/** Feedpaths whose active deltas were folded and retired by this call. */
|
|
98
|
+
compactedFeedpaths: number;
|
|
99
|
+
/**
|
|
100
|
+
* Feedpaths that still hold outstanding deltas. Call `compactUrls` again to
|
|
101
|
+
* continue. No cursor is needed because the projection watermark excludes a
|
|
102
|
+
* compacted feedpath's grace-retained deltas from the next call.
|
|
103
|
+
*/
|
|
104
|
+
remainingFeedpaths: number;
|
|
105
|
+
}
|
|
106
|
+
interface DeltaEntry {
|
|
107
|
+
feedpath: string;
|
|
108
|
+
feedpathHash: string;
|
|
109
|
+
urlHash: string;
|
|
110
|
+
op: 'added' | 'removed';
|
|
111
|
+
loc: string;
|
|
112
|
+
lastmod?: string;
|
|
113
|
+
at: number;
|
|
114
|
+
}
|
|
115
|
+
interface SitemapMembershipEvent {
|
|
116
|
+
feedpath: string;
|
|
117
|
+
feedpathHash: string;
|
|
118
|
+
urlHash: string;
|
|
119
|
+
op: 'added' | 'removed';
|
|
120
|
+
loc: string;
|
|
121
|
+
lastmod?: string;
|
|
122
|
+
generationId: string;
|
|
123
|
+
/** Unix epoch milliseconds. */
|
|
124
|
+
observedAt: number;
|
|
125
|
+
/** Stable ordering within one generation. */
|
|
126
|
+
sequence: number;
|
|
127
|
+
/** Whether this row also mutates the disposable current-state projection. */
|
|
128
|
+
projectsState: boolean;
|
|
129
|
+
}
|
|
130
|
+
interface DateRange {
|
|
131
|
+
/** YYYY-MM-DD inclusive. */
|
|
132
|
+
from?: string;
|
|
133
|
+
/** YYYY-MM-DD inclusive. */
|
|
134
|
+
to?: string;
|
|
135
|
+
}
|
|
136
|
+
interface LoadUrlsOptions {
|
|
137
|
+
includeRemoved?: boolean;
|
|
138
|
+
}
|
|
139
|
+
interface SitemapReadStore {
|
|
140
|
+
/** Load the full site index (latest record per feedpath). */
|
|
141
|
+
loadIndex: (ctx: TenantCtx) => Promise<SitemapIndex>;
|
|
142
|
+
/** Fetch the latest snapshot for a feedpath, or undefined. */
|
|
143
|
+
getLatest: (ctx: TenantCtx, path: string) => Promise<SitemapRecord | undefined>;
|
|
144
|
+
/** Stream live (and optionally removed) URL rows for a feedpath. */
|
|
145
|
+
loadUrls: (ctx: TenantCtx, feedpath: string, opts?: LoadUrlsOptions) => AsyncIterable<SitemapUrlRecord>;
|
|
146
|
+
/** Stream all delta entries within `[from, to]` (YYYY-MM-DD inclusive). */
|
|
147
|
+
loadDeltas: (ctx: TenantCtx, dateRange?: DateRange) => AsyncIterable<DeltaEntry>;
|
|
148
|
+
/** Stream immutable membership events within `[from, to]`. */
|
|
149
|
+
loadEvents: (ctx: TenantCtx, dateRange?: DateRange) => AsyncIterable<SitemapMembershipEvent>;
|
|
150
|
+
}
|
|
151
|
+
interface SitemapStore extends SitemapReadStore {
|
|
152
|
+
/**
|
|
153
|
+
* Persist a snapshot run. Updates the index + writes one immutable
|
|
154
|
+
* history doc per record under `history/<feedpathHash>__<capturedAtMs>.json`.
|
|
155
|
+
*/
|
|
156
|
+
writeSnapshot: (ctx: TenantCtx, records: readonly SitemapRecord[]) => Promise<void>;
|
|
157
|
+
/**
|
|
158
|
+
* Diff a complete sitemap generation against current state. The immutable
|
|
159
|
+
* membership event lands before its disposable state delta.
|
|
160
|
+
*/
|
|
161
|
+
snapshotUrls: (ctx: TenantCtx, generation: CompleteSitemapGeneration, feedpath: string, urls: readonly ParsedUrl[]) => Promise<SnapshotUrlsResult>;
|
|
162
|
+
/**
|
|
163
|
+
* Fold accumulated deltas into the prior index, one feedpath at a time:
|
|
164
|
+
* rewrites each touched feedpath's `by-feed/<hash>/index.parquet` and deletes
|
|
165
|
+
* the consumed delta files. Bounded per feedpath, so it stays within memory
|
|
166
|
+
* regardless of total site URL count.
|
|
167
|
+
*
|
|
168
|
+
* Optionally bounded in TIME too (`opts.deadlineMs` / `opts.maxFeedpaths`).
|
|
169
|
+
* A bounded call is safe to stop mid-way: each rewritten feedpath advances
|
|
170
|
+
* the projection watermark, so the remainder is simply what the next call
|
|
171
|
+
* finds. Callers drive this from `remainingFeedpaths`, not a cursor.
|
|
172
|
+
*/
|
|
173
|
+
compactUrls: (ctx: TenantCtx, opts?: CompactUrlsOptions) => Promise<CompactUrlsResult>;
|
|
174
|
+
/**
|
|
175
|
+
* Site-wide convergence: mark every still-live URL whose owning feedpath is
|
|
176
|
+
* absent from `liveFeedpaths` as removed. `compactUrls`/`snapshotUrls` only
|
|
177
|
+
* prune URLs *inside* a feedpath that was re-observed; a whole feed dropped
|
|
178
|
+
* from the sitemap list (no `snapshotUrls` call) leaves its URLs frozen-live
|
|
179
|
+
* forever. This is the sidecar mirror of the D1 generation sweep: it rewrites
|
|
180
|
+
* each dropped feedpath's `by-feed/<hash>/index.parquet` with `removedAt` set,
|
|
181
|
+
* advances its projection watermark, and grace-retires outstanding deltas.
|
|
182
|
+
* Bounded per feedpath, so memory stays flat regardless of site size. Live
|
|
183
|
+
* feedpaths are never touched.
|
|
184
|
+
*/
|
|
185
|
+
reconcile: (ctx: TenantCtx, generation: CompleteSitemapGeneration, opts: {
|
|
186
|
+
liveFeedpaths: readonly string[];
|
|
187
|
+
}) => Promise<ReconcileResult>;
|
|
188
|
+
}
|
|
189
|
+
interface CreateSitemapReadStoreOptions {
|
|
190
|
+
dataSource: DataSource;
|
|
191
|
+
/** Override the feedpath hash (test seam). */
|
|
192
|
+
hash?: (path: string) => string;
|
|
193
|
+
}
|
|
194
|
+
type SitemapMutation = <T>(ctx: TenantCtx, fn: () => Promise<T>) => Promise<T>;
|
|
195
|
+
interface CreateSitemapStoreOptions extends CreateSitemapReadStoreOptions {
|
|
196
|
+
withMutation: SitemapMutation;
|
|
197
|
+
now?: () => number;
|
|
198
|
+
}
|
|
199
|
+
export { CompactUrlsOptions, CompactUrlsResult, CompleteSitemapGeneration, CreateSitemapReadStoreOptions, CreateSitemapStoreOptions, DateRange, DeltaEntry, LoadUrlsOptions, ParsedUrl, ReconcileResult, SitemapHistoryDoc, SitemapIndex, SitemapMembershipEvent, SitemapMutation, SitemapReadStore, SitemapRecord, SitemapStore, SitemapUrlRecord, SnapshotUrlsResult };
|