polite-crawler 0.1.1 → 0.1.3

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 (62) hide show
  1. package/README.md +1087 -15
  2. package/build/browser-CKRNbIDT.mjs +62 -0
  3. package/build/browser-CKRNbIDT.mjs.map +1 -0
  4. package/build/cli-daemon--cTmq0Z5.mjs +38 -0
  5. package/build/cli-daemon--cTmq0Z5.mjs.map +1 -0
  6. package/build/cli.mjs +1065 -123
  7. package/build/cli.mjs.map +1 -1
  8. package/build/daemon-DP4EqjFL.d.mts +3772 -0
  9. package/build/daemon-fZTE24P5.mjs +515 -0
  10. package/build/daemon-fZTE24P5.mjs.map +1 -0
  11. package/build/diff-NQcQqKe9.mjs +648 -0
  12. package/build/diff-NQcQqKe9.mjs.map +1 -0
  13. package/build/extract-ByFfc8Zk.mjs +1793 -0
  14. package/build/extract-ByFfc8Zk.mjs.map +1 -0
  15. package/build/hours.d.mts +26 -0
  16. package/build/hours.mjs +48 -1
  17. package/build/hours.mjs.map +1 -1
  18. package/build/{html-nWYFVsRU.d.mts → html-BACljlkf.d.mts} +8 -9
  19. package/build/{html-oNpGAHwi.mjs → html-DkQUAE_d.mjs} +18 -39
  20. package/build/html-DkQUAE_d.mjs.map +1 -0
  21. package/build/html.d.mts +2 -1
  22. package/build/html.mjs +3 -1
  23. package/build/index.d.mts +582 -12
  24. package/build/index.mjs +903 -6
  25. package/build/index.mjs.map +1 -0
  26. package/build/log-Dx-wXjJl.mjs +342 -0
  27. package/build/log-Dx-wXjJl.mjs.map +1 -0
  28. package/build/loopback-C_QYowSf.mjs +22 -0
  29. package/build/loopback-C_QYowSf.mjs.map +1 -0
  30. package/build/mcp.d.mts +164 -5
  31. package/build/mcp.mjs +1115 -42
  32. package/build/mcp.mjs.map +1 -1
  33. package/build/ollama-BJxkjny4.mjs +160 -0
  34. package/build/ollama-BJxkjny4.mjs.map +1 -0
  35. package/build/open-C4fhXJgD.mjs +4544 -0
  36. package/build/open-C4fhXJgD.mjs.map +1 -0
  37. package/build/{browser-gJ_h4AhB.mjs → render-C1CUraWF.mjs} +31 -74
  38. package/build/render-C1CUraWF.mjs.map +1 -0
  39. package/build/rolldown-runtime-D7D4PA-g.mjs +13 -0
  40. package/build/{open-DYZ7KL2A.mjs → runtime-JToRO63o.mjs} +986 -1657
  41. package/build/runtime-JToRO63o.mjs.map +1 -0
  42. package/build/transformers-BQMlaGj4.mjs +133 -0
  43. package/build/transformers-BQMlaGj4.mjs.map +1 -0
  44. package/build/urls-BHFeSyEK.mjs +62 -0
  45. package/build/urls-BHFeSyEK.mjs.map +1 -0
  46. package/build/urls-BTDSgDLC.d.mts +16 -0
  47. package/build/version-ntbY-6wh.mjs +38 -0
  48. package/build/version-ntbY-6wh.mjs.map +1 -0
  49. package/build/wacz-BFNG42Kt.mjs +275 -0
  50. package/build/wacz-BFNG42Kt.mjs.map +1 -0
  51. package/build/warc-CVS0t1fW.mjs +728 -0
  52. package/build/warc-CVS0t1fW.mjs.map +1 -0
  53. package/build/webbotauth-CjJdTAfE.mjs +568 -0
  54. package/build/webbotauth-CjJdTAfE.mjs.map +1 -0
  55. package/package.json +10 -2
  56. package/record.schema.json +15 -1
  57. package/build/browser-gJ_h4AhB.mjs.map +0 -1
  58. package/build/html-oNpGAHwi.mjs.map +0 -1
  59. package/build/open-DYZ7KL2A.mjs.map +0 -1
  60. package/build/open-eHcriTds.d.mts +0 -1499
  61. package/build/version-no12qgos.mjs +0 -11
  62. package/build/version-no12qgos.mjs.map +0 -1
@@ -0,0 +1,4544 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { n as FileBlobStore, t as CaptureLog } from "./log-Dx-wXjJl.mjs";
3
+ import { r as playwrightRenderer } from "./render-C1CUraWF.mjs";
4
+ import { r as checkedRenderer } from "./browser-CKRNbIDT.mjs";
5
+ import { i as durationMs, n as DEFAULT_TOKEN, r as VERSION, t as BOT_PAGE } from "./version-ntbY-6wh.mjs";
6
+ import { A as snippetOf, C as fallbackReason, E as fold, L as sha256$1, M as Crawler, N as DEADLINE_NOTE, R as stableStringify, S as archivedOf, a as fetchPage, c as isHeldBack, f as readDocument, g as verifyQuote, i as fetchAndIndex, k as parseQuery, m as reindex, n as HELD_BACK, o as findDocuments, p as recordOverride, r as catchUpIndex, s as heldBackMeta, t as Extractors, y as archivedCopy } from "./runtime-JToRO63o.mjs";
7
+ import { MemoryHostStateStore, nextValidatorMemo, realClock } from "./politeness.mjs";
8
+ import { matchesPattern } from "./robots.mjs";
9
+ import { n as canonicalUrl, r as scopeTest, t as cacheKeyOf } from "./urls-BHFeSyEK.mjs";
10
+ import { o as decodeBody, t as extractPage } from "./html-DkQUAE_d.mjs";
11
+ import { n as diffDocument, r as diffTexts } from "./diff-NQcQqKe9.mjs";
12
+ import { r as extractDocument } from "./extract-ByFfc8Zk.mjs";
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import { dirname, join } from "node:path";
15
+ import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
16
+ import { createGunzip } from "node:zlib";
17
+ import { DatabaseSync } from "node:sqlite";
18
+ /** The search index reads this many characters of each text, at most. */
19
+ const MAX_INDEX_CHARS = 2e5;
20
+ /** In the mode `fast`, a part is common when more than this share of the documents hold it. */
21
+ const FAST_RANK_SHARE = .2;
22
+ /** The text of the error of hybrid search with no embedder. */
23
+ const NO_EMBEDDER = "hybrid search needs an embedder: open the crawler with the option embedder (transformersEmbedder or ollamaEmbedder), or give --embedder transformers or ollama to the command line";
24
+ /** This function gives the rank mode of an option. A value that is not a rank mode throws a `RangeError`. */
25
+ const rankModeOf = (value) => {
26
+ if (value === void 0 || value === "exact") return "exact";
27
+ if (value === "fast") return "fast";
28
+ if (value === "hybrid") throw new RangeError("the index ranks with \"exact\" and \"fast\" only: rank \"hybrid\" needs the vectors of an embedder, so use find or rankWithin of a crawler with the option embedder");
29
+ throw new RangeError(`rank must be "exact" or "fast", not ${JSON.stringify(value) ?? "undefined"}`);
30
+ };
31
+ const namesOf = (generation) => ({
32
+ docs: `docs_${String(generation)}`,
33
+ fts: `fts_${String(generation)}`,
34
+ versions: `versions_${String(generation)}`,
35
+ texts: `texts_${String(generation)}`,
36
+ latest: `latest_${String(generation)}`
37
+ });
38
+ const GENERATION_TABLE = /^(docs|fts|versions|texts|latest)_(\d+)$/;
39
+ /** A build that started this long ago and did not finish belongs to a process that stopped. */
40
+ const ABANDONED_MS = 864e5;
41
+ /** The FTS5 tokenizer: folded text, English stems, and spacing marks kept in the words. */
42
+ const TOKENIZER = `tokenize = "porter unicode61 remove_diacritics 0 categories 'L* N* Co Mc'"`;
43
+ const ddlOf = (n) => `
44
+ CREATE TABLE IF NOT EXISTS ${n.texts} (
45
+ sha TEXT PRIMARY KEY,
46
+ body TEXT NOT NULL
47
+ );
48
+ CREATE TABLE IF NOT EXISTS ${n.versions} (
49
+ doc TEXT NOT NULL,
50
+ capture TEXT NOT NULL,
51
+ recipe TEXT NOT NULL,
52
+ rendered INTEGER NOT NULL,
53
+ fetched_at TEXT NOT NULL,
54
+ blob TEXT,
55
+ kind TEXT NOT NULL,
56
+ title TEXT NOT NULL,
57
+ url TEXT,
58
+ meta_sha TEXT NOT NULL,
59
+ text_sha TEXT NOT NULL,
60
+ text_chars INTEGER NOT NULL,
61
+ full_sha TEXT,
62
+ ai_input TEXT NOT NULL,
63
+ merge TEXT NOT NULL,
64
+ scope TEXT,
65
+ labels TEXT,
66
+ PRIMARY KEY (doc, capture)
67
+ );
68
+ CREATE INDEX IF NOT EXISTS ${n.versions}_time ON ${n.versions} (doc, recipe, rendered, fetched_at);
69
+ CREATE INDEX IF NOT EXISTS ${n.versions}_capture ON ${n.versions} (capture);
70
+ CREATE INDEX IF NOT EXISTS ${n.versions}_fetched ON ${n.versions} (fetched_at);
71
+ CREATE INDEX IF NOT EXISTS ${n.versions}_recipe ON ${n.versions} (recipe);
72
+ CREATE TABLE IF NOT EXISTS ${n.docs} (
73
+ doc TEXT PRIMARY KEY,
74
+ kind TEXT NOT NULL,
75
+ title TEXT NOT NULL,
76
+ url TEXT,
77
+ meta TEXT NOT NULL,
78
+ text_sha TEXT NOT NULL,
79
+ full_sha TEXT,
80
+ captures TEXT NOT NULL,
81
+ capture_count INTEGER NOT NULL,
82
+ capture TEXT NOT NULL,
83
+ blob TEXT,
84
+ fetched_at TEXT NOT NULL,
85
+ recipe TEXT NOT NULL,
86
+ rendered INTEGER NOT NULL,
87
+ ai_input TEXT NOT NULL,
88
+ scope TEXT,
89
+ labels TEXT,
90
+ updated_at TEXT NOT NULL
91
+ );
92
+ CREATE VIRTUAL TABLE IF NOT EXISTS ${n.fts} USING fts5(
93
+ title, text, content = '', contentless_delete = 1, ${TOKENIZER}
94
+ );
95
+ CREATE TABLE IF NOT EXISTS ${n.latest} (
96
+ url TEXT NOT NULL,
97
+ variant TEXT NOT NULL,
98
+ capture TEXT NOT NULL,
99
+ blob TEXT,
100
+ etag TEXT,
101
+ last_modified TEXT,
102
+ content_type TEXT,
103
+ fetched_at TEXT NOT NULL,
104
+ status INTEGER NOT NULL,
105
+ ai_input TEXT NOT NULL,
106
+ location TEXT,
107
+ truncated INTEGER,
108
+ fresh_until TEXT,
109
+ vary_key TEXT,
110
+ PRIMARY KEY (url, variant)
111
+ );
112
+ CREATE INDEX IF NOT EXISTS ${n.latest}_fetched ON ${n.latest} (fetched_at);
113
+ `;
114
+ const RENDERED_MARK = "#rendered";
115
+ /** This function splits a cache key into its URL and its variant. A key in the form of version 0.1 (`…#rendered`) is a rendered variant. */
116
+ const keyOf = (url, variant) => {
117
+ if (url.endsWith(RENDERED_MARK)) return {
118
+ url: cacheKeyOf(url.slice(0, -9)),
119
+ variant: "rendered"
120
+ };
121
+ return {
122
+ url: cacheKeyOf(url),
123
+ variant: variant ?? "plain"
124
+ };
125
+ };
126
+ const sha = (text) => createHash("sha256").update(text, "utf8").digest("hex");
127
+ const parseJson$1 = (text, fallback) => {
128
+ if (text === null || text === void 0) return fallback;
129
+ try {
130
+ return JSON.parse(text);
131
+ } catch {
132
+ return fallback;
133
+ }
134
+ };
135
+ const toLatest = (row) => ({
136
+ url: row.url,
137
+ capture: row.capture,
138
+ blob: row.blob,
139
+ etag: row.etag,
140
+ last_modified: row.last_modified,
141
+ content_type: row.content_type,
142
+ fetched_at: row.fetched_at,
143
+ status: row.status,
144
+ ai_input: row.ai_input,
145
+ location: row.location,
146
+ truncated: row.truncated === 1 ? true : void 0,
147
+ fresh_until: row.fresh_until,
148
+ vary_key: row.vary_key,
149
+ variant: row.variant
150
+ });
151
+ const toVersion = (row) => ({
152
+ doc: row.doc,
153
+ capture: row.capture,
154
+ recipe: row.recipe,
155
+ rendered: row.rendered === 1,
156
+ fetched_at: row.fetched_at,
157
+ blob: row.blob,
158
+ kind: row.kind,
159
+ title: row.title,
160
+ url: row.url,
161
+ ai_input: row.ai_input,
162
+ chars: row.text_chars,
163
+ cut: row.full_sha !== null
164
+ });
165
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
166
+ var DocIndex = class {
167
+ path;
168
+ db;
169
+ statements = /* @__PURE__ */ new Map();
170
+ depth = 0;
171
+ closed = false;
172
+ empty;
173
+ /**
174
+ * This value is true when the file had a different schema, or no tables, so the index opened
175
+ * empty. A store with captures then needs `reindex`. The commit of a rebuild makes it false.
176
+ */
177
+ get stale() {
178
+ return this.empty;
179
+ }
180
+ constructor(path, options = {}) {
181
+ this.path = path;
182
+ if (path !== ":memory:") mkdirSync(dirname(path), {
183
+ recursive: true,
184
+ mode: 448
185
+ });
186
+ this.db = new DatabaseSync(path);
187
+ const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
188
+ this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
189
+ try {
190
+ this.db.exec("CREATE VIRTUAL TABLE temp.polite_fts5_probe USING fts5(x, content = '', contentless_delete = 1); DROP TABLE temp.polite_fts5_probe;");
191
+ } catch (cause) {
192
+ this.db.close();
193
+ throw new Error(`polite-crawler needs SQLite FTS5 with contentless_delete, and the SQLite of Node.js ${process.version} does not have it: use Node.js 22.19 or later`, { cause });
194
+ }
195
+ if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
196
+ this.db.exec("PRAGMA synchronous = NORMAL;");
197
+ this.empty = this.write(() => this.prepareSchema());
198
+ }
199
+ /** This method makes the tables of the schema. It gives true when the index opened empty. */
200
+ prepareSchema() {
201
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
202
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
203
+ if (version === 2 && tables.some((table) => table.name === "index_meta")) {
204
+ this.dropAbandoned();
205
+ return false;
206
+ }
207
+ const virtual = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'").all();
208
+ for (const { name } of virtual) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
209
+ const rest = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
210
+ for (const { name } of rest) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
211
+ this.db.exec("CREATE TABLE index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
212
+ this.db.exec(ddlOf(namesOf(1)));
213
+ this.statements.clear();
214
+ this.setMeta("generation", "1");
215
+ this.setMeta("next_generation", "2");
216
+ this.db.exec(`PRAGMA user_version = ${String(2)}`);
217
+ return true;
218
+ }
219
+ setMeta(key, value) {
220
+ this.stmt("INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
221
+ }
222
+ getMeta(key) {
223
+ return this.stmt("SELECT value FROM index_meta WHERE key = ?").get(key)?.value;
224
+ }
225
+ stmt(sql) {
226
+ let statement = this.statements.get(sql);
227
+ if (statement === void 0) {
228
+ statement = this.db.prepare(sql);
229
+ this.statements.set(sql, statement);
230
+ }
231
+ return statement;
232
+ }
233
+ /**
234
+ * This method does a write in one transaction. A transaction in progress takes the write in,
235
+ * also a transaction that the caller opened on the connection.
236
+ */
237
+ write(work) {
238
+ if (this.closed) throw new Error("the index is closed");
239
+ if (this.depth > 0 || this.db.isTransaction) {
240
+ this.depth += 1;
241
+ try {
242
+ return work();
243
+ } finally {
244
+ this.depth -= 1;
245
+ }
246
+ }
247
+ this.db.exec("BEGIN IMMEDIATE");
248
+ this.depth = 1;
249
+ try {
250
+ const result = work();
251
+ this.db.exec("COMMIT");
252
+ return result;
253
+ } catch (error) {
254
+ if (this.db.isTransaction) this.db.exec("ROLLBACK");
255
+ throw error;
256
+ } finally {
257
+ this.depth = 0;
258
+ }
259
+ }
260
+ generation() {
261
+ return Number(this.getMeta("generation") ?? "1");
262
+ }
263
+ /**
264
+ * This method does a step on the tables of the current generation. A rebuild of a different
265
+ * process can remove those tables in the meantime. Then the method reads the generation again
266
+ * and does the step once more.
267
+ */
268
+ live(step) {
269
+ if (this.closed) throw new Error("the index is closed");
270
+ try {
271
+ return step(namesOf(this.generation()));
272
+ } catch (error) {
273
+ if (!/no such table/i.test(messageOf(error)) || this.depth > 0) throw error;
274
+ this.statements.clear();
275
+ return step(namesOf(this.generation()));
276
+ }
277
+ }
278
+ /** This method removes the tables of a generation. */
279
+ dropGeneration(generation) {
280
+ const n = namesOf(generation);
281
+ for (const table of [
282
+ n.fts,
283
+ n.docs,
284
+ n.versions,
285
+ n.texts,
286
+ n.latest
287
+ ]) this.db.exec(`DROP TABLE IF EXISTS ${table}`);
288
+ this.db.prepare("DELETE FROM index_meta WHERE key = ?").run(`building:${String(generation)}`);
289
+ this.statements.clear();
290
+ }
291
+ /** This method removes the generations of builds that a stopped process left. */
292
+ dropAbandoned() {
293
+ const current = this.generation();
294
+ const generations = /* @__PURE__ */ new Set();
295
+ for (const { name } of this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()) {
296
+ const match = GENERATION_TABLE.exec(name);
297
+ if (match !== null) generations.add(Number(match[2]));
298
+ }
299
+ for (const generation of generations) {
300
+ if (generation === current) continue;
301
+ const started = this.getMeta(`building:${String(generation)}`);
302
+ if (!((started === void 0 ? Number.POSITIVE_INFINITY : Date.now() - Date.parse(started)) < ABANDONED_MS)) this.dropGeneration(generation);
303
+ }
304
+ }
305
+ putText(n, body) {
306
+ const key = sha(body);
307
+ this.stmt(`INSERT OR IGNORE INTO ${n.texts} (sha, body) VALUES (?, ?)`).run(key, body);
308
+ return key;
309
+ }
310
+ textOf(n, key) {
311
+ if (key === null) return void 0;
312
+ return this.stmt(`SELECT body FROM ${n.texts} WHERE sha = ?`).get(key)?.body;
313
+ }
314
+ putVersion(n, input, now) {
315
+ const full = input.full_text !== void 0 && input.full_text !== input.text ? this.putText(n, input.full_text) : null;
316
+ this.stmt(`INSERT INTO ${n.versions} (doc, capture, recipe, rendered, fetched_at, blob, kind, title, url,
317
+ meta_sha, text_sha, text_chars, full_sha, ai_input, merge, scope, labels)
318
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
319
+ ON CONFLICT(doc, capture) DO UPDATE SET recipe = excluded.recipe, rendered = excluded.rendered,
320
+ fetched_at = excluded.fetched_at, blob = excluded.blob, kind = excluded.kind, title = excluded.title,
321
+ url = excluded.url, meta_sha = excluded.meta_sha, text_sha = excluded.text_sha,
322
+ text_chars = excluded.text_chars, full_sha = excluded.full_sha, ai_input = excluded.ai_input,
323
+ merge = excluded.merge, scope = excluded.scope, labels = excluded.labels`).run(input.doc, input.capture ?? input.captures[0] ?? "", input.recipe ?? "", input.rendered === true ? 1 : 0, input.fetched_at ?? now, input.blob ?? null, input.kind, input.title, input.url, this.putText(n, JSON.stringify(input.meta ?? {})), this.putText(n, input.text), input.text.length, full, input.ai_input ?? "unset", input.merge ?? "richest", input.scope ?? null, input.labels === void 0 ? null : JSON.stringify(input.labels));
324
+ }
325
+ /** This method gives the newest version of a document for one recipe and one variant, at `until` or before when it is given. */
326
+ newest(n, doc, recipe, rendered, until) {
327
+ if (until === void 0) return this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = ?
328
+ ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered);
329
+ return this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = ? AND fetched_at <= ?
330
+ ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered, until);
331
+ }
332
+ /**
333
+ * This method chooses the current version of a document with the rules of the class. With
334
+ * `until`, it uses only the versions that the crawler fetched at that time or before. Thus it
335
+ * gives the version that was current at that time. It gives undefined when no version agrees.
336
+ */
337
+ choose(n, doc, until) {
338
+ const groups = until === void 0 ? this.stmt(`SELECT DISTINCT recipe, rendered FROM ${n.versions} WHERE doc = ?`).all(doc) : this.stmt(`SELECT DISTINCT recipe, rendered FROM ${n.versions} WHERE doc = ? AND fetched_at <= ?`).all(doc, until);
339
+ if (groups.length === 0) return void 0;
340
+ const byRecipe = /* @__PURE__ */ new Map();
341
+ for (const group of groups) {
342
+ const row = this.newest(n, doc, group.recipe, group.rendered, until);
343
+ if (row === void 0) continue;
344
+ const entry = byRecipe.get(group.recipe) ?? {};
345
+ if (group.rendered === 1) entry.rendered = row;
346
+ else entry.plain = row;
347
+ byRecipe.set(group.recipe, entry);
348
+ }
349
+ const candidates = [];
350
+ for (const [recipe, { plain, rendered }] of byRecipe) {
351
+ let pick;
352
+ if (rendered === void 0) pick = plain;
353
+ else if (plain === void 0 || rendered.fetched_at >= plain.fetched_at) pick = rendered;
354
+ else {
355
+ const base = this.stmt(`SELECT blob FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = 0 AND fetched_at <= ?
356
+ ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered.fetched_at);
357
+ pick = base !== void 0 && base.blob !== null && base.blob === plain.blob ? rendered : plain;
358
+ }
359
+ const newest = plain === void 0 ? rendered : rendered === void 0 || plain.fetched_at > rendered.fetched_at ? plain : rendered;
360
+ const others = [plain, rendered].filter((row) => row !== void 0 && row !== pick);
361
+ candidates.push({
362
+ row: pick,
363
+ ai: newest.ai_input,
364
+ others
365
+ });
366
+ }
367
+ let winner = candidates[0];
368
+ if (candidates.length > 1) {
369
+ const latest = candidates.every((candidate) => candidate.row.merge === "latest");
370
+ for (const candidate of candidates.slice(1)) {
371
+ const a = candidate.row;
372
+ const b = winner.row;
373
+ if (latest ? a.fetched_at > b.fetched_at : a.text_chars > b.text_chars || a.text_chars === b.text_chars && a.fetched_at > b.fetched_at) winner = candidate;
374
+ }
375
+ }
376
+ const aiInput = winner.row.ai_input === "no" || candidates.some((candidate) => candidate.ai === "no") ? "no" : winner.ai !== "unset" ? winner.ai : candidates.some((candidate) => candidate.ai === "yes") ? "yes" : "unset";
377
+ return {
378
+ winner,
379
+ candidates,
380
+ aiInput
381
+ };
382
+ }
383
+ /** This method chooses the current version of a document from its versions, and writes it with its search entry. */
384
+ recompute(n, doc, now) {
385
+ const before = this.stmt(`SELECT rowid, text_sha, title FROM ${n.docs} WHERE doc = ?`).get(doc);
386
+ const choice = this.choose(n, doc);
387
+ if (choice === void 0) {
388
+ if (before !== void 0) {
389
+ this.stmt(`DELETE FROM ${n.fts} WHERE rowid = ?`).run(before.rowid);
390
+ this.stmt(`DELETE FROM ${n.docs} WHERE doc = ?`).run(doc);
391
+ }
392
+ return;
393
+ }
394
+ const { winner, candidates, aiInput } = choice;
395
+ const row = winner.row;
396
+ let meta = parseJson$1(this.textOf(n, row.meta_sha), {});
397
+ for (const candidate of candidates) if (candidate !== winner) meta = {
398
+ ...parseJson$1(this.textOf(n, candidate.row.meta_sha), {}),
399
+ ...meta
400
+ };
401
+ const same = this.stmt(`SELECT capture FROM ${n.versions} WHERE doc = ? AND text_sha = ?
402
+ ORDER BY fetched_at DESC, capture DESC LIMIT ?`).all(doc, row.text_sha, 10);
403
+ const captures = [.../* @__PURE__ */ new Set([
404
+ row.capture,
405
+ ...same.map((entry) => entry.capture),
406
+ ...winner.others.map((other) => other.capture),
407
+ ...candidates.filter((candidate) => candidate !== winner).map((candidate) => candidate.row.capture)
408
+ ])].slice(0, 10);
409
+ const count = this.stmt(`SELECT count(*) AS n FROM ${n.versions} WHERE doc = ?`).get(doc).n;
410
+ const url = row.url ?? candidates.map((candidate) => candidate.row.url).find((value) => value !== null) ?? null;
411
+ const saved = this.stmt(`INSERT INTO ${n.docs} (doc, kind, title, url, meta, text_sha, full_sha, captures, capture_count, capture,
412
+ blob, fetched_at, recipe, rendered, ai_input, scope, labels, updated_at)
413
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
414
+ ON CONFLICT(doc) DO UPDATE SET kind = excluded.kind, title = excluded.title, url = excluded.url,
415
+ meta = excluded.meta, text_sha = excluded.text_sha, full_sha = excluded.full_sha,
416
+ captures = excluded.captures, capture_count = excluded.capture_count, capture = excluded.capture,
417
+ blob = excluded.blob, fetched_at = excluded.fetched_at, recipe = excluded.recipe,
418
+ rendered = excluded.rendered, ai_input = excluded.ai_input, scope = excluded.scope,
419
+ labels = excluded.labels, updated_at = excluded.updated_at
420
+ RETURNING rowid`).get(doc, row.kind, row.title, url, JSON.stringify(meta), row.text_sha, row.full_sha, JSON.stringify(captures), count, row.capture, row.blob, row.fetched_at, row.recipe, row.rendered, aiInput, row.scope, row.labels, now);
421
+ const text = this.textOf(n, row.text_sha) ?? "";
422
+ if (before === void 0 || before.text_sha !== row.text_sha || before.title !== row.title) {
423
+ if (before !== void 0) this.stmt(`DELETE FROM ${n.fts} WHERE rowid = ?`).run(before.rowid);
424
+ this.stmt(`INSERT INTO ${n.fts} (rowid, title, text) VALUES (?, ?, ?)`).run(saved.rowid, fold(row.title, "search"), fold(text.length > MAX_INDEX_CHARS ? text.slice(0, MAX_INDEX_CHARS) : text, "search"));
425
+ }
426
+ return this.docOf(n, doc, text);
427
+ }
428
+ docOf(n, doc, knownText) {
429
+ const row = this.stmt(`SELECT rowid, * FROM ${n.docs} WHERE doc = ?`).get(doc);
430
+ if (row === void 0) return void 0;
431
+ return this.toDoc(n, row, knownText);
432
+ }
433
+ toDoc(n, row, knownText) {
434
+ const labels = parseJson$1(row.labels, void 0);
435
+ return {
436
+ doc: row.doc,
437
+ kind: row.kind,
438
+ title: row.title,
439
+ text: knownText ?? this.textOf(n, row.text_sha) ?? "",
440
+ url: row.url,
441
+ meta: parseJson$1(row.meta, {}),
442
+ captures: parseJson$1(row.captures, []),
443
+ capture_count: row.capture_count,
444
+ capture: row.capture,
445
+ blob: row.blob,
446
+ fetched_at: row.fetched_at,
447
+ recipe: row.recipe,
448
+ rendered: row.rendered === 1,
449
+ ai_input: row.ai_input,
450
+ cut: row.full_sha !== null,
451
+ ...row.scope !== null ? { scope: row.scope } : {},
452
+ ...labels !== void 0 ? { labels } : {},
453
+ updated_at: row.updated_at
454
+ };
455
+ }
456
+ versionTextOf(n, row) {
457
+ return {
458
+ ...toVersion(row),
459
+ text: this.textOf(n, row.text_sha) ?? "",
460
+ full_text: this.textOf(n, row.full_sha),
461
+ meta: parseJson$1(this.textOf(n, row.meta_sha), {})
462
+ };
463
+ }
464
+ get(doc) {
465
+ return this.live((n) => this.docOf(n, doc));
466
+ }
467
+ /**
468
+ * This method adds one version of a document, and it gives the current document. The rules of
469
+ * the class choose the current version. An upsert of a known capture replaces that version.
470
+ */
471
+ upsert(input, now) {
472
+ return this.live((n) => this.write(() => {
473
+ this.putVersion(n, input, now);
474
+ return this.recompute(n, input.doc, now);
475
+ }));
476
+ }
477
+ /** This method gives the versions of a document, the newest first. */
478
+ versions(doc, options = {}) {
479
+ const limit = Math.max(1, Math.floor(options.limit ?? 100));
480
+ return this.live((n) => this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? ORDER BY fetched_at DESC, capture DESC LIMIT ?`).all(doc, limit).map(toVersion));
481
+ }
482
+ /**
483
+ * This method gives the text of one version of a document. With no capture, it gives the current
484
+ * version. It also gives the text of a held-back version: the caller applies `isHeldBack` to the
485
+ * `ai_input` of the version.
486
+ */
487
+ versionText(doc, capture) {
488
+ return this.live((n) => {
489
+ const id = capture ?? this.docOf(n, doc)?.capture;
490
+ if (id === void 0) return void 0;
491
+ const row = this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND capture = ?`).get(doc, id);
492
+ return row === void 0 ? void 0 : this.versionTextOf(n, row);
493
+ });
494
+ }
495
+ /**
496
+ * This method gives the version of a document that was current at a time (ISO 8601). It applies
497
+ * the rules of the class to the versions that the crawler fetched at that time or before. Thus
498
+ * a later capture does not change the answer. It also gives a held-back version: its `ai_input`
499
+ * is `no` when the document was held back at that time, and the caller applies `isHeldBack`.
500
+ */
501
+ versionAt(doc, at) {
502
+ return this.live((n) => {
503
+ const first = this.stmt(`SELECT min(fetched_at) AS first FROM ${n.versions} WHERE doc = ?`).get(doc)?.first ?? void 0;
504
+ const choice = this.choose(n, doc, at);
505
+ if (choice === void 0) return {
506
+ version: void 0,
507
+ first
508
+ };
509
+ const version = this.versionTextOf(n, choice.winner.row);
510
+ return {
511
+ version: choice.aiInput === "no" ? {
512
+ ...version,
513
+ ai_input: "no"
514
+ } : version,
515
+ first
516
+ };
517
+ });
518
+ }
519
+ /**
520
+ * This method gives the texts of the earlier versions of a document, the newest first. Each
521
+ * text comes one time, with the newest version that has it. It skips the current text. It also
522
+ * gives held-back texts: the caller applies `isHeldBack` to the `ai_input` of each version.
523
+ */
524
+ earlierTexts(doc, options = {}) {
525
+ const limit = Math.max(1, Math.floor(options.limit ?? 50));
526
+ return this.live((n) => {
527
+ const head = this.stmt(`SELECT text_sha FROM ${n.docs} WHERE doc = ?`).get(doc);
528
+ if (head === void 0) return [];
529
+ const rows = this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND text_sha != ? ORDER BY fetched_at DESC, capture DESC`).all(doc, head.text_sha);
530
+ const seen = /* @__PURE__ */ new Set();
531
+ const out = [];
532
+ for (const row of rows) {
533
+ if (seen.has(row.text_sha)) continue;
534
+ seen.add(row.text_sha);
535
+ out.push(this.versionTextOf(n, row));
536
+ if (out.length >= limit) break;
537
+ }
538
+ return out;
539
+ });
540
+ }
541
+ /** This method tells if a capture gave a version of a document. */
542
+ hasVersion(capture) {
543
+ return this.live((n) => this.stmt(`SELECT 1 AS found FROM ${n.versions} WHERE capture = ? LIMIT 1`).get(capture) !== void 0);
544
+ }
545
+ /**
546
+ * This method gives the hash of the current text of documents, with no text. It gives the
547
+ * documents of `docs`, or each document after `after` in the sequence of the ids, to `limit`.
548
+ */
549
+ digests(options = {}) {
550
+ return this.live((n) => {
551
+ if (options.docs !== void 0) {
552
+ if (options.docs.length === 0) return [];
553
+ return this.stmt(`SELECT doc, kind, ai_input, text_sha FROM ${n.docs} WHERE doc IN (SELECT value FROM json_each(?))`).all(JSON.stringify(options.docs));
554
+ }
555
+ const limit = Math.max(1, Math.floor(options.limit ?? 500));
556
+ return this.stmt(`SELECT doc, kind, ai_input, text_sha FROM ${n.docs} WHERE doc > ? ORDER BY doc LIMIT ?`).all(options.after ?? "", limit);
557
+ });
558
+ }
559
+ /** This method gives the newest fetch time of the cache entries, or undefined for an empty index. */
560
+ newestFetch() {
561
+ return this.live((n) => this.stmt(`SELECT max(fetched_at) AS newest FROM ${n.latest}`).get()?.newest ?? void 0);
562
+ }
563
+ /** This method gives the current documents that a capture gave. */
564
+ docsOfCapture(capture) {
565
+ return this.live((n) => this.stmt(`SELECT DISTINCT doc FROM ${n.versions} WHERE capture = ?`).all(capture).map((row) => this.docOf(n, row.doc)).filter((doc) => doc !== void 0));
566
+ }
567
+ /**
568
+ * This method searches the documents with BM25, the best first. The syntax of the query is the
569
+ * syntax of `parseQuery`. It first asks for each part. When that gives too few documents, it
570
+ * adds the documents that match one part. A document that asked not to be AI input is not in
571
+ * the results, so a search cannot show its text. The option `rank` chooses the ranking (`RankMode`).
572
+ */
573
+ find(q, options) {
574
+ const rank = rankModeOf(options.rank);
575
+ const query = parseQuery(q);
576
+ if (query === void 0) return [];
577
+ const limit = Math.max(1, Math.min(1e3, Math.floor(options.limit) || 10));
578
+ return this.live((n) => {
579
+ const kindClause = options.kind === void 0 || options.kind === null ? "" : "AND d.kind = ?";
580
+ const sql = `SELECT d.rowid AS rowid, d.*, bm25(${n.fts}, 3.0, 1.0) AS rank
581
+ FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
582
+ WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' ${kindClause}
583
+ ORDER BY rank LIMIT ?`;
584
+ const run = (match) => {
585
+ const params = [match];
586
+ if (kindClause.length > 0) params.push(options.kind);
587
+ params.push(limit);
588
+ return this.stmt(sql).all(...params);
589
+ };
590
+ const rows = run(query.all);
591
+ if (rows.length < limit && query.parts > 1) {
592
+ const seen = new Set(rows.map((row) => row.doc));
593
+ for (const row of run(rank === "fast" ? this.rareParts(n, query.anyParts) : query.any)) {
594
+ if (rows.length >= limit) break;
595
+ if (!seen.has(row.doc)) rows.push(row);
596
+ }
597
+ }
598
+ return rows.map((row) => ({
599
+ doc: row.doc,
600
+ kind: row.kind,
601
+ title: row.title,
602
+ url: row.url,
603
+ snippet: snippetOf(this.textOf(n, row.text_sha) ?? "", query.terms),
604
+ score: Math.round(-row.rank * 1e3) / 1e3,
605
+ ai_input: row.ai_input,
606
+ fetched_at: row.fetched_at,
607
+ capture: row.capture
608
+ }));
609
+ });
610
+ }
611
+ /**
612
+ * This method scores a set of documents against a query, with the best first. It leaves out a
613
+ * document that matches no term, and a document that asked not to be AI input. It ranks the
614
+ * results of a source again, because the order of the source is only as good as its search engine.
615
+ * In the mode `fast`, it also leaves out a document that holds only common parts (`RankMode`).
616
+ */
617
+ rankWithin(q, docs, options = {}) {
618
+ const rank = rankModeOf(options.rank);
619
+ const query = parseQuery(q);
620
+ if (query === void 0 || docs.length === 0) return [];
621
+ return this.live((n) => this.stmt(`SELECT d.doc AS doc, bm25(${n.fts}, 3.0, 1.0) AS rank
622
+ FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
623
+ WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' AND d.doc IN (SELECT value FROM json_each(?))
624
+ ORDER BY rank`).all(rank === "fast" ? this.rareParts(n, query.anyParts) : query.any, JSON.stringify(docs)).map((row) => ({
625
+ doc: row.doc,
626
+ score: Math.round(-row.rank * 1e3) / 1e3
627
+ })));
628
+ }
629
+ /**
630
+ * This method gives the form of a query that needs one part, without the common parts (the mode
631
+ * `fast` of `RankMode`). It counts the documents that hold each part. When each part is common,
632
+ * it keeps the rarest part.
633
+ */
634
+ rareParts(n, parts) {
635
+ if (parts.length < 2) return parts.join(" OR ");
636
+ const total = this.stmt(`SELECT count(*) AS n FROM ${n.docs}`).get().n;
637
+ const counted = parts.map((part) => ({
638
+ part,
639
+ count: this.stmt(`SELECT count(*) AS n FROM ${n.fts} WHERE ${n.fts} MATCH ?`).get(part).n
640
+ }));
641
+ const rare = counted.filter((one) => one.count <= FAST_RANK_SHARE * total);
642
+ if (rare.length > 0) return rare.map((one) => one.part).join(" OR ");
643
+ return counted.reduce((best, one) => one.count < best.count ? one : best).part;
644
+ }
645
+ /** This method gives the cache entry of a URL. The key is canonical, so two spellings of one URL give one entry. */
646
+ latest(url, variant) {
647
+ const key = keyOf(url, variant);
648
+ return this.live((n) => {
649
+ const row = this.stmt(`SELECT * FROM ${n.latest} WHERE url = ? AND variant = ?`).get(key.url, key.variant);
650
+ return row === void 0 ? void 0 : toLatest(row);
651
+ });
652
+ }
653
+ putLatest(n, entry) {
654
+ const key = keyOf(entry.url, entry.variant);
655
+ this.stmt(`INSERT INTO ${n.latest} (url, variant, capture, blob, etag, last_modified, content_type, fetched_at,
656
+ status, ai_input, location, truncated, fresh_until, vary_key)
657
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
658
+ ON CONFLICT(url, variant) DO UPDATE SET capture = excluded.capture, blob = excluded.blob,
659
+ etag = excluded.etag, last_modified = excluded.last_modified, content_type = excluded.content_type,
660
+ fetched_at = excluded.fetched_at, status = excluded.status, ai_input = excluded.ai_input,
661
+ location = excluded.location, truncated = excluded.truncated, fresh_until = excluded.fresh_until,
662
+ vary_key = excluded.vary_key
663
+ WHERE excluded.fetched_at >= ${n.latest}.fetched_at`).run(key.url, key.variant, entry.capture, entry.blob, entry.etag, entry.last_modified, entry.content_type, entry.fetched_at, entry.status, entry.ai_input, entry.location ?? null, entry.truncated === true ? 1 : null, entry.fresh_until ?? null, entry.vary_key ?? null);
664
+ }
665
+ /** This method keeps the last answer for a URL. An entry that is older than the stored entry changes nothing. */
666
+ setLatest(entry) {
667
+ this.live((n) => this.write(() => this.putLatest(n, entry)));
668
+ }
669
+ counts() {
670
+ return this.live((n) => ({
671
+ docs: this.stmt(`SELECT count(*) AS n FROM ${n.docs}`).get().n,
672
+ urls: this.stmt(`SELECT count(*) AS n FROM ${n.latest}`).get().n
673
+ }));
674
+ }
675
+ /** This method empties the current generation. A rebuild uses `beginRebuild`, which keeps the index until its commit. */
676
+ reset() {
677
+ this.live((n) => this.write(() => {
678
+ for (const table of [
679
+ n.fts,
680
+ n.docs,
681
+ n.versions,
682
+ n.texts,
683
+ n.latest
684
+ ]) this.db.exec(`DELETE FROM ${table}`);
685
+ }));
686
+ }
687
+ /**
688
+ * This method starts a rebuild into a new generation of the tables. The current generation
689
+ * answers each read and takes each write until the commit. A failed rebuild thus leaves the
690
+ * index as it was.
691
+ */
692
+ beginRebuild(options = {}) {
693
+ const batchSize = Math.max(1, Math.floor(options.batch ?? 500));
694
+ const generation = this.write(() => {
695
+ const next = Number(this.getMeta("next_generation") ?? "2");
696
+ this.setMeta("next_generation", String(next + 1));
697
+ this.setMeta(`building:${String(next)}`, (/* @__PURE__ */ new Date()).toISOString());
698
+ this.db.exec(ddlOf(namesOf(next)));
699
+ return next;
700
+ });
701
+ const n = namesOf(generation);
702
+ const versions = [];
703
+ const latest = /* @__PURE__ */ new Map();
704
+ let open = true;
705
+ const keyText = (url, variant) => {
706
+ const key = keyOf(url, variant);
707
+ return `${key.variant} ${key.url}`;
708
+ };
709
+ const flush = () => {
710
+ if (!open) throw new Error("the rebuild is over");
711
+ if (versions.length === 0 && latest.size === 0) return;
712
+ this.write(() => {
713
+ const touched = /* @__PURE__ */ new Map();
714
+ for (const { input, now } of versions) {
715
+ this.putVersion(n, input, now);
716
+ touched.set(input.doc, now);
717
+ }
718
+ for (const entry of latest.values()) this.putLatest(n, entry);
719
+ for (const [doc, now] of touched) this.recompute(n, doc, now);
720
+ });
721
+ versions.length = 0;
722
+ latest.clear();
723
+ };
724
+ /** This function copies the texts of the versions of the current generation that agree with `where`. */
725
+ const copyTexts = (current, where, params) => {
726
+ for (const column of [
727
+ "text_sha",
728
+ "meta_sha",
729
+ "full_sha"
730
+ ]) this.db.prepare(`INSERT OR IGNORE INTO ${n.texts} (sha, body)
731
+ SELECT t.sha, t.body FROM ${current.texts} t
732
+ WHERE t.sha IN (SELECT ${column} FROM ${current.versions} WHERE ${where})`).run(...params);
733
+ };
734
+ return {
735
+ upsert: (input, now) => {
736
+ if (!open) throw new Error("the rebuild is over");
737
+ versions.push({
738
+ input,
739
+ now
740
+ });
741
+ if (versions.length >= batchSize) flush();
742
+ },
743
+ setLatest: (entry) => {
744
+ if (!open) throw new Error("the rebuild is over");
745
+ const key = keyText(entry.url, entry.variant);
746
+ const before = latest.get(key);
747
+ if (before === void 0 || entry.fetched_at >= before.fetched_at) latest.set(key, entry);
748
+ if (latest.size >= batchSize) flush();
749
+ },
750
+ latest: (url, variant) => {
751
+ const waiting = latest.get(keyText(url, variant));
752
+ if (waiting !== void 0) return waiting;
753
+ const key = keyOf(url, variant);
754
+ const row = this.stmt(`SELECT * FROM ${n.latest} WHERE url = ? AND variant = ?`).get(key.url, key.variant);
755
+ return row === void 0 ? void 0 : toLatest(row);
756
+ },
757
+ flush,
758
+ carry: (recipes) => {
759
+ flush();
760
+ const known = JSON.stringify([...recipes]);
761
+ const current = namesOf(this.generation());
762
+ const docs = this.db.prepare(`SELECT DISTINCT doc FROM ${current.versions} WHERE recipe NOT IN (SELECT value FROM json_each(?))`).all(known);
763
+ const where = "recipe NOT IN (SELECT value FROM json_each(?)) AND doc IN (SELECT value FROM json_each(?))";
764
+ for (let at = 0; at < docs.length; at += batchSize) {
765
+ const part = JSON.stringify(docs.slice(at, at + batchSize).map((row) => row.doc));
766
+ this.write(() => {
767
+ copyTexts(current, where, [known, part]);
768
+ this.db.prepare(`INSERT OR IGNORE INTO ${n.versions} SELECT * FROM ${current.versions} WHERE ${where}`).run(known, part);
769
+ const now = (/* @__PURE__ */ new Date()).toISOString();
770
+ for (const doc of JSON.parse(part)) this.recompute(n, doc, now);
771
+ });
772
+ }
773
+ return docs.length;
774
+ },
775
+ commit: (since) => {
776
+ flush();
777
+ open = false;
778
+ return this.write(() => {
779
+ const current = this.generation();
780
+ const old = namesOf(current);
781
+ copyTexts(old, "fetched_at >= ?", [since]);
782
+ const added = this.db.prepare(`SELECT DISTINCT doc FROM ${old.versions} WHERE fetched_at >= ?`).all(since);
783
+ this.db.prepare(`INSERT OR IGNORE INTO ${n.versions} SELECT * FROM ${old.versions} WHERE fetched_at >= ?`).run(since);
784
+ const now = (/* @__PURE__ */ new Date()).toISOString();
785
+ for (const { doc } of added) this.recompute(n, doc, now);
786
+ for (const row of this.db.prepare(`SELECT * FROM ${old.latest} WHERE fetched_at >= ?`).all(since)) this.putLatest(n, toLatest(row));
787
+ this.setMeta("generation", String(generation));
788
+ this.db.prepare("DELETE FROM index_meta WHERE key = ?").run(`building:${String(generation)}`);
789
+ this.dropGeneration(current);
790
+ this.empty = false;
791
+ return {
792
+ docs: this.db.prepare(`SELECT count(*) AS n FROM ${n.docs}`).get().n,
793
+ urls: this.db.prepare(`SELECT count(*) AS n FROM ${n.latest}`).get().n
794
+ };
795
+ });
796
+ },
797
+ abort: () => {
798
+ open = false;
799
+ versions.length = 0;
800
+ latest.clear();
801
+ if (this.closed) return;
802
+ this.write(() => this.dropGeneration(generation));
803
+ }
804
+ };
805
+ }
806
+ /** This method closes the file. A second close does nothing. */
807
+ close() {
808
+ if (this.closed) return;
809
+ this.closed = true;
810
+ this.statements.clear();
811
+ this.db.close();
812
+ }
813
+ };
814
+ //#endregion
815
+ //#region src/sitemaps.ts
816
+ /** These are the default limits of the reader, and the maximum length of one value. */
817
+ const SITEMAP_LIMITS = {
818
+ maxEntries: 5e4,
819
+ maxDepth: 32,
820
+ /** A value (a URL or a date) longer than this number of characters is not valid. */
821
+ maxValue: 4096
822
+ };
823
+ const CHANGEFREQ = /* @__PURE__ */ new Set([
824
+ "always",
825
+ "hourly",
826
+ "daily",
827
+ "weekly",
828
+ "monthly",
829
+ "yearly",
830
+ "never"
831
+ ]);
832
+ const SITEMAP_NS = [
833
+ "http://www.sitemaps.org/schemas/sitemap/0.9",
834
+ "http://www.google.com/schemas/sitemap/0.84",
835
+ "http://www.google.com/schemas/sitemap/0.9"
836
+ ];
837
+ const ATOM_NS = ["http://www.w3.org/2005/Atom"];
838
+ const RSS1_NS = ["http://purl.org/rss/1.0/"];
839
+ const DC_NS = ["http://purl.org/dc/elements/1.1/"];
840
+ const RDF_NS = ["http://www.w3.org/1999/02/22-rdf-syntax-ns#"];
841
+ const XML_ENTITIES = {
842
+ amp: "&",
843
+ lt: "<",
844
+ gt: ">",
845
+ quot: "\"",
846
+ apos: "'"
847
+ };
848
+ /** A tag keeps this number of attributes at most. The reader reads the others and keeps nothing of them. */
849
+ const MAX_ATTRIBUTES = 64;
850
+ const NAME$1 = /^[A-Za-z_][\w.:-]*$/;
851
+ const W3C_DATE = /^\d{4}(?:-\d{2}(?:-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?)?)?$/;
852
+ const MAIL_DATE = /^(?:[A-Za-z]{3},?\s+)?\d{1,2}\s+[A-Za-z]{3,9}\s+\d{2,4}\s+\d{1,2}:\d{2}(?::\d{2})?(?:\s*(?:[A-Za-z]{1,5}|[+-]\d{4}))?$/;
853
+ const short = (name) => name === void 0 ? "nothing" : name.length > 40 ? `${name.slice(0, 40)}...` : name;
854
+ /**
855
+ * This function changes a date of a sitemap or a feed into ISO 8601. It reads the W3C form of the
856
+ * sitemap protocol and Atom, and the mail form of RSS. A time with no zone is UTC. It gives null
857
+ * for a text that it cannot read.
858
+ */
859
+ const feedDate = (value) => {
860
+ const text = value.trim();
861
+ if (text.length === 0 || text.length > 64) return null;
862
+ let parse;
863
+ const w3c = W3C_DATE.exec(text);
864
+ if (w3c !== null) {
865
+ const iso = text.replace(" ", "T");
866
+ parse = iso.includes("T") && w3c[1] === void 0 ? `${iso}Z` : iso;
867
+ } else if (MAIL_DATE.test(text)) parse = text;
868
+ if (parse === void 0) return null;
869
+ const ms = Date.parse(parse);
870
+ return Number.isFinite(ms) && Math.abs(ms) < 864e13 ? new Date(ms).toISOString() : null;
871
+ };
872
+ /** This function gives the canonical form of an http(s) URL, or undefined. A relative URL needs a base. */
873
+ const webUrl = (value, base) => {
874
+ const text = value.trim();
875
+ if (text.length === 0 || text.length > SITEMAP_LIMITS.maxValue) return void 0;
876
+ try {
877
+ const url = base === void 0 ? new URL(text) : new URL(text, base);
878
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
879
+ return canonicalUrl(url.href);
880
+ } catch {
881
+ return;
882
+ }
883
+ };
884
+ /** This function decodes the five entities of XML and the character references. It gives undefined for each other entity. */
885
+ const decodeText = (raw, meter) => {
886
+ meter.steps += raw.length;
887
+ if (!raw.includes("&")) return raw;
888
+ let out = "";
889
+ let at = 0;
890
+ for (;;) {
891
+ const amp = raw.indexOf("&", at);
892
+ if (amp === -1) return out + raw.slice(at);
893
+ out += raw.slice(at, amp);
894
+ const semi = raw.indexOf(";", amp + 1);
895
+ if (semi === -1 || semi - amp > 10) return void 0;
896
+ const name = raw.slice(amp + 1, semi);
897
+ let char = XML_ENTITIES[name];
898
+ if (char === void 0 && /^#(?:\d{1,7}|x[0-9a-fA-F]{1,6})$/.test(name)) {
899
+ const code = name[1] === "x" ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1));
900
+ if (code > 0 && code <= 1114111 && (code < 55296 || code > 57343)) char = String.fromCodePoint(code);
901
+ }
902
+ if (char === void 0) return void 0;
903
+ out += char;
904
+ at = semi + 1;
905
+ }
906
+ };
907
+ const isSpace = (code) => code === 32 || code === 9 || code === 10 || code === 13;
908
+ /** This function reads a start tag from the position after its `<`. It gives undefined for a tag that is not well formed. */
909
+ const readTag = (text, from, meter) => {
910
+ const tag = tagAt(text, from);
911
+ meter.steps += (tag?.end ?? text.length) - from;
912
+ return tag;
913
+ };
914
+ const tagAt = (text, from) => {
915
+ const n = text.length;
916
+ let at = from;
917
+ while (at < n) {
918
+ const code = text.charCodeAt(at);
919
+ if (isSpace(code) || code === 47 || code === 62) break;
920
+ at += 1;
921
+ }
922
+ const name = text.slice(from, Math.min(at, from + 256));
923
+ if (at - from > 256 || !NAME$1.test(name)) return void 0;
924
+ const attributes = [];
925
+ for (;;) {
926
+ while (at < n && isSpace(text.charCodeAt(at))) at += 1;
927
+ if (at >= n) return void 0;
928
+ const code = text.charCodeAt(at);
929
+ if (code === 62) return {
930
+ name,
931
+ attributes,
932
+ selfClosing: false,
933
+ end: at + 1
934
+ };
935
+ if (code === 47) return text.charCodeAt(at + 1) === 62 ? {
936
+ name,
937
+ attributes,
938
+ selfClosing: true,
939
+ end: at + 2
940
+ } : void 0;
941
+ const start = at;
942
+ while (at < n) {
943
+ const next = text.charCodeAt(at);
944
+ if (isSpace(next) || next === 61 || next === 62 || next === 47) break;
945
+ at += 1;
946
+ }
947
+ const key = text.slice(start, Math.min(at, start + 256));
948
+ if (at - start > 256 || !NAME$1.test(key)) return void 0;
949
+ while (at < n && isSpace(text.charCodeAt(at))) at += 1;
950
+ if (text.charCodeAt(at) !== 61) return void 0;
951
+ at += 1;
952
+ while (at < n && isSpace(text.charCodeAt(at))) at += 1;
953
+ const quote = text[at];
954
+ if (quote !== "\"" && quote !== "'") return void 0;
955
+ const close = text.indexOf(quote, at + 1);
956
+ if (close === -1) return void 0;
957
+ if (attributes.length < MAX_ATTRIBUTES && close - at <= SITEMAP_LIMITS.maxValue) attributes.push([key, text.slice(at + 1, close)]);
958
+ at = close + 1;
959
+ }
960
+ };
961
+ /**
962
+ * This function finds the end of a declaration (`<!DOCTYPE ...>`) with its internal subset. It
963
+ * gives the position after the `>`, or -1, and it tells if the declaration has an internal subset.
964
+ */
965
+ const declarationEnd = (text, from, meter) => {
966
+ let depth = 0;
967
+ let quote = 0;
968
+ let subset = false;
969
+ for (let at = from; at < text.length; at += 1) {
970
+ meter.steps += 1;
971
+ const code = text.charCodeAt(at);
972
+ if (quote !== 0) {
973
+ if (code === quote) quote = 0;
974
+ } else if (code === 34 || code === 39) quote = code;
975
+ else if (code === 91) {
976
+ depth += 1;
977
+ subset = true;
978
+ } else if (code === 93) depth = Math.max(0, depth - 1);
979
+ else if (code === 62 && depth === 0) return {
980
+ end: at + 1,
981
+ subset
982
+ };
983
+ }
984
+ return {
985
+ end: -1,
986
+ subset
987
+ };
988
+ };
989
+ /** This function reads a text sitemap: each line that is an absolute http(s) URL is an entry. */
990
+ const readLines = (text, maxEntries, meter) => {
991
+ const entries = [];
992
+ let invalid = 0;
993
+ let at = 0;
994
+ while (at < text.length) {
995
+ let end = text.indexOf("\n", at);
996
+ if (end === -1) end = text.length;
997
+ meter.steps += end - at + 1;
998
+ const line = end - at > SITEMAP_LIMITS.maxValue + 2 ? "-" : text.slice(at, end).trim();
999
+ at = end + 1;
1000
+ if (line.length === 0) continue;
1001
+ const url = webUrl(line, void 0);
1002
+ if (url === void 0) invalid += 1;
1003
+ else entries.push({
1004
+ url,
1005
+ lastmod: null,
1006
+ changefreq: null
1007
+ });
1008
+ if (entries.length >= maxEntries) {
1009
+ const complete = at >= text.length;
1010
+ return {
1011
+ kind: "text",
1012
+ entries,
1013
+ invalid,
1014
+ complete,
1015
+ notes: complete ? [] : [`the reader stopped at ${String(maxEntries)} entries`]
1016
+ };
1017
+ }
1018
+ }
1019
+ return {
1020
+ kind: "text",
1021
+ entries,
1022
+ invalid,
1023
+ complete: true,
1024
+ notes: []
1025
+ };
1026
+ };
1027
+ /**
1028
+ * This function reads a sitemap, a sitemap index, a feed or a text sitemap, and it counts its
1029
+ * steps in `meter`. The function `readSitemap` uses it. A test uses it to show that the time is linear.
1030
+ */
1031
+ const scanSitemap = (text, base, limits = {}, meter = { steps: 0 }) => {
1032
+ const maxEntries = Math.max(1, Math.floor(limits.maxEntries ?? SITEMAP_LIMITS.maxEntries));
1033
+ const maxDepth = Math.max(1, Math.floor(limits.maxDepth ?? SITEMAP_LIMITS.maxDepth));
1034
+ let at = text.charCodeAt(0) === 65279 ? 1 : 0;
1035
+ while (at < text.length && isSpace(text.charCodeAt(at))) at += 1;
1036
+ if (at < text.length && text[at] !== "<") return readLines(text.slice(at), maxEntries, meter);
1037
+ const n = text.length;
1038
+ const notes = [];
1039
+ const entries = [];
1040
+ const stack = [];
1041
+ const prefixes = /* @__PURE__ */ new Map();
1042
+ let kind = "unknown";
1043
+ let invalid = 0;
1044
+ let complete = true;
1045
+ let done = false;
1046
+ let feedBase = base;
1047
+ let entry;
1048
+ let field;
1049
+ const stop = (note) => {
1050
+ complete = false;
1051
+ done = true;
1052
+ notes.push(note);
1053
+ };
1054
+ /** This function tells if a name is a local name of one of the namespaces, or the local name with no prefix. */
1055
+ const named = (qname, local, spaces) => {
1056
+ const colon = qname.indexOf(":");
1057
+ if (colon === -1) return qname === local;
1058
+ return qname.slice(colon + 1) === local && spaces.includes(prefixes.get(qname.slice(0, colon)) ?? "");
1059
+ };
1060
+ const attribute = (tag, local, spaces) => {
1061
+ for (const [key, raw] of tag.attributes) if (spaces === void 0 ? key === local : named(key, local, spaces) && key.includes(":")) return decodeText(raw, meter);
1062
+ };
1063
+ const fieldOf = (qname) => {
1064
+ switch (kind) {
1065
+ case "urlset":
1066
+ case "sitemapindex":
1067
+ if (named(qname, "loc", SITEMAP_NS)) return "loc";
1068
+ if (named(qname, "lastmod", SITEMAP_NS)) return "lastmod";
1069
+ return kind === "urlset" && named(qname, "changefreq", SITEMAP_NS) ? "changefreq" : void 0;
1070
+ case "rss":
1071
+ if (qname === "link") return "link";
1072
+ if (qname === "guid") return "guid";
1073
+ return qname === "pubDate" ? "date" : void 0;
1074
+ case "atom": return named(qname, "updated", ATOM_NS) || named(qname, "published", ATOM_NS) ? "date" : void 0;
1075
+ case "rdf":
1076
+ if (named(qname, "link", RSS1_NS)) return "link";
1077
+ return named(qname, "date", DC_NS) ? "date" : void 0;
1078
+ default: return;
1079
+ }
1080
+ };
1081
+ const isEntry = (qname, depth) => {
1082
+ switch (kind) {
1083
+ case "urlset": return depth === 2 && named(qname, "url", SITEMAP_NS);
1084
+ case "sitemapindex": return depth === 2 && named(qname, "sitemap", SITEMAP_NS);
1085
+ case "rss": return depth === 3 && qname === "item" && stack[1] === "channel";
1086
+ case "atom": return depth === 2 && named(qname, "entry", ATOM_NS);
1087
+ case "rdf": return depth === 2 && named(qname, "item", RSS1_NS);
1088
+ default: return false;
1089
+ }
1090
+ };
1091
+ const resolveBase = (tag, against) => {
1092
+ const declared = attribute(tag, "xml:base");
1093
+ if (declared === void 0) return against;
1094
+ try {
1095
+ return new URL(declared.trim(), against).href;
1096
+ } catch {
1097
+ return against;
1098
+ }
1099
+ };
1100
+ const open = (tag, depth) => {
1101
+ for (const [key, raw] of tag.attributes) if (key.startsWith("xmlns:")) {
1102
+ const uri = decodeText(raw, meter);
1103
+ if (uri !== void 0) prefixes.set(key.slice(6), uri.trim());
1104
+ }
1105
+ if (depth === 1) {
1106
+ const local = tag.name.slice(tag.name.indexOf(":") + 1);
1107
+ if (local === "urlset" || local === "sitemapindex" || local === "rss") kind = local;
1108
+ else if (local === "feed") kind = "atom";
1109
+ else if (local === "RDF") kind = "rdf";
1110
+ else {
1111
+ stop(`not a sitemap or a feed: the root element is <${short(tag.name)}>`);
1112
+ return;
1113
+ }
1114
+ feedBase = resolveBase(tag, base);
1115
+ return;
1116
+ }
1117
+ if (entry === void 0) {
1118
+ if (isEntry(tag.name, depth)) entry = {
1119
+ depth,
1120
+ base: kind === "atom" ? resolveBase(tag, feedBase) : feedBase,
1121
+ loc: void 0,
1122
+ link: void 0,
1123
+ guid: void 0,
1124
+ lastmod: void 0,
1125
+ changefreq: void 0,
1126
+ about: kind === "rdf" ? attribute(tag, "about", RDF_NS) : void 0,
1127
+ linkPage: false
1128
+ };
1129
+ return;
1130
+ }
1131
+ if (field !== void 0) {
1132
+ field.bad = true;
1133
+ return;
1134
+ }
1135
+ if (depth !== entry.depth + 1) return;
1136
+ if (kind === "atom" && named(tag.name, "link", ATOM_NS)) {
1137
+ const rel = (attribute(tag, "rel") ?? "alternate").trim().toLowerCase();
1138
+ const href = attribute(tag, "href");
1139
+ const type = (attribute(tag, "type") ?? "text/html").trim().toLowerCase();
1140
+ const page = type.startsWith("text/html") || type.startsWith("application/xhtml");
1141
+ if (rel === "alternate" && href !== void 0 && (entry.link === void 0 || page && !entry.linkPage)) {
1142
+ entry.link = href;
1143
+ entry.linkPage = page;
1144
+ }
1145
+ return;
1146
+ }
1147
+ const which = fieldOf(tag.name);
1148
+ if (which === void 0) return;
1149
+ if (which === "guid") {
1150
+ if ((attribute(tag, "isPermaLink") ?? "true").trim().toLowerCase() === "false") return;
1151
+ }
1152
+ field = {
1153
+ field: which,
1154
+ depth,
1155
+ parts: [],
1156
+ length: 0,
1157
+ bad: false
1158
+ };
1159
+ };
1160
+ const finishEntry = (current) => {
1161
+ const raw = kind === "urlset" || kind === "sitemapindex" ? current.loc : current.link ?? (kind === "rdf" ? current.about : current.guid);
1162
+ const url = raw === void 0 ? void 0 : webUrl(raw, kind === "urlset" || kind === "sitemapindex" ? void 0 : current.base);
1163
+ if (url === void 0) {
1164
+ invalid += 1;
1165
+ return;
1166
+ }
1167
+ const freq = current.changefreq?.trim().toLowerCase();
1168
+ entries.push({
1169
+ url,
1170
+ lastmod: current.lastmod === void 0 ? null : feedDate(current.lastmod),
1171
+ changefreq: freq !== void 0 && CHANGEFREQ.has(freq) && kind === "urlset" ? freq : null
1172
+ });
1173
+ if (entries.length >= maxEntries) stop(`the reader stopped at ${String(maxEntries)} entries`);
1174
+ };
1175
+ const close = (depth) => {
1176
+ if (field !== void 0 && depth === field.depth) {
1177
+ const finished = field;
1178
+ field = void 0;
1179
+ if (finished.bad || entry === void 0) return;
1180
+ const value = finished.parts.join("").trim();
1181
+ if (value.length === 0) return;
1182
+ if (finished.field === "loc") entry.loc ??= value;
1183
+ else if (finished.field === "link") entry.link ??= value;
1184
+ else if (finished.field === "guid") entry.guid ??= value;
1185
+ else if (finished.field === "lastmod" || finished.field === "date") entry.lastmod ??= value;
1186
+ else entry.changefreq ??= value;
1187
+ return;
1188
+ }
1189
+ if (entry !== void 0 && depth === entry.depth) {
1190
+ const finished = entry;
1191
+ entry = void 0;
1192
+ finishEntry(finished);
1193
+ }
1194
+ };
1195
+ const addText = (value) => {
1196
+ if (field === void 0 || field.bad) return;
1197
+ if (value === void 0 || field.length + value.length > SITEMAP_LIMITS.maxValue) {
1198
+ field.bad = true;
1199
+ return;
1200
+ }
1201
+ field.parts.push(value);
1202
+ field.length += value.length;
1203
+ };
1204
+ while (at < n && !done) {
1205
+ meter.steps += 1;
1206
+ const lt = text.indexOf("<", at);
1207
+ const end = lt === -1 ? n : lt;
1208
+ if (end > at) {
1209
+ meter.steps += end - at;
1210
+ if (field !== void 0 && !field.bad) addText(end - at > SITEMAP_LIMITS.maxValue ? void 0 : decodeText(text.slice(at, end), meter));
1211
+ }
1212
+ if (lt === -1) break;
1213
+ at = lt;
1214
+ if (text.startsWith("<!--", at)) {
1215
+ const endOf = text.indexOf("-->", at + 4);
1216
+ if (endOf === -1) {
1217
+ stop("a comment does not end");
1218
+ break;
1219
+ }
1220
+ meter.steps += endOf + 3 - at;
1221
+ at = endOf + 3;
1222
+ continue;
1223
+ }
1224
+ if (text.startsWith("<![CDATA[", at)) {
1225
+ const endOf = text.indexOf("]]>", at + 9);
1226
+ if (endOf === -1) {
1227
+ stop("a CDATA section does not end");
1228
+ break;
1229
+ }
1230
+ meter.steps += endOf + 3 - at;
1231
+ if (field !== void 0 && !field.bad) addText(endOf - at - 9 > SITEMAP_LIMITS.maxValue ? void 0 : text.slice(at + 9, endOf));
1232
+ at = endOf + 3;
1233
+ continue;
1234
+ }
1235
+ if (text.startsWith("<?", at)) {
1236
+ const endOf = text.indexOf("?>", at + 2);
1237
+ if (endOf === -1) {
1238
+ stop("a processing instruction does not end");
1239
+ break;
1240
+ }
1241
+ meter.steps += endOf + 2 - at;
1242
+ at = endOf + 2;
1243
+ continue;
1244
+ }
1245
+ if (text.startsWith("<!", at)) {
1246
+ const declaration = declarationEnd(text, at + 2, meter);
1247
+ if (declaration.end === -1) {
1248
+ stop("a declaration does not end");
1249
+ break;
1250
+ }
1251
+ if (declaration.subset && /^<!DOCTYPE/i.test(text.slice(at, at + 9))) notes.push("the file declares a DTD: the reader ignores it and expands no entity");
1252
+ at = declaration.end;
1253
+ continue;
1254
+ }
1255
+ if (text.startsWith("</", at)) {
1256
+ const endOf = text.indexOf(">", at + 2);
1257
+ if (endOf === -1) {
1258
+ stop("an end tag does not end");
1259
+ break;
1260
+ }
1261
+ meter.steps += endOf + 1 - at;
1262
+ const name = text.slice(at + 2, Math.min(endOf, at + 300)).trim();
1263
+ const top = stack.pop();
1264
+ if (top !== name) {
1265
+ stop(`the end tag </${short(name)}> does not match <${short(top)}>`);
1266
+ break;
1267
+ }
1268
+ close(stack.length + 1);
1269
+ at = endOf + 1;
1270
+ if (stack.length === 0) break;
1271
+ continue;
1272
+ }
1273
+ const tag = readTag(text, at + 1, meter);
1274
+ if (tag === void 0) {
1275
+ stop("a start tag is not well formed");
1276
+ break;
1277
+ }
1278
+ at = tag.end;
1279
+ const depth = stack.length + 1;
1280
+ if (depth > maxDepth) {
1281
+ stop(`the elements nest deeper than ${String(maxDepth)}`);
1282
+ break;
1283
+ }
1284
+ open(tag, depth);
1285
+ if (done) break;
1286
+ if (tag.selfClosing) close(depth);
1287
+ else stack.push(tag.name);
1288
+ if (depth === 1 && tag.selfClosing) break;
1289
+ }
1290
+ if (!done && stack.length > 0) stop("the file ends inside an element");
1291
+ if (kind === "unknown" && notes.length === 0) {
1292
+ complete = false;
1293
+ notes.push("not a sitemap or a feed: the file has no root element");
1294
+ }
1295
+ return {
1296
+ kind,
1297
+ entries,
1298
+ invalid,
1299
+ complete,
1300
+ notes
1301
+ };
1302
+ };
1303
+ /**
1304
+ * This function reads a sitemap, a sitemap index, an RSS or Atom feed, or a text sitemap. The
1305
+ * root element of the file tells its kind. The entries of a feed can have URLs relative to `base`
1306
+ * (the URL of the file). The entries of a sitemap must have absolute URLs.
1307
+ */
1308
+ const readSitemap = (text, base, limits) => scanSitemap(text, base, limits);
1309
+ //#endregion
1310
+ //#region src/discover.ts
1311
+ /**
1312
+ * Discovery finds the pages of a site from its sitemaps and its feeds. Each file is a fetch of the
1313
+ * crawler, so each polite rule applies to it. These rules are robots.txt, one request at a time for
1314
+ * each host, the pace, the stops, the egress policy and the capture log. These are the sources, in sequence:
1315
+ *
1316
+ * 1. The page at the URL of the site, for the RSS and Atom feeds that it links.
1317
+ * 2. The feeds that the caller gives.
1318
+ * 3. The sitemaps that the caller gives, and the `Sitemap:` lines of robots.txt. When there are
1319
+ * none, `/sitemap.xml`.
1320
+ * 4. The sitemaps of each sitemap index, the newest first, to a maximum depth.
1321
+ *
1322
+ * Discovery keeps a URL only when it is in scope (the same site by default) and robots.txt permits
1323
+ * it. It stops at a maximum of files, a maximum of URLs and a maximum time.
1324
+ */
1325
+ /** These are the default limits of `discover`. */
1326
+ const DISCOVER_LIMITS = {
1327
+ /** The most sitemap and feed files that one discovery reads. */
1328
+ maxFiles: 20,
1329
+ /** The most URLs that one discovery gives. */
1330
+ maxUrls: 1e4,
1331
+ /** The most bytes of one file, after decompression: 10 MiB. */
1332
+ maxBytes: 10485760,
1333
+ /** The most levels of sitemap index under a file that discovery found first. */
1334
+ maxNesting: 3,
1335
+ /** The longest time of one discovery: 1 minute. */
1336
+ budgetMs: 6e4,
1337
+ /** The most origins whose robots.txt one discovery reads for the URLs that it finds. */
1338
+ maxOrigins: 10,
1339
+ /** The most feeds that discovery reads from the links of the page. */
1340
+ maxLinkedFeeds: 5
1341
+ };
1342
+ const PAGE_ACCEPT$1 = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5";
1343
+ const SITEMAP_ACCEPT = "application/xml,text/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5";
1344
+ const FEED_ACCEPT = "application/rss+xml,application/atom+xml,application/xml;q=0.9,text/xml;q=0.8,*/*;q=0.5";
1345
+ const FEED_KINDS = /* @__PURE__ */ new Set([
1346
+ "rss",
1347
+ "atom",
1348
+ "rdf"
1349
+ ]);
1350
+ const whole$1 = (name, value, fallback, min) => {
1351
+ if (value === void 0) return fallback;
1352
+ if (!Number.isInteger(value) || value < min) throw new RangeError(`${name} must be an integer of ${String(min)} or more, not ${String(value)}`);
1353
+ return value;
1354
+ };
1355
+ /**
1356
+ * This function reads a body that is gzip data (a `.xml.gz` sitemap). It keeps at most `maxBytes`
1357
+ * of the decompressed data, so a decompression bomb stops at the limit.
1358
+ */
1359
+ const gunzipBounded = async (body, maxBytes) => {
1360
+ const gunzip = createGunzip();
1361
+ const chunks = [];
1362
+ let size = 0;
1363
+ let truncated = false;
1364
+ let error;
1365
+ gunzip.end(body);
1366
+ try {
1367
+ for await (const chunk of gunzip) {
1368
+ const room = maxBytes - size;
1369
+ if (chunk.length > room) {
1370
+ chunks.push(chunk.subarray(0, room));
1371
+ size += room;
1372
+ truncated = true;
1373
+ break;
1374
+ }
1375
+ chunks.push(chunk);
1376
+ size += chunk.length;
1377
+ }
1378
+ } catch (caught) {
1379
+ error = caught instanceof Error ? caught.message : String(caught);
1380
+ truncated = true;
1381
+ } finally {
1382
+ gunzip.destroy();
1383
+ }
1384
+ return {
1385
+ bytes: Buffer.concat(chunks, size),
1386
+ truncated,
1387
+ ...error === void 0 ? {} : { error }
1388
+ };
1389
+ };
1390
+ const isGzip = (body) => body[0] === 31 && body[1] === 139;
1391
+ const parsedUrl = (value, base) => {
1392
+ try {
1393
+ const url = base === void 0 ? new URL(value.trim()) : new URL(value.trim(), base);
1394
+ return url.protocol === "http:" || url.protocol === "https:" ? url : void 0;
1395
+ } catch {
1396
+ return;
1397
+ }
1398
+ };
1399
+ /** This function gives the value of a promise, or rejects when the signal aborts first. */
1400
+ const untilAbort = (work, signal) => {
1401
+ if (signal === void 0) return work;
1402
+ return new Promise((resolve, reject) => {
1403
+ const cancel = () => reject(signal.reason);
1404
+ if (signal.aborted) cancel();
1405
+ signal.addEventListener("abort", cancel, { once: true });
1406
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", cancel));
1407
+ });
1408
+ };
1409
+ /** This function tells if the note of a fetch says that its deadline came first. */
1410
+ const pastDeadline = (note) => note?.startsWith(DEADLINE_NOTE) === true;
1411
+ /**
1412
+ * This function finds the pages of a site from its sitemaps and its feeds. `PoliteCrawler.discover`
1413
+ * uses it. Each file is a fetch of the crawler with the recipe `sitemap` or `feed`, so the
1414
+ * capture log records it, and robots.txt applies to it.
1415
+ */
1416
+ const discoverSite = async (runtime, site, options = {}) => {
1417
+ const start = parsedUrl(site);
1418
+ if (start === void 0) throw new Error(`discover wants an http(s) URL, not "${site}"`);
1419
+ const maxFiles = whole$1("maxFiles", options.maxFiles, DISCOVER_LIMITS.maxFiles, 1);
1420
+ const maxUrls = whole$1("maxUrls", options.maxUrls, DISCOVER_LIMITS.maxUrls, 1);
1421
+ const maxBytes = whole$1("maxBytes", options.maxBytes, DISCOVER_LIMITS.maxBytes, 1);
1422
+ const maxNesting = whole$1("maxNesting", options.maxNesting, DISCOVER_LIMITS.maxNesting, 0);
1423
+ const maxAge = options.maxAge ?? "24h";
1424
+ const maxAgeMs = maxAge === "auto" ? "auto" : durationMs(maxAge);
1425
+ const budgetMs = options.budgetMs ?? DISCOVER_LIMITS.budgetMs;
1426
+ if (!(budgetMs >= 0)) throw new RangeError(`budgetMs must be 0 or more, not ${String(budgetMs)}`);
1427
+ const now = () => Date.parse(runtime.now());
1428
+ const deadline = Math.min(now() + budgetMs, options.deadline ?? Number.POSITIVE_INFINITY);
1429
+ const crawler = runtime.crawler;
1430
+ const signal = options.signal;
1431
+ const cancelled = () => signal?.aborted === true;
1432
+ const marks = {
1433
+ scope: options.scope,
1434
+ labels: options.labels
1435
+ };
1436
+ const inScope = scopeTest([start], options);
1437
+ const urls = /* @__PURE__ */ new Map();
1438
+ const sources = [];
1439
+ const dropped = {
1440
+ scope: 0,
1441
+ robots: 0,
1442
+ duplicate: 0,
1443
+ invalid: 0,
1444
+ limit: 0
1445
+ };
1446
+ const origins = /* @__PURE__ */ new Set();
1447
+ const queued = /* @__PURE__ */ new Set();
1448
+ const pending = [];
1449
+ let stopped;
1450
+ let note = null;
1451
+ let files = 0;
1452
+ let next = 0;
1453
+ const stopNote = () => {
1454
+ switch (stopped) {
1455
+ case "max-files": return `discovery read ${String(maxFiles)} files, its limit, and ${String(pending.length - next)} more files wait`;
1456
+ case "max-urls": return `discovery found ${String(maxUrls)} URLs, its limit`;
1457
+ case "budget": return `the budget of ${String(budgetMs)} ms ended`;
1458
+ case "cancelled": return "the caller cancelled the discovery";
1459
+ case "closed": return "the crawler closed";
1460
+ default: return null;
1461
+ }
1462
+ };
1463
+ const result = () => ({
1464
+ site: start.origin,
1465
+ urls: [...urls.values()].map((url, order) => ({
1466
+ url,
1467
+ order
1468
+ })).sort((a, b) => (b.url.lastmod === null ? -Infinity : Date.parse(b.url.lastmod)) - (a.url.lastmod === null ? -Infinity : Date.parse(a.url.lastmod)) || a.order - b.order).map(({ url }) => url),
1469
+ sources,
1470
+ dropped,
1471
+ stopped: stopped ?? "done",
1472
+ note: note ?? stopNote()
1473
+ });
1474
+ /** This function sets the reason of the stop from the note of a fetch, when the note gives one. */
1475
+ const stoppedBy = (fetched) => {
1476
+ if (cancelled()) stopped = "cancelled";
1477
+ else if (pastDeadline(fetched.note)) stopped = "budget";
1478
+ else if (fetched.note === "crawler closed") stopped = "closed";
1479
+ else return false;
1480
+ return true;
1481
+ };
1482
+ const enqueue = (url, via, from, nesting = 0) => {
1483
+ const parsed = parsedUrl(url, start.href);
1484
+ if (parsed === void 0) {
1485
+ dropped.invalid += 1;
1486
+ return;
1487
+ }
1488
+ const key = cacheKeyOf(parsed.href);
1489
+ if (queued.has(key)) return;
1490
+ queued.add(key);
1491
+ pending.push({
1492
+ url: parsed.href,
1493
+ via,
1494
+ from,
1495
+ nesting
1496
+ });
1497
+ };
1498
+ if (cancelled()) {
1499
+ stopped = "cancelled";
1500
+ return result();
1501
+ }
1502
+ let sitemapLines = [];
1503
+ try {
1504
+ const robots = await untilAbort(crawler.readRobots(start.href, marks), signal);
1505
+ if (robots.robots.unreachable === "crawler closed") {
1506
+ stopped = "closed";
1507
+ return result();
1508
+ }
1509
+ if (robots.robots.unreachable !== void 0) {
1510
+ stopped = "robots";
1511
+ note = `robots.txt could not be read (${robots.robots.unreachable}), so nothing on the site is fetched`;
1512
+ return result();
1513
+ }
1514
+ sitemapLines = robots.robots.sitemaps;
1515
+ } catch {
1516
+ stopped = cancelled() ? "cancelled" : "closed";
1517
+ return result();
1518
+ }
1519
+ const fetchFile = (url, recipe) => crawler.fetch({
1520
+ url,
1521
+ mode: "page",
1522
+ recipe,
1523
+ accept: recipe === "feed" ? FEED_ACCEPT : SITEMAP_ACCEPT,
1524
+ maxBytes,
1525
+ maxAgeMs,
1526
+ onBehalfOf: options.onBehalfOf,
1527
+ signal,
1528
+ scope: options.scope,
1529
+ labels: options.labels,
1530
+ deadline
1531
+ });
1532
+ if (options.linkedFeeds !== false) {
1533
+ const page = await fetchAndIndex(runtime, {
1534
+ url: start.href,
1535
+ mode: "page",
1536
+ recipe: "page",
1537
+ accept: PAGE_ACCEPT$1,
1538
+ maxAgeMs,
1539
+ onBehalfOf: options.onBehalfOf,
1540
+ signal,
1541
+ scope: options.scope,
1542
+ labels: options.labels,
1543
+ deadline
1544
+ });
1545
+ if (stoppedBy(page.fetched)) return result();
1546
+ const doc = page.docs[0];
1547
+ const body = page.fetched.body;
1548
+ const html = /html/i.test(page.fetched.contentType ?? "text/html");
1549
+ if (body !== void 0 && html && doc !== void 0 && !isHeldBack(doc)) {
1550
+ let linked = [];
1551
+ try {
1552
+ linked = extractPage(decodeBody(body, page.fetched.contentType ?? null), page.fetched.url).feeds ?? [];
1553
+ } catch {}
1554
+ let taken = 0;
1555
+ for (const feed of linked) {
1556
+ const parsed = parsedUrl(feed);
1557
+ if (parsed === void 0 || !inScope(parsed)) {
1558
+ dropped.scope += 1;
1559
+ continue;
1560
+ }
1561
+ if (taken >= DISCOVER_LIMITS.maxLinkedFeeds) {
1562
+ dropped.limit += 1;
1563
+ continue;
1564
+ }
1565
+ taken += 1;
1566
+ enqueue(parsed.href, "feed", "page");
1567
+ }
1568
+ }
1569
+ }
1570
+ for (const feed of options.feeds ?? []) enqueue(feed, "feed", "given");
1571
+ for (const sitemap of options.sitemaps ?? []) enqueue(sitemap, "sitemap", "given");
1572
+ for (const sitemap of sitemapLines) enqueue(sitemap, "sitemap", "robots.txt");
1573
+ if ((options.sitemaps ?? []).length === 0 && sitemapLines.length === 0) enqueue(new URL("/sitemap.xml", start.origin).href, "sitemap", "default");
1574
+ /** This function keeps the page URLs of one file that are new, in scope and permitted. */
1575
+ const keep = async (read, source, via) => {
1576
+ let kept = 0;
1577
+ dropped.invalid += read.invalid;
1578
+ for (const entry of read.entries) {
1579
+ if (urls.size >= maxUrls) {
1580
+ stopped = "max-urls";
1581
+ return kept;
1582
+ }
1583
+ const known = urls.get(entry.url);
1584
+ if (known !== void 0) {
1585
+ dropped.duplicate += 1;
1586
+ if (entry.lastmod !== null && (known.lastmod === null || Date.parse(entry.lastmod) > Date.parse(known.lastmod))) urls.set(entry.url, {
1587
+ ...known,
1588
+ lastmod: entry.lastmod
1589
+ });
1590
+ continue;
1591
+ }
1592
+ const url = new URL(entry.url);
1593
+ if (!inScope(url)) {
1594
+ dropped.scope += 1;
1595
+ continue;
1596
+ }
1597
+ if (!origins.has(url.origin)) {
1598
+ if (origins.size >= DISCOVER_LIMITS.maxOrigins) {
1599
+ dropped.limit += 1;
1600
+ continue;
1601
+ }
1602
+ origins.add(url.origin);
1603
+ }
1604
+ let allowed;
1605
+ try {
1606
+ allowed = (await crawler.robotsAllow(entry.url, {
1607
+ onBehalfOf: options.onBehalfOf,
1608
+ signal,
1609
+ ...marks
1610
+ })).allowed;
1611
+ } catch {
1612
+ stopped = "cancelled";
1613
+ return kept;
1614
+ }
1615
+ if (!allowed) {
1616
+ dropped.robots += 1;
1617
+ continue;
1618
+ }
1619
+ urls.set(entry.url, {
1620
+ url: entry.url,
1621
+ lastmod: entry.lastmod,
1622
+ changefreq: entry.changefreq,
1623
+ via,
1624
+ source
1625
+ });
1626
+ kept += 1;
1627
+ }
1628
+ return kept;
1629
+ };
1630
+ for (; next < pending.length && stopped === void 0; next += 1) {
1631
+ if (cancelled()) {
1632
+ stopped = "cancelled";
1633
+ break;
1634
+ }
1635
+ if (now() >= deadline) {
1636
+ stopped = "budget";
1637
+ break;
1638
+ }
1639
+ if (files >= maxFiles) {
1640
+ stopped = "max-files";
1641
+ break;
1642
+ }
1643
+ const file = pending[next];
1644
+ files += 1;
1645
+ const fetched = await fetchFile(file.url, file.via);
1646
+ const base = {
1647
+ url: fetched.url,
1648
+ via: file.via,
1649
+ from: file.from,
1650
+ outcome: fetched.outcome,
1651
+ status: fetched.status,
1652
+ capture: fetched.capture ?? null,
1653
+ fetched_at: fetched.fetchedAt ?? null,
1654
+ from_cache: fetched.fromCache
1655
+ };
1656
+ if (stoppedBy(fetched)) {
1657
+ sources.push({
1658
+ ...base,
1659
+ kind: null,
1660
+ entries: 0,
1661
+ urls: 0,
1662
+ truncated: false,
1663
+ note: fetched.note ?? null
1664
+ });
1665
+ break;
1666
+ }
1667
+ const body = fetched.body;
1668
+ if (fetched.outcome !== "ok" && fetched.outcome !== "not-modified" || body === void 0) {
1669
+ sources.push({
1670
+ ...base,
1671
+ kind: null,
1672
+ entries: 0,
1673
+ urls: 0,
1674
+ truncated: false,
1675
+ note: fetched.note ?? `${fetched.outcome} ${String(fetched.status)}`
1676
+ });
1677
+ continue;
1678
+ }
1679
+ let bytes = body;
1680
+ let truncated = fetched.truncated === true;
1681
+ const notes = [];
1682
+ if (isGzip(body)) {
1683
+ const unzipped = await gunzipBounded(body, maxBytes);
1684
+ bytes = unzipped.bytes;
1685
+ truncated ||= unzipped.truncated;
1686
+ if (unzipped.error !== void 0) notes.push(`the gzip data ended early (${unzipped.error})`);
1687
+ }
1688
+ const read = readSitemap(decodeBody(bytes, isGzip(body) ? null : fetched.contentType ?? null), fetched.url);
1689
+ if (truncated) notes.push(`the file is longer than ${String(maxBytes)} bytes, so discovery read its first part`);
1690
+ notes.push(...read.notes);
1691
+ let kept = 0;
1692
+ if (read.kind === "sitemapindex") {
1693
+ const children = [...read.entries].sort((a, b) => (b.lastmod === null ? -Infinity : Date.parse(b.lastmod)) - (a.lastmod === null ? -Infinity : Date.parse(a.lastmod)));
1694
+ for (const child of children) if (!inScope(new URL(child.url))) dropped.scope += 1;
1695
+ else if (file.nesting + 1 > maxNesting) dropped.limit += 1;
1696
+ else enqueue(child.url, "sitemap", "index", file.nesting + 1);
1697
+ if (file.nesting + 1 > maxNesting && children.length > 0) notes.push(`the files of the index are deeper than maxNesting (${String(maxNesting)}), so discovery reads none of them`);
1698
+ } else if (read.kind !== "unknown") kept = await keep(read, fetched.url, FEED_KINDS.has(read.kind) ? "feed" : "sitemap");
1699
+ sources.push({
1700
+ ...base,
1701
+ kind: read.kind,
1702
+ entries: read.entries.length,
1703
+ urls: kept,
1704
+ truncated,
1705
+ note: notes.length > 0 ? notes.join("; ") : null
1706
+ });
1707
+ }
1708
+ if (stopped === void 0 && urls.size >= maxUrls) stopped = "max-urls";
1709
+ return result();
1710
+ };
1711
+ //#endregion
1712
+ //#region src/frontier.ts
1713
+ /**
1714
+ * The frontier is a small crawl of a site. It starts at the seeds. It follows the links of each page
1715
+ * that it fetched, to a maximum of pages, a maximum depth and a maximum time.
1716
+ *
1717
+ * Each page is a fetch of the crawler, so each polite rule applies. These rules are robots.txt, one
1718
+ * request at a time for each host, the pace, the stops, the egress policy and the capture log. The
1719
+ * frontier does not start a second request to a host with a request in progress. The crawler
1720
+ * limits the hosts in progress (`maxActive`).
1721
+ *
1722
+ * The state of the frontier is in memory, for one call. A later version can make it resumable.
1723
+ */
1724
+ /** These are the default limits of `crawl`, and the limits of the memory of the frontier. */
1725
+ const CRAWL_LIMITS = {
1726
+ /** The most pages that one crawl gives. */
1727
+ maxPages: 100,
1728
+ /** The most links from a seed to a page. A seed has the depth 0. */
1729
+ maxDepth: 3,
1730
+ /** The longest time of one crawl: 10 minutes. */
1731
+ budgetMs: 6e5,
1732
+ /** The most hosts that get a request of the crawl at one time. */
1733
+ hostsAtOnce: 16,
1734
+ /** The most URLs that wait in the frontier. */
1735
+ maxQueue: 1e4,
1736
+ /** The most URLs that the frontier remembers, so it fetches none of them two times. */
1737
+ maxSeen: 1e5
1738
+ };
1739
+ /** The lower depth first, then the lower tier, then the newer `lastmod`, then the first found. */
1740
+ const before = (a, b) => a.depth - b.depth || a.tier - b.tier || b.lastmod - a.lastmod || a.seq - b.seq;
1741
+ /** A binary heap of the URLs of one host, the first URL to fetch at the top. */
1742
+ var Heap = class {
1743
+ items = [];
1744
+ get size() {
1745
+ return this.items.length;
1746
+ }
1747
+ peek() {
1748
+ return this.items[0];
1749
+ }
1750
+ push(item) {
1751
+ const items = this.items;
1752
+ items.push(item);
1753
+ let at = items.length - 1;
1754
+ while (at > 0) {
1755
+ const parent = at - 1 >> 1;
1756
+ if (before(items[at], items[parent]) >= 0) break;
1757
+ [items[at], items[parent]] = [items[parent], items[at]];
1758
+ at = parent;
1759
+ }
1760
+ }
1761
+ pop() {
1762
+ const items = this.items;
1763
+ const top = items[0];
1764
+ const last = items.pop();
1765
+ if (top === void 0 || last === void 0 || items.length === 0) return top;
1766
+ items[0] = last;
1767
+ let at = 0;
1768
+ for (;;) {
1769
+ const left = at * 2 + 1;
1770
+ const right = left + 1;
1771
+ let first = at;
1772
+ if (left < items.length && before(items[left], items[first]) < 0) first = left;
1773
+ if (right < items.length && before(items[right], items[first]) < 0) first = right;
1774
+ if (first === at) return top;
1775
+ [items[at], items[first]] = [items[first], items[at]];
1776
+ at = first;
1777
+ }
1778
+ }
1779
+ };
1780
+ const hostOf = (url) => `${url.hostname.toLowerCase().replace(/\.$/, "")}${url.port === "" ? "" : `:${url.port}`}`;
1781
+ const matcher = (pattern) => {
1782
+ if (pattern instanceof RegExp) return (url) => {
1783
+ pattern.lastIndex = 0;
1784
+ return pattern.test(url.href);
1785
+ };
1786
+ if (typeof pattern !== "string" || pattern.length === 0) throw new RangeError("a URL pattern is a regular expression or a text that is not empty");
1787
+ return pattern.startsWith("/") ? (url) => matchesPattern(pattern, `${url.pathname}${url.search}`) : (url) => matchesPattern(pattern, url.href);
1788
+ };
1789
+ const whole = (name, value, fallback, min) => {
1790
+ if (value === void 0) return fallback;
1791
+ if (!Number.isInteger(value) || value < min) throw new RangeError(`${name} must be an integer of ${String(min)} or more, not ${String(value)}`);
1792
+ return value;
1793
+ };
1794
+ const failedPage = (url, note) => ({
1795
+ url,
1796
+ doc: null,
1797
+ outcome: "error",
1798
+ status: 0,
1799
+ title: null,
1800
+ chars: 0,
1801
+ rendered: false,
1802
+ structured: [],
1803
+ links: 0,
1804
+ captures: [],
1805
+ capture: null,
1806
+ capture_count: 0,
1807
+ fetched_at: null,
1808
+ from_cache: false,
1809
+ held_back: false,
1810
+ note
1811
+ });
1812
+ const REFUSALS$1 = /* @__PURE__ */ new Set([
1813
+ "skipped",
1814
+ "blocked",
1815
+ "challenge",
1816
+ "payment-required"
1817
+ ]);
1818
+ /**
1819
+ * This function crawls a site from its seeds. `PoliteCrawler.crawl` uses it. It gives each page
1820
+ * when its fetch ends, and a summary at the end. The seeds come first, then the pages of the
1821
+ * sitemaps (the newest first), then the links, the lower depth first.
1822
+ */
1823
+ const crawlSite = (runtime, seeds, options = {}) => {
1824
+ let resolveSummary = () => void 0;
1825
+ const summary = new Promise((resolve) => {
1826
+ resolveSummary = resolve;
1827
+ });
1828
+ let used = false;
1829
+ async function* pages() {
1830
+ const now = () => Date.parse(runtime.now());
1831
+ const startedAt = now();
1832
+ const abort = new AbortController();
1833
+ const outer = options.signal;
1834
+ const cancel = () => abort.abort(outer?.reason);
1835
+ if (outer?.aborted === true) cancel();
1836
+ outer?.addEventListener("abort", cancel, { once: true });
1837
+ const inflight = /* @__PURE__ */ new Map();
1838
+ const heaps = /* @__PURE__ */ new Map();
1839
+ const seen = /* @__PURE__ */ new Set();
1840
+ const counts = {
1841
+ pages: 0,
1842
+ fetched: 0,
1843
+ from_cache: 0,
1844
+ refused: 0,
1845
+ failed: 0,
1846
+ sitemap_urls: 0
1847
+ };
1848
+ const skips = {
1849
+ scope: 0,
1850
+ pattern: 0,
1851
+ depth: 0,
1852
+ held_back: 0,
1853
+ limit: 0,
1854
+ duplicate: 0
1855
+ };
1856
+ const got = /* @__PURE__ */ new Set();
1857
+ let queued = 0;
1858
+ let returned = 0;
1859
+ let seq = 0;
1860
+ let started = 0;
1861
+ let stopping;
1862
+ let reason;
1863
+ let finished = false;
1864
+ let maxPages = CRAWL_LIMITS.maxPages;
1865
+ let budgetMs = CRAWL_LIMITS.budgetMs;
1866
+ const crawler = runtime.crawler;
1867
+ const marks = {
1868
+ scope: options.scope,
1869
+ labels: options.labels
1870
+ };
1871
+ try {
1872
+ await options.ready?.();
1873
+ maxPages = whole("maxPages", options.maxPages, CRAWL_LIMITS.maxPages, 1);
1874
+ const maxDepth = whole("maxDepth", options.maxDepth, CRAWL_LIMITS.maxDepth, 0);
1875
+ budgetMs = options.budgetMs ?? CRAWL_LIMITS.budgetMs;
1876
+ if (!(budgetMs >= 0)) throw new RangeError(`budgetMs must be 0 or more, not ${String(budgetMs)}`);
1877
+ const maxAge = options.maxAge ?? "24h";
1878
+ const maxAgeMs = maxAge === "auto" ? "auto" : durationMs(maxAge);
1879
+ const includes = (options.include ?? []).map(matcher);
1880
+ const excludes = (options.exclude ?? []).map(matcher);
1881
+ const deadline = startedAt + budgetMs;
1882
+ const list = typeof seeds === "string" ? [seeds] : [...seeds];
1883
+ if (list.length === 0) throw new RangeError("a crawl needs one seed URL or more");
1884
+ const starts = list.flatMap((seed) => {
1885
+ try {
1886
+ const url = new URL(seed);
1887
+ return url.protocol === "http:" || url.protocol === "https:" ? [url] : [];
1888
+ } catch {
1889
+ return [];
1890
+ }
1891
+ });
1892
+ const inScope = scopeTest(starts, options);
1893
+ const followed = (url) => (includes.length === 0 || includes.some((test) => test(url))) && !excludes.some((test) => test(url));
1894
+ /** This function adds a URL to the frontier, when it is new, in scope and not too deep. */
1895
+ const offer = (raw, depth, tier, lastmod, source, referrer) => {
1896
+ let url;
1897
+ try {
1898
+ url = new URL(raw);
1899
+ } catch {
1900
+ url = void 0;
1901
+ }
1902
+ const web = url !== void 0 && (url.protocol === "http:" || url.protocol === "https:");
1903
+ if (!web && source !== "seed") return;
1904
+ const key = cacheKeyOf(url?.href ?? raw);
1905
+ if (seen.has(key)) return;
1906
+ if (seen.size >= CRAWL_LIMITS.maxSeen) {
1907
+ skips.limit += 1;
1908
+ return;
1909
+ }
1910
+ seen.add(key);
1911
+ if (source !== "seed" && url !== void 0) {
1912
+ if (!inScope(url)) {
1913
+ skips.scope += 1;
1914
+ return;
1915
+ }
1916
+ if (!followed(url)) {
1917
+ skips.pattern += 1;
1918
+ return;
1919
+ }
1920
+ if (depth > maxDepth) {
1921
+ skips.depth += 1;
1922
+ return;
1923
+ }
1924
+ }
1925
+ if (queued >= CRAWL_LIMITS.maxQueue) {
1926
+ skips.limit += 1;
1927
+ return;
1928
+ }
1929
+ const host = url === void 0 || !web ? `not-web:${raw}` : hostOf(url);
1930
+ let heap = heaps.get(host);
1931
+ if (heap === void 0) {
1932
+ heap = new Heap();
1933
+ heaps.set(host, heap);
1934
+ }
1935
+ seq += 1;
1936
+ heap.push({
1937
+ url: raw,
1938
+ key,
1939
+ host,
1940
+ depth,
1941
+ tier,
1942
+ lastmod,
1943
+ seq,
1944
+ source,
1945
+ referrer
1946
+ });
1947
+ queued += 1;
1948
+ };
1949
+ /** This function gives the first URL of a host with no request of the crawl in progress. */
1950
+ const pick = () => {
1951
+ for (;;) {
1952
+ let best;
1953
+ for (const [host, heap] of heaps) {
1954
+ if (inflight.has(host)) continue;
1955
+ const top = heap.peek();
1956
+ if (top === void 0) continue;
1957
+ if (best === void 0 || before(top, best.heap.peek()) < 0) best = {
1958
+ host,
1959
+ heap
1960
+ };
1961
+ }
1962
+ if (best === void 0) return void 0;
1963
+ const item = best.heap.pop();
1964
+ queued -= 1;
1965
+ if (best.heap.size === 0) heaps.delete(best.host);
1966
+ if (!got.has(item.key)) return item;
1967
+ skips.duplicate += 1;
1968
+ }
1969
+ };
1970
+ const fetchItem = async (item) => {
1971
+ try {
1972
+ if (item.source !== "seed") {
1973
+ if (!(await crawler.robotsAllow(item.url, {
1974
+ onBehalfOf: options.onBehalfOf,
1975
+ signal: abort.signal,
1976
+ ...marks
1977
+ })).allowed) return {
1978
+ kind: "refused",
1979
+ item
1980
+ };
1981
+ }
1982
+ return {
1983
+ kind: "page",
1984
+ item,
1985
+ page: await fetchPage(runtime, item.url, {
1986
+ render: options.render ?? "auto",
1987
+ maxAgeMs,
1988
+ onBehalfOf: options.onBehalfOf,
1989
+ signal: abort.signal,
1990
+ scope: options.scope,
1991
+ labels: options.labels,
1992
+ deadline
1993
+ })
1994
+ };
1995
+ } catch (error) {
1996
+ if (abort.signal.aborted) return {
1997
+ kind: "aborted",
1998
+ item
1999
+ };
2000
+ const message = error instanceof Error ? error.message : String(error);
2001
+ return {
2002
+ kind: "page",
2003
+ item,
2004
+ page: failedPage(item.url, `crawl error: ${message}`)
2005
+ };
2006
+ }
2007
+ };
2008
+ /** This function adds the links of a page to the frontier. The canonical URL of the page is known after its fetch. */
2009
+ const follow = (page, item) => {
2010
+ if (page.doc === null) return;
2011
+ const doc = runtime.index.get(page.doc);
2012
+ if (doc === void 0) return;
2013
+ for (const same of [doc.url, doc.meta["canonical"]]) {
2014
+ if (typeof same !== "string") continue;
2015
+ seen.add(cacheKeyOf(same));
2016
+ got.add(cacheKeyOf(same));
2017
+ }
2018
+ const links = doc.meta["links"];
2019
+ if (!Array.isArray(links) || links.length === 0) return;
2020
+ if (isHeldBack(doc)) {
2021
+ skips.held_back += 1;
2022
+ return;
2023
+ }
2024
+ for (const link of links) if (typeof link?.href === "string") offer(link.href, item.depth + 1, 2, Number.NEGATIVE_INFINITY, "link", page.url);
2025
+ };
2026
+ for (const seed of list) offer(seed, 0, 0, Number.NEGATIVE_INFINITY, "seed", null);
2027
+ if (options.useSitemaps === true) {
2028
+ const sites = [...new Set(starts.map((start) => start.origin))];
2029
+ for (const site of sites) {
2030
+ if (abort.signal.aborted || now() >= deadline) break;
2031
+ try {
2032
+ const found = await discoverSite(runtime, site, {
2033
+ sameSite: options.sameSite,
2034
+ allowHosts: options.allowHosts,
2035
+ maxUrls: Math.min(1e4, Math.max(50, maxPages * 4)),
2036
+ linkedFeeds: false,
2037
+ maxAge,
2038
+ onBehalfOf: options.onBehalfOf,
2039
+ signal: abort.signal,
2040
+ scope: options.scope,
2041
+ labels: options.labels,
2042
+ deadline
2043
+ });
2044
+ counts.sitemap_urls += found.urls.length;
2045
+ for (const url of found.urls) offer(url.url, 0, 1, url.lastmod === null ? Number.NEGATIVE_INFINITY : Date.parse(url.lastmod), "sitemap", null);
2046
+ } catch {}
2047
+ }
2048
+ }
2049
+ for (;;) {
2050
+ if (stopping === void 0 && abort.signal.aborted) stopping = "cancelled";
2051
+ if (stopping === void 0 && now() >= deadline) stopping = "budget";
2052
+ while (stopping === void 0 && inflight.size < CRAWL_LIMITS.hostsAtOnce && started < maxPages) {
2053
+ const item = pick();
2054
+ if (item === void 0) break;
2055
+ started += 1;
2056
+ inflight.set(item.host, fetchItem(item));
2057
+ }
2058
+ if (inflight.size === 0) break;
2059
+ const done = await Promise.race(inflight.values());
2060
+ inflight.delete(done.item.host);
2061
+ if (done.kind === "aborted") {
2062
+ started -= 1;
2063
+ stopping ??= "cancelled";
2064
+ continue;
2065
+ }
2066
+ if (done.kind === "refused") {
2067
+ started -= 1;
2068
+ counts.refused += 1;
2069
+ continue;
2070
+ }
2071
+ const page = done.page;
2072
+ if (pastDeadline(page.note)) {
2073
+ started -= 1;
2074
+ returned += 1;
2075
+ stopping ??= "budget";
2076
+ continue;
2077
+ }
2078
+ if (abort.signal.aborted) {
2079
+ started -= 1;
2080
+ stopping ??= "cancelled";
2081
+ continue;
2082
+ }
2083
+ if (page.outcome === "skipped" && page.note === "crawler closed") {
2084
+ started -= 1;
2085
+ stopping ??= "closed";
2086
+ continue;
2087
+ }
2088
+ counts.pages += 1;
2089
+ if (page.outcome === "ok" || page.outcome === "not-modified") {
2090
+ counts.fetched += 1;
2091
+ if (page.from_cache) counts.from_cache += 1;
2092
+ } else if (REFUSALS$1.has(page.outcome)) counts.refused += 1;
2093
+ else counts.failed += 1;
2094
+ follow(page, done.item);
2095
+ yield {
2096
+ ...page,
2097
+ depth: done.item.depth,
2098
+ source: done.item.source,
2099
+ referrer: done.item.referrer
2100
+ };
2101
+ }
2102
+ stopping ??= queued > 0 && started >= maxPages ? "max-pages" : "done";
2103
+ finished = true;
2104
+ } catch (error) {
2105
+ stopping = "error";
2106
+ reason = error instanceof Error ? error.message : String(error);
2107
+ finished = true;
2108
+ throw error;
2109
+ } finally {
2110
+ if (!finished) {
2111
+ stopping ??= "cancelled";
2112
+ reason ??= "the caller stopped the loop";
2113
+ }
2114
+ outer?.removeEventListener("abort", cancel);
2115
+ abort.abort(/* @__PURE__ */ new Error("the crawl ended"));
2116
+ await Promise.allSettled(inflight.values());
2117
+ const stop = stopping ?? "done";
2118
+ reason ??= stop === "done" ? "the crawl fetched each page in scope that it found" : stop === "max-pages" ? `the crawl gave ${String(maxPages)} pages, its limit (maxPages)` : stop === "budget" ? `the budget of ${String(budgetMs)} ms ended (budgetMs)` : stop === "cancelled" ? "the caller cancelled the crawl" : "the crawler closed";
2119
+ resolveSummary({
2120
+ ...counts,
2121
+ skipped: skips.scope + skips.pattern + skips.depth + skips.held_back + skips.limit + skips.duplicate,
2122
+ skips,
2123
+ queued: queued + returned,
2124
+ stopped: stop,
2125
+ reason,
2126
+ elapsed_ms: Math.max(0, now() - startedAt)
2127
+ });
2128
+ }
2129
+ }
2130
+ return {
2131
+ summary,
2132
+ [Symbol.asyncIterator]() {
2133
+ if (used) throw new Error("a crawl runs one time: call crawl again for a new crawl");
2134
+ used = true;
2135
+ return pages();
2136
+ }
2137
+ };
2138
+ };
2139
+ //#endregion
2140
+ //#region src/hoststate.ts
2141
+ /** A stop with no end goes into the table as this value, which is past each date that JavaScript can hold. */
2142
+ const NO_END = 9e15;
2143
+ /** A process that shares the store asks again after this wait, at most, during the lease of a different process. */
2144
+ const SHARED_WAIT_MS = 1e3;
2145
+ /** The store removes the rows of idle hosts after this many reservations. */
2146
+ const SWEEP_EVERY = 1e3;
2147
+ const verdictOf = (value) => value === "works" || value === "fails" ? value : "unknown";
2148
+ const memoOf = (row) => ({
2149
+ skipEtag: row.skip_etag === 1,
2150
+ etag: verdictOf(row.etag),
2151
+ lastModified: verdictOf(row.last_modified),
2152
+ etagMisses: row.etag_misses,
2153
+ dateMisses: row.date_misses,
2154
+ sinceProbe: row.since_probe,
2155
+ changedAt: row.changed_at
2156
+ });
2157
+ /**
2158
+ * This store keeps the state of each host in a SQLite file. Two or more processes on one store
2159
+ * share it, so they share the pace and the stops of each host. Thus a host gets one request at a
2160
+ * time from all of them. Each method is one transaction (`BEGIN IMMEDIATE`), and it waits for the lock
2161
+ * of a different process (`busy_timeout`).
2162
+ *
2163
+ * The lease of a request belongs to the store that gave it. A different process that asks during
2164
+ * the lease gets a wait of one second or less, and asks again. A process that stops during a
2165
+ * request holds its lease until the lease ends (5 minutes by default).
2166
+ *
2167
+ * The table `validators` keeps the validator memo of each host (`ValidatorMemo`), so the processes
2168
+ * share it and it stays after a restart. A file of version 0.1.0 gets the table when it opens.
2169
+ *
2170
+ * The state is not a projection of the capture log. A deletion of the file loses only the pace,
2171
+ * the counts, the stops and the memos, and the crawler starts them again.
2172
+ */
2173
+ var SqliteHostStateStore = class {
2174
+ path;
2175
+ db;
2176
+ statements = /* @__PURE__ */ new Map();
2177
+ /** This id names the leases of this store. */
2178
+ owner = randomUUID();
2179
+ reservations = 0;
2180
+ closed = false;
2181
+ pollMs;
2182
+ constructor(path, options = {}) {
2183
+ this.path = path;
2184
+ if (path !== ":memory:") mkdirSync(dirname(path), {
2185
+ recursive: true,
2186
+ mode: 448
2187
+ });
2188
+ this.pollMs = Math.max(1, Math.min(SHARED_WAIT_MS, options.pollMs ?? SHARED_WAIT_MS));
2189
+ this.db = new DatabaseSync(path);
2190
+ const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
2191
+ this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
2192
+ if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
2193
+ this.db.exec("PRAGMA synchronous = NORMAL;");
2194
+ this.migrate();
2195
+ }
2196
+ /**
2197
+ * This method makes the tables, or adds the tables of a later version to an older file. It is one
2198
+ * transaction, so two processes that open one old file at the same time do not collide.
2199
+ */
2200
+ migrate() {
2201
+ this.step(() => {
2202
+ this.db.exec(`CREATE TABLE IF NOT EXISTS hosts (
2203
+ host TEXT PRIMARY KEY,
2204
+ next_at REAL NOT NULL DEFAULT 0,
2205
+ leased_until REAL NOT NULL DEFAULT 0,
2206
+ owner TEXT,
2207
+ failures INTEGER NOT NULL DEFAULT 0,
2208
+ waits INTEGER NOT NULL DEFAULT 0,
2209
+ stop_until REAL,
2210
+ stop_reason TEXT
2211
+ )`);
2212
+ this.db.exec(`CREATE TABLE IF NOT EXISTS validators (
2213
+ host TEXT PRIMARY KEY,
2214
+ skip_etag INTEGER NOT NULL DEFAULT 0,
2215
+ etag TEXT NOT NULL DEFAULT 'unknown',
2216
+ last_modified TEXT NOT NULL DEFAULT 'unknown',
2217
+ etag_misses INTEGER NOT NULL DEFAULT 0,
2218
+ date_misses INTEGER NOT NULL DEFAULT 0,
2219
+ since_probe INTEGER NOT NULL DEFAULT 0,
2220
+ changed_at REAL NOT NULL DEFAULT 0
2221
+ )`);
2222
+ const { user_version: version } = this.db.prepare("PRAGMA user_version").get();
2223
+ if (version < 1) this.db.exec(`PRAGMA user_version = ${String(1)}`);
2224
+ });
2225
+ }
2226
+ stmt(sql) {
2227
+ let statement = this.statements.get(sql);
2228
+ if (statement === void 0) {
2229
+ statement = this.db.prepare(sql);
2230
+ this.statements.set(sql, statement);
2231
+ }
2232
+ return statement;
2233
+ }
2234
+ /** This method does one step in one transaction. */
2235
+ step(work) {
2236
+ if (this.closed) throw new Error("the host state store is closed");
2237
+ this.db.exec("BEGIN IMMEDIATE");
2238
+ try {
2239
+ const result = work();
2240
+ this.db.exec("COMMIT");
2241
+ return result;
2242
+ } catch (error) {
2243
+ if (this.db.isTransaction) this.db.exec("ROLLBACK");
2244
+ throw error;
2245
+ }
2246
+ }
2247
+ row(host) {
2248
+ this.stmt("INSERT OR IGNORE INTO hosts (host) VALUES (?)").run(host);
2249
+ return this.stmt("SELECT next_at, leased_until, owner, failures, waits, stop_until, stop_reason FROM hosts WHERE host = ?").get(host);
2250
+ }
2251
+ /** This method removes the hosts that have nothing to remember: no rest, no lease, no counts and no stop. */
2252
+ sweep(now) {
2253
+ this.stmt(`DELETE FROM hosts WHERE next_at <= ? AND leased_until <= ? AND failures = 0 AND waits = 0
2254
+ AND (stop_until IS NULL OR stop_until <= ?)`).run(now, now, now);
2255
+ }
2256
+ reserve(host, now, leaseMs) {
2257
+ return this.step(() => {
2258
+ this.reservations += 1;
2259
+ if (this.reservations % SWEEP_EVERY === 0) this.sweep(now);
2260
+ const row = this.row(host);
2261
+ if (row.leased_until > now) return row.owner === this.owner ? row.leased_until - now : Math.min(this.pollMs, row.leased_until - now);
2262
+ if (row.next_at > now) return row.next_at - now;
2263
+ this.stmt("UPDATE hosts SET leased_until = ?, owner = ? WHERE host = ?").run(now + leaseMs, this.owner, host);
2264
+ return 0;
2265
+ });
2266
+ }
2267
+ record(host, result) {
2268
+ return this.step(() => {
2269
+ const row = this.row(host);
2270
+ let { failures, waits } = row;
2271
+ if (result === "success") {
2272
+ failures = 0;
2273
+ waits = 0;
2274
+ } else if (result === "failure") failures += 1;
2275
+ else if (result === "wait") waits += 1;
2276
+ if (failures !== row.failures || waits !== row.waits) this.stmt("UPDATE hosts SET failures = ?, waits = ? WHERE host = ?").run(failures, waits, host);
2277
+ return {
2278
+ failures,
2279
+ waits
2280
+ };
2281
+ });
2282
+ }
2283
+ release(host, nextAt) {
2284
+ this.step(() => {
2285
+ this.row(host);
2286
+ this.stmt(`UPDATE hosts SET next_at = max(next_at, ?),
2287
+ leased_until = CASE WHEN owner = ? THEN 0 ELSE leased_until END,
2288
+ owner = CASE WHEN owner = ? THEN NULL ELSE owner END
2289
+ WHERE host = ?`).run(nextAt, this.owner, this.owner, host);
2290
+ });
2291
+ }
2292
+ defer(host, until) {
2293
+ this.step(() => {
2294
+ this.row(host);
2295
+ this.stmt("UPDATE hosts SET next_at = max(next_at, ?) WHERE host = ?").run(until, host);
2296
+ });
2297
+ }
2298
+ stop(host, until, reason) {
2299
+ const end = Number.isFinite(until) ? until : NO_END;
2300
+ this.step(() => {
2301
+ this.row(host);
2302
+ this.stmt(`UPDATE hosts SET stop_until = ?, stop_reason = ?
2303
+ WHERE host = ? AND (stop_until IS NULL OR stop_until < ?)`).run(end, reason, host, end);
2304
+ });
2305
+ }
2306
+ stopped(host, now) {
2307
+ if (this.closed) throw new Error("the host state store is closed");
2308
+ const row = this.stmt("SELECT stop_until, stop_reason FROM hosts WHERE host = ?").get(host);
2309
+ if (row === void 0 || row.stop_until === null || row.stop_until <= now) return void 0;
2310
+ return {
2311
+ until: row.stop_until >= NO_END ? Number.POSITIVE_INFINITY : row.stop_until,
2312
+ reason: row.stop_reason ?? "stopped"
2313
+ };
2314
+ }
2315
+ reset(host) {
2316
+ this.step(() => {
2317
+ if (host === void 0) this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL").run();
2318
+ else this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL WHERE host = ?").run(host);
2319
+ });
2320
+ }
2321
+ validators(host) {
2322
+ if (this.closed) throw new Error("the host state store is closed");
2323
+ const row = this.stmt(`SELECT skip_etag, etag, last_modified, etag_misses, date_misses, since_probe, changed_at
2324
+ FROM validators WHERE host = ?`).get(host);
2325
+ return row === void 0 ? void 0 : memoOf(row);
2326
+ }
2327
+ noteValidators(host, report, now) {
2328
+ return this.step(() => {
2329
+ const memo = nextValidatorMemo(this.validators(host), report, now);
2330
+ this.stmt(`INSERT INTO validators (host, skip_etag, etag, last_modified, etag_misses, date_misses, since_probe, changed_at)
2331
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2332
+ ON CONFLICT(host) DO UPDATE SET skip_etag = excluded.skip_etag, etag = excluded.etag,
2333
+ last_modified = excluded.last_modified, etag_misses = excluded.etag_misses,
2334
+ date_misses = excluded.date_misses, since_probe = excluded.since_probe,
2335
+ changed_at = excluded.changed_at`).run(host, memo.skipEtag ? 1 : 0, memo.etag, memo.lastModified, memo.etagMisses, memo.dateMisses, memo.sinceProbe, memo.changedAt);
2336
+ return memo;
2337
+ });
2338
+ }
2339
+ /** This value is the number of hosts that the store holds. */
2340
+ get size() {
2341
+ return this.stmt("SELECT count(*) AS n FROM hosts").get().n;
2342
+ }
2343
+ /** This method closes the file. A second close does nothing. */
2344
+ close() {
2345
+ if (this.closed) return;
2346
+ this.closed = true;
2347
+ this.statements.clear();
2348
+ this.db.close();
2349
+ }
2350
+ };
2351
+ //#endregion
2352
+ //#region src/embed.ts
2353
+ /** These are the limits of the chunks of a text. */
2354
+ const CHUNK_LIMITS = {
2355
+ maxTokens: 512,
2356
+ defaultTokens: 256,
2357
+ fill: .9,
2358
+ overlap: .1,
2359
+ maxChunks: 64
2360
+ };
2361
+ /** These scripts write no space between words, so the estimate counts each character as a token. */
2362
+ const SEGMENTED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
2363
+ const WORD = /[\p{L}\p{N}\p{M}]/u;
2364
+ const SPACE = /\s/u;
2365
+ /** A word of this number of letters or fewer is one token in the estimate. A longer word is more tokens. */
2366
+ const LETTERS_PER_TOKEN = 6;
2367
+ const isAsciiWord = (code) => code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122;
2368
+ const isAsciiSpace = (code) => code === 32 || code >= 9 && code <= 13;
2369
+ const kindOf = (point) => {
2370
+ if (point < 128) return isAsciiWord(point) ? "word" : isAsciiSpace(point) ? "space" : "other";
2371
+ const char = String.fromCodePoint(point);
2372
+ if (SEGMENTED.test(char)) return "segmented";
2373
+ if (WORD.test(char)) return "word";
2374
+ return SPACE.test(char) ? "space" : "other";
2375
+ };
2376
+ /**
2377
+ * This function estimates the number of tokens of a part of a text. A word is one token for each
2378
+ * six letters. A Chinese, Japanese or Thai character is one token. Each other character that is
2379
+ * not a space is one token. The estimate is a little high for English text, so a chunk fits.
2380
+ */
2381
+ const estimateTokens = (text, start = 0, end = text.length) => {
2382
+ let tokens = 0;
2383
+ let run = 0;
2384
+ for (let at = start; at < end;) {
2385
+ const point = text.codePointAt(at);
2386
+ at += point > 65535 ? 2 : 1;
2387
+ const kind = kindOf(point);
2388
+ if (kind === "word") {
2389
+ run += 1;
2390
+ continue;
2391
+ }
2392
+ if (run > 0) tokens += Math.ceil(run / LETTERS_PER_TOKEN);
2393
+ run = 0;
2394
+ if (kind !== "space") tokens += 1;
2395
+ }
2396
+ if (run > 0) tokens += Math.ceil(run / LETTERS_PER_TOKEN);
2397
+ return tokens;
2398
+ };
2399
+ /** These characters end a sentence when a space follows them. */
2400
+ const STOP = /[.!?…]+["'”’)\]]*(?=\s|$)|[。!?]+/gu;
2401
+ /** This function gives the ranges of the sentences of a part of a text, with no space at the ends. */
2402
+ const sentencesOf = (text, start, end) => {
2403
+ const part = text.slice(start, end);
2404
+ const out = [];
2405
+ let from = 0;
2406
+ for (const match of part.matchAll(STOP)) {
2407
+ const to = match.index + match[0].length;
2408
+ if (to > from) out.push([from, to]);
2409
+ from = to;
2410
+ }
2411
+ if (from < part.length) out.push([from, part.length]);
2412
+ return out.map(([a, b]) => {
2413
+ let first = a;
2414
+ let last = b;
2415
+ while (first < last && SPACE.test(part[first])) first += 1;
2416
+ while (last > first && SPACE.test(part[last - 1])) last -= 1;
2417
+ return [start + first, start + last];
2418
+ }).filter(([a, b]) => b > a);
2419
+ };
2420
+ /** This function cuts a part of a text that is longer than the limit. It cuts at the spaces, and in a word that is too long. */
2421
+ const cutLong = (text, start, end, size) => {
2422
+ const out = [];
2423
+ let from = start;
2424
+ let tokens = 0;
2425
+ let run = 0;
2426
+ let lastSpace = -1;
2427
+ let at = start;
2428
+ const emit = (to) => {
2429
+ let first = from;
2430
+ while (first < to && SPACE.test(text[first])) first += 1;
2431
+ let last = to;
2432
+ while (last > first && SPACE.test(text[last - 1])) last -= 1;
2433
+ if (last > first) out.push({
2434
+ start: first,
2435
+ end: last,
2436
+ tokens: estimateTokens(text, first, last),
2437
+ paragraph: false
2438
+ });
2439
+ from = to;
2440
+ tokens = 0;
2441
+ run = 0;
2442
+ lastSpace = -1;
2443
+ };
2444
+ while (at < end) {
2445
+ const point = text.codePointAt(at);
2446
+ const width = point > 65535 ? 2 : 1;
2447
+ const kind = kindOf(point);
2448
+ let cost = 0;
2449
+ if (kind === "word") {
2450
+ run += 1;
2451
+ if (run % LETTERS_PER_TOKEN === 1) cost = 1;
2452
+ } else {
2453
+ run = 0;
2454
+ if (kind !== "space") cost = 1;
2455
+ }
2456
+ if (tokens + cost > size && at > from) {
2457
+ if (lastSpace > from) {
2458
+ const to = lastSpace;
2459
+ emit(to);
2460
+ at = to;
2461
+ continue;
2462
+ }
2463
+ emit(at);
2464
+ }
2465
+ if (kind === "space") lastSpace = at;
2466
+ tokens += cost;
2467
+ at += width;
2468
+ }
2469
+ emit(end);
2470
+ return out;
2471
+ };
2472
+ /** This function gives the pieces of a text: its sentences, in paragraphs, and the parts of a sentence that is longer than the limit. */
2473
+ const piecesOf = (text, size) => {
2474
+ const pieces = [];
2475
+ for (const line of text.matchAll(/[^\r\n]+/g)) {
2476
+ let first = true;
2477
+ for (const [start, end] of sentencesOf(text, line.index, line.index + line[0].length)) {
2478
+ const tokens = estimateTokens(text, start, end);
2479
+ const parts = tokens <= size ? [{
2480
+ start,
2481
+ end,
2482
+ tokens,
2483
+ paragraph: false
2484
+ }] : cutLong(text, start, end, size);
2485
+ for (const part of parts) {
2486
+ pieces.push({
2487
+ ...part,
2488
+ paragraph: first
2489
+ });
2490
+ first = false;
2491
+ }
2492
+ }
2493
+ }
2494
+ return pieces;
2495
+ };
2496
+ const positive = (name, value) => {
2497
+ if (!Number.isFinite(value) || value < 1) throw new RangeError(`${name} must be a number of 1 or more, not ${String(value)}`);
2498
+ return Math.floor(value);
2499
+ };
2500
+ /**
2501
+ * This function cuts a text into chunks for an embedder. A chunk is a window of whole sentences.
2502
+ * It ends at the end of a paragraph when the paragraph fills half of the window or more. A
2503
+ * sentence that is longer than the window is cut at its spaces. Two chunks next to each other share
2504
+ * a few sentences (`overlapTokens`), so a passage at a cut is in one chunk. Each chunk keeps its
2505
+ * position in the text, so a hit points at a passage.
2506
+ */
2507
+ const chunkText = (text, options) => {
2508
+ const size = positive("maxTokens", options.maxTokens);
2509
+ const overlap = Math.min(Math.floor(size / 2), Math.max(0, Math.floor(options.overlapTokens ?? size * CHUNK_LIMITS.overlap)));
2510
+ const maxChunks = positive("maxChunks", options.maxChunks ?? CHUNK_LIMITS.maxChunks);
2511
+ const pieces = piecesOf(text, size);
2512
+ const chunks = [];
2513
+ let first = 0;
2514
+ let fresh = 0;
2515
+ while (first < pieces.length && chunks.length < maxChunks) {
2516
+ let last = first;
2517
+ let tokens = pieces[first].tokens;
2518
+ while (last + 1 < pieces.length && tokens + pieces[last + 1].tokens <= size) {
2519
+ last += 1;
2520
+ tokens += pieces[last].tokens;
2521
+ }
2522
+ if (last + 1 < pieces.length && !pieces[last + 1].paragraph) {
2523
+ let filled = 0;
2524
+ let cut = -1;
2525
+ for (let at = first; at < last; at += 1) {
2526
+ filled += pieces[at].tokens;
2527
+ if (at >= fresh && pieces[at + 1].paragraph && filled * 2 >= size) cut = at;
2528
+ }
2529
+ if (cut !== -1) last = cut;
2530
+ }
2531
+ fresh = last + 1;
2532
+ const start = pieces[first].start;
2533
+ const end = pieces[last].end;
2534
+ chunks.push({
2535
+ index: chunks.length,
2536
+ start,
2537
+ end,
2538
+ text: text.slice(start, end)
2539
+ });
2540
+ if (last + 1 >= pieces.length) break;
2541
+ let next = last + 1;
2542
+ let shared = 0;
2543
+ while (next - 1 > first && shared + pieces[next - 1].tokens <= overlap) {
2544
+ next -= 1;
2545
+ shared += pieces[next].tokens;
2546
+ }
2547
+ if (shared + pieces[last + 1].tokens > size) next = last + 1;
2548
+ first = next;
2549
+ }
2550
+ return chunks;
2551
+ };
2552
+ /**
2553
+ * This function gives the number of tokens of a chunk for an embedder. It is the input limit with a
2554
+ * margin for the estimate, and `CHUNK_LIMITS.maxTokens` at most.
2555
+ */
2556
+ const chunkTokensOf = (embedder) => {
2557
+ const limit = embedder.maxInputTokens;
2558
+ const tokens = limit === void 0 || !Number.isFinite(limit) || limit < 1 ? CHUNK_LIMITS.defaultTokens : Math.min(limit, CHUNK_LIMITS.maxTokens);
2559
+ return Math.max(8, Math.floor(tokens * CHUNK_LIMITS.fill));
2560
+ };
2561
+ /** This function gives a copy of a vector with a length of 1. A vector of zeros stays zeros. A value that is not finite throws a `RangeError`. */
2562
+ const unitVector = (vector) => {
2563
+ const out = Float32Array.from(vector);
2564
+ let sum = 0;
2565
+ for (const value of out) {
2566
+ if (!Number.isFinite(value)) throw new RangeError("the embedder gave a value that is not finite");
2567
+ sum += value * value;
2568
+ }
2569
+ if (sum === 0) return out;
2570
+ const scale = 1 / Math.sqrt(sum);
2571
+ for (let at = 0; at < out.length; at += 1) out[at] = out[at] * scale;
2572
+ return out;
2573
+ };
2574
+ /** These are the defaults of a hybrid search. */
2575
+ const HYBRID_DEFAULTS = {
2576
+ candidates: 50,
2577
+ neighbors: 50,
2578
+ fusionK: 60
2579
+ };
2580
+ /**
2581
+ * This function fuses ranked lists with reciprocal rank fusion. Each item gets the sum of
2582
+ * 1 / (k + rank) over the lists that hold it, with the rank from 1. The best item is first. A tie goes to
2583
+ * the item with the best rank in a list, then to the earlier list, then to the smaller id. An item
2584
+ * that is two times in one list counts at its first place.
2585
+ */
2586
+ const fuseRanks = (lists, k = HYBRID_DEFAULTS.fusionK) => {
2587
+ if (!Number.isFinite(k) || k < 0) throw new RangeError(`the fusion constant k must be a number of 0 or more, not ${String(k)}`);
2588
+ const ranks = /* @__PURE__ */ new Map();
2589
+ lists.forEach((list, which) => {
2590
+ list.forEach((id, at) => {
2591
+ let entry = ranks.get(id);
2592
+ if (entry === void 0) {
2593
+ entry = lists.map(() => null);
2594
+ ranks.set(id, entry);
2595
+ }
2596
+ entry[which] ??= at + 1;
2597
+ });
2598
+ });
2599
+ const keyed = [...ranks].map(([id, entry]) => {
2600
+ let score = 0;
2601
+ let best = Number.POSITIVE_INFINITY;
2602
+ for (const rank of entry) {
2603
+ if (rank === null) continue;
2604
+ score += 1 / (k + rank);
2605
+ best = Math.min(best, rank);
2606
+ }
2607
+ return {
2608
+ id,
2609
+ score,
2610
+ ranks: entry,
2611
+ best,
2612
+ first: entry.findIndex((rank) => rank !== null)
2613
+ };
2614
+ });
2615
+ keyed.sort((a, b) => b.score - a.score || a.best - b.best || a.first - b.first || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2616
+ return keyed.map(({ id, score, ranks: entry }) => ({
2617
+ id,
2618
+ score,
2619
+ ranks: entry
2620
+ }));
2621
+ };
2622
+ //#endregion
2623
+ //#region src/hybrid.ts
2624
+ /** The embedder gets this number of texts in one call at most. */
2625
+ const BATCH = 32;
2626
+ /** The search keeps the vectors of this number of questions. */
2627
+ const QUERY_CACHE = 32;
2628
+ /** The search checks this number of texts of the vector search against the index at one time. */
2629
+ const PAGE = 64;
2630
+ const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
2631
+ const count = (name, value, fallback, max) => {
2632
+ if (value === void 0) return fallback;
2633
+ if (!Number.isFinite(value) || value < 1) throw new RangeError(`${name} must be a number of 1 or more, not ${String(value)}`);
2634
+ return Math.min(max, Math.floor(value));
2635
+ };
2636
+ /** This function checks the constant k of reciprocal rank fusion. */
2637
+ const fusionKOf = (value) => {
2638
+ if (value === void 0) return HYBRID_DEFAULTS.fusionK;
2639
+ if (!Number.isFinite(value) || value < 0) throw new RangeError(`fusionK must be a number of 0 or more, not ${String(value)}`);
2640
+ return value;
2641
+ };
2642
+ /** This function gives a promise with its resolve and its reject. A rejection with no listener is not an error of the process. */
2643
+ const deferred = () => {
2644
+ let resolve = () => void 0;
2645
+ let reject = () => void 0;
2646
+ const promise = new Promise((yes, no) => {
2647
+ resolve = yes;
2648
+ reject = no;
2649
+ });
2650
+ promise.catch(() => void 0);
2651
+ return {
2652
+ promise,
2653
+ resolve,
2654
+ reject
2655
+ };
2656
+ };
2657
+ const round = (value, digits) => {
2658
+ const scale = 10 ** digits;
2659
+ return Math.round(value * scale) / scale;
2660
+ };
2661
+ /** This function makes a hybrid search over a runtime. The vectors go into the store, with the id of the embedder. */
2662
+ const createHybridSearch = (runtime, options) => {
2663
+ const { embedder, store } = options;
2664
+ const tokens = chunkTokensOf(embedder);
2665
+ const plan = `chunks-1/${String(tokens)}/${String(Math.floor(tokens * CHUNK_LIMITS.overlap))}/${String(CHUNK_LIMITS.maxChunks)}`;
2666
+ const working = /* @__PURE__ */ new Map();
2667
+ const queries = /* @__PURE__ */ new Map();
2668
+ let dimensions = embedder.dimensions > 0 ? embedder.dimensions : store.dimensions(embedder.id);
2669
+ /** This function gives the heads of documents, from `digests` when the index has it. */
2670
+ const headsOf = (docs) => {
2671
+ const heads = /* @__PURE__ */ new Map();
2672
+ if (docs.length === 0) return heads;
2673
+ const index = runtime.index;
2674
+ if (index.digests !== void 0) {
2675
+ for (const head of index.digests({ docs })) heads.set(head.doc, head);
2676
+ return heads;
2677
+ }
2678
+ for (const doc of docs) {
2679
+ const found = index.get(doc);
2680
+ if (found !== void 0) heads.set(doc, headOfDoc(found));
2681
+ }
2682
+ return heads;
2683
+ };
2684
+ const headOfDoc = (doc) => ({
2685
+ doc: doc.doc,
2686
+ kind: doc.kind,
2687
+ ai_input: doc.ai_input,
2688
+ text_sha: sha256(doc.text)
2689
+ });
2690
+ /** This function asks the embedder for the vectors of texts, in batches, and checks them. */
2691
+ const vectorsOf = async (texts, purpose, signal) => {
2692
+ const out = [];
2693
+ for (let at = 0; at < texts.length; at += BATCH) {
2694
+ signal?.throwIfAborted();
2695
+ const part = texts.slice(at, at + BATCH);
2696
+ const given = await embedder.embed(part, {
2697
+ signal,
2698
+ purpose
2699
+ });
2700
+ if (!Array.isArray(given) || given.length !== part.length) throw new Error(`the embedder ${embedder.id} gave ${String(Array.isArray(given) ? given.length : 0)} vectors for ${String(part.length)} texts`);
2701
+ for (const vector of given) {
2702
+ const unit = unitVector(vector);
2703
+ dimensions ??= unit.length;
2704
+ if (unit.length !== dimensions || unit.length === 0) throw new Error(`the embedder ${embedder.id} gave a vector of ${String(unit.length)} dimensions, not ${String(dimensions)}`);
2705
+ out.push(unit);
2706
+ }
2707
+ }
2708
+ return out;
2709
+ };
2710
+ const queryVector = async (q, signal) => {
2711
+ const text = q.replace(/["*]/g, " ").replace(/\s+/g, " ").trim();
2712
+ const known = queries.get(text);
2713
+ if (known !== void 0) {
2714
+ queries.delete(text);
2715
+ queries.set(text, known);
2716
+ return known;
2717
+ }
2718
+ const [vector] = await vectorsOf([text], "query", signal);
2719
+ queries.set(text, vector);
2720
+ if (queries.size > QUERY_CACHE) queries.delete(queries.keys().next().value);
2721
+ return vector;
2722
+ };
2723
+ /**
2724
+ * This function gives vectors to the texts of documents that have none, and points each document
2725
+ * at its current text. It reads each text from the index, and it does not send the text
2726
+ * of a held-back document. Two calls for one text share the work. It gives the number of new vectors.
2727
+ */
2728
+ const ensure = async (docs, signal) => {
2729
+ const tally = {
2730
+ embedded: 0,
2731
+ reused: 0,
2732
+ held: 0,
2733
+ chunks: 0
2734
+ };
2735
+ const now = runtime.now();
2736
+ const heads = headsOf(docs);
2737
+ const waiting = /* @__PURE__ */ new Map();
2738
+ const shared = [];
2739
+ for (const id of docs) {
2740
+ const head = heads.get(id);
2741
+ if (head === void 0) continue;
2742
+ if (isHeldBack(head)) {
2743
+ tally.held += 1;
2744
+ if (store.pointed(embedder.id, id) !== void 0) store.forget(embedder.id, id);
2745
+ continue;
2746
+ }
2747
+ if (store.plan(embedder.id, head.text_sha) === plan) {
2748
+ tally.reused += 1;
2749
+ if (store.pointed(embedder.id, id) !== head.text_sha) store.point(embedder.id, id, head.text_sha, now);
2750
+ continue;
2751
+ }
2752
+ const doc = runtime.index.get(id);
2753
+ if (doc === void 0 || isHeldBack(doc)) continue;
2754
+ const sha = sha256(doc.text);
2755
+ const entry = waiting.get(sha);
2756
+ if (entry !== void 0) {
2757
+ entry.docs.push(id);
2758
+ continue;
2759
+ }
2760
+ const busy = working.get(sha);
2761
+ if (busy !== void 0) {
2762
+ shared.push(busy.then((made) => {
2763
+ store.point(embedder.id, id, sha, now);
2764
+ return made;
2765
+ }));
2766
+ continue;
2767
+ }
2768
+ waiting.set(sha, {
2769
+ text: doc.text,
2770
+ docs: [id]
2771
+ });
2772
+ }
2773
+ const jobs = [...waiting].map(([sha, entry]) => {
2774
+ const job = {
2775
+ sha,
2776
+ entry,
2777
+ chunks: chunkText(entry.text, { maxTokens: tokens }),
2778
+ ...deferred()
2779
+ };
2780
+ working.set(sha, job.promise);
2781
+ return job;
2782
+ });
2783
+ try {
2784
+ for (let at = 0; at < jobs.length;) {
2785
+ const batch = [];
2786
+ let size = 0;
2787
+ while (at < jobs.length && (batch.length === 0 || size + jobs[at].chunks.length <= BATCH)) {
2788
+ batch.push(jobs[at]);
2789
+ size += jobs[at].chunks.length;
2790
+ at += 1;
2791
+ }
2792
+ const vectors = await vectorsOf(batch.flatMap((job) => job.chunks.map((chunk) => chunk.text)), "document", signal);
2793
+ let next = 0;
2794
+ for (const job of batch) {
2795
+ const chunks = job.chunks.map((chunk) => ({
2796
+ start: chunk.start,
2797
+ end: chunk.end,
2798
+ vector: vectors[next++]
2799
+ }));
2800
+ store.put({
2801
+ embedder: embedder.id,
2802
+ dimensions: dimensions ?? chunks[0]?.vector.length ?? 0,
2803
+ sha: job.sha,
2804
+ plan,
2805
+ chars: job.entry.text.length,
2806
+ chunks,
2807
+ docs: job.entry.docs,
2808
+ at: now
2809
+ });
2810
+ tally.embedded += 1;
2811
+ tally.chunks += chunks.length;
2812
+ working.delete(job.sha);
2813
+ job.resolve(chunks.length);
2814
+ }
2815
+ }
2816
+ } catch (error) {
2817
+ for (const job of jobs) {
2818
+ if (working.get(job.sha) === job.promise) working.delete(job.sha);
2819
+ job.reject(error);
2820
+ }
2821
+ throw error;
2822
+ }
2823
+ await Promise.all(shared);
2824
+ return tally;
2825
+ };
2826
+ /**
2827
+ * This function gives the documents of the vector search, the best first, to `limit`. It checks
2828
+ * each document against the index. Its current text must be the text of the hit, it must not be
2829
+ * held back, and it must have the kind. With `within`, it ranks only those documents.
2830
+ */
2831
+ const neighborsOf = (query, limit, kind, within) => {
2832
+ const hits = store.search(embedder.id, query, within === void 0 ? {} : { within });
2833
+ const out = [];
2834
+ for (let at = 0; at < hits.length && out.length < limit; at += PAGE) {
2835
+ const page = hits.slice(at, at + PAGE);
2836
+ const docs = [...new Set(page.flatMap((hit) => within === void 0 ? hit.docs : hit.docs.filter((doc) => within.has(doc))))];
2837
+ const heads = headsOf(docs);
2838
+ for (const hit of page) for (const doc of hit.docs) {
2839
+ if (out.length >= limit) break;
2840
+ if (within !== void 0 && !within.has(doc)) continue;
2841
+ const head = heads.get(doc);
2842
+ if (head === void 0 || head.text_sha !== hit.sha || isHeldBack(head)) continue;
2843
+ if (kind !== void 0 && kind !== null && head.kind !== kind) continue;
2844
+ out.push({
2845
+ doc,
2846
+ hit
2847
+ });
2848
+ }
2849
+ }
2850
+ return out;
2851
+ };
2852
+ const passageOf = (hit) => ({
2853
+ start: hit.start,
2854
+ end: hit.end,
2855
+ similarity: round(hit.similarity, 4)
2856
+ });
2857
+ return {
2858
+ embedder,
2859
+ async find(q, findOptions = {}) {
2860
+ const query = parseQuery(q);
2861
+ if (query === void 0) return [];
2862
+ const limit = Math.max(1, Math.min(1e3, Math.floor(findOptions.limit ?? 10) || 10));
2863
+ const candidates = count("candidates", findOptions.candidates, HYBRID_DEFAULTS.candidates, 1e3);
2864
+ const neighbors = count("neighbors", findOptions.neighbors, HYBRID_DEFAULTS.neighbors, 1e3);
2865
+ const k = fusionKOf(findOptions.fusionK);
2866
+ const signal = findOptions.signal;
2867
+ const vector = await queryVector(q, signal);
2868
+ const kind = findOptions.kind ?? null;
2869
+ const lexical = runtime.index.find(q, {
2870
+ limit: candidates,
2871
+ kind,
2872
+ rank: "exact"
2873
+ });
2874
+ await ensure(lexical.map((hit) => hit.doc), signal);
2875
+ const near = neighborsOf(vector, neighbors, kind, void 0);
2876
+ const fused = fuseRanks([lexical.map((hit) => hit.doc), near.map((one) => one.doc)], k).slice(0, limit);
2877
+ const byDoc = new Map(lexical.map((hit) => [hit.doc, hit]));
2878
+ const nearBy = new Map(near.map((one) => [one.doc, one.hit]));
2879
+ const results = [];
2880
+ for (const one of fused) {
2881
+ const doc = runtime.index.get(one.id);
2882
+ if (doc === void 0 || isHeldBack(doc)) continue;
2883
+ const best = nearBy.get(one.id) ?? store.best(embedder.id, sha256(doc.text), vector);
2884
+ const lexicalHit = byDoc.get(one.id);
2885
+ const archived = doc.kind === "archived" ? archivedOf(doc.meta) : void 0;
2886
+ results.push({
2887
+ doc: doc.doc,
2888
+ kind: doc.kind,
2889
+ title: doc.title,
2890
+ url: doc.url,
2891
+ snippet: best === void 0 ? lexicalHit?.snippet ?? snippetOf(doc.text, query.terms) : snippetOf(doc.text.slice(best.start, best.end), query.terms),
2892
+ score: round(one.score, 6),
2893
+ ai_input: doc.ai_input,
2894
+ fetched_at: doc.fetched_at,
2895
+ capture: doc.capture,
2896
+ ...archived === void 0 ? {} : { archived },
2897
+ ...best === void 0 ? {} : { passage: passageOf(best) }
2898
+ });
2899
+ }
2900
+ return results;
2901
+ },
2902
+ async rankWithin(q, docs, rankOptions = {}) {
2903
+ if (parseQuery(q) === void 0 || docs.length === 0) return [];
2904
+ const candidates = count("candidates", rankOptions.candidates, HYBRID_DEFAULTS.candidates, 1e3);
2905
+ const neighbors = count("neighbors", rankOptions.neighbors, HYBRID_DEFAULTS.neighbors, 1e5);
2906
+ const k = fusionKOf(rankOptions.fusionK);
2907
+ const signal = rankOptions.signal;
2908
+ const vector = await queryVector(q, signal);
2909
+ const lexical = runtime.index.rankWithin(q, docs, { rank: "exact" });
2910
+ const first = [.../* @__PURE__ */ new Set([...lexical.map((hit) => hit.doc), ...docs])].slice(0, candidates);
2911
+ await ensure(first, signal);
2912
+ const within = new Set(docs);
2913
+ const near = neighborsOf(vector, neighbors, void 0, within);
2914
+ const nearBy = new Map(near.map((one) => [one.doc, one.hit]));
2915
+ const heads = headsOf(lexical.map((hit) => hit.doc).filter((doc) => !nearBy.has(doc)));
2916
+ return fuseRanks([lexical.map((hit) => hit.doc), near.map((one) => one.doc)], k).map((one) => {
2917
+ const sha = heads.get(one.id)?.text_sha;
2918
+ const best = nearBy.get(one.id) ?? (sha === void 0 ? void 0 : store.best(embedder.id, sha, vector));
2919
+ return {
2920
+ doc: one.id,
2921
+ score: round(one.score, 6),
2922
+ ...best === void 0 ? {} : { passage: passageOf(best) }
2923
+ };
2924
+ });
2925
+ },
2926
+ async embed(embedOptions = {}) {
2927
+ const started = Date.now();
2928
+ const budgetMs = durationMs(embedOptions.budget ?? "10m");
2929
+ const maxDocs = embedOptions.maxDocs === void 0 ? Number.POSITIVE_INFINITY : count("maxDocs", embedOptions.maxDocs, 1, Number.MAX_SAFE_INTEGER);
2930
+ const kind = embedOptions.kind ?? null;
2931
+ const timer = new AbortController();
2932
+ const clock = setTimeout(() => timer.abort(/* @__PURE__ */ new Error("the budget of embed is over")), Math.min(budgetMs, 2147483647));
2933
+ clock.unref();
2934
+ const signal = embedOptions.signal === void 0 ? timer.signal : AbortSignal.any([embedOptions.signal, timer.signal]);
2935
+ const tally = {
2936
+ docs: 0,
2937
+ embedded: 0,
2938
+ reused: 0,
2939
+ held_back: 0,
2940
+ chunks: 0
2941
+ };
2942
+ let stopped = "done";
2943
+ const stopOf = () => embedOptions.signal?.aborted === true ? "signal" : timer.signal.aborted || Date.now() - started >= budgetMs ? "budget" : tally.embedded >= maxDocs ? "max-docs" : void 0;
2944
+ try {
2945
+ for await (const page of pagesOf(runtime)) {
2946
+ const docs = page.filter((head) => kind === null || head.kind === kind);
2947
+ for (let at = 0; at < docs.length;) {
2948
+ const stop = stopOf();
2949
+ if (stop !== void 0) {
2950
+ stopped = stop;
2951
+ break;
2952
+ }
2953
+ const room = Math.max(1, Math.min(BATCH, maxDocs - tally.embedded));
2954
+ const group = docs.slice(at, at + room).map((head) => head.doc);
2955
+ at += group.length;
2956
+ try {
2957
+ const done = await ensure(group, signal);
2958
+ tally.docs += group.length;
2959
+ tally.embedded += done.embedded;
2960
+ tally.reused += done.reused;
2961
+ tally.held_back += done.held;
2962
+ tally.chunks += done.chunks;
2963
+ } catch (error) {
2964
+ const stop = stopOf();
2965
+ if (stop === void 0) throw error;
2966
+ stopped = stop;
2967
+ break;
2968
+ }
2969
+ }
2970
+ if (stopped !== "done") break;
2971
+ }
2972
+ } finally {
2973
+ clearTimeout(clock);
2974
+ }
2975
+ return {
2976
+ embedder: embedder.id,
2977
+ ...tally,
2978
+ stopped,
2979
+ ms: Date.now() - started
2980
+ };
2981
+ }
2982
+ };
2983
+ };
2984
+ /**
2985
+ * This function gives the heads of each stored document, in pages. It uses `digests` when the
2986
+ * index has it. Else it reads the capture log and asks the index for the documents of each capture.
2987
+ */
2988
+ async function* pagesOf(runtime) {
2989
+ const index = runtime.index;
2990
+ if (index.digests !== void 0) {
2991
+ let after = "";
2992
+ for (;;) {
2993
+ const page = index.digests({
2994
+ after,
2995
+ limit: 500
2996
+ });
2997
+ if (page.length === 0) return;
2998
+ yield page;
2999
+ after = page.at(-1).doc;
3000
+ }
3001
+ }
3002
+ const seen = /* @__PURE__ */ new Set();
3003
+ let page = [];
3004
+ for await (const envelope of runtime.captures.scan({ source: "fetch" })) for (const doc of index.docsOfCapture(envelope.id)) {
3005
+ if (seen.has(doc.doc)) continue;
3006
+ seen.add(doc.doc);
3007
+ page.push({
3008
+ doc: doc.doc,
3009
+ kind: doc.kind,
3010
+ ai_input: doc.ai_input,
3011
+ text_sha: sha256(doc.text)
3012
+ });
3013
+ if (page.length >= 500) {
3014
+ yield page;
3015
+ page = [];
3016
+ }
3017
+ }
3018
+ if (page.length > 0) yield page;
3019
+ }
3020
+ //#endregion
3021
+ //#region src/recipe.ts
3022
+ /**
3023
+ * This function reads a body as JSON, in the encoding that the response declares. A body that is
3024
+ * not JSON (a page about maintenance, or a login page) throws an error that names the URL and the
3025
+ * content type. In an extractor, the runtime then reports that error.
3026
+ */
3027
+ const readJson = (body, capture) => {
3028
+ const text = decodeBody(body, capture.contentType);
3029
+ try {
3030
+ return JSON.parse(text);
3031
+ } catch {
3032
+ throw new Error(`${capture.url} answered ${capture.contentType ?? "a body of no stated type"}, not JSON`);
3033
+ }
3034
+ };
3035
+ /**
3036
+ * These recipe names belong to the crawler: the page reader, the archived fallback, robots.txt,
3037
+ * and the sitemaps and feeds of `discover`. A recipe cannot use them.
3038
+ */
3039
+ const RESERVED_RECIPES = [
3040
+ "page",
3041
+ "archive-copy",
3042
+ "archive-index",
3043
+ "robots.txt",
3044
+ "sitemap",
3045
+ "feed"
3046
+ ];
3047
+ /** These are the limits of a recipe. */
3048
+ const RECIPE_LIMITS = {
3049
+ nameChars: 64,
3050
+ docIdChars: 2048
3051
+ };
3052
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
3053
+ const PAGE_ACCEPT = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5";
3054
+ const API_ACCEPT = "application/json";
3055
+ const AI_INPUTS = [
3056
+ "yes",
3057
+ "no",
3058
+ "unset"
3059
+ ];
3060
+ /** This function tells what is wrong with one document of an extractor, or gives undefined. */
3061
+ const faultOf = (value, seen) => {
3062
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "a document must be an object";
3063
+ const doc = value;
3064
+ const id = doc["doc"];
3065
+ if (typeof id !== "string" || id.length === 0 || id.length > RECIPE_LIMITS.docIdChars) return `doc must be a text of 1 to ${String(RECIPE_LIMITS.docIdChars)} characters`;
3066
+ if (id.startsWith("archive:")) return `the id ${id} starts with archive:, which is for the archived copies`;
3067
+ if (seen.has(id)) return `two documents have the id ${id}`;
3068
+ seen.add(id);
3069
+ const kind = doc["kind"];
3070
+ if (typeof kind !== "string" || kind.length === 0) return "kind must be a text that is not empty";
3071
+ if (kind === "archived") return "the kind archived is for the archived copies";
3072
+ if (typeof doc["title"] !== "string") return "title must be a text";
3073
+ if (typeof doc["text"] !== "string") return "text must be a text";
3074
+ if (doc["url"] !== null && typeof doc["url"] !== "string") return "url must be a text or null";
3075
+ const meta = doc["meta"];
3076
+ if (meta === null || typeof meta !== "object" || Array.isArray(meta)) return "meta must be an object";
3077
+ try {
3078
+ JSON.stringify(meta);
3079
+ } catch {
3080
+ return "meta must be JSON: no cycle and no BigInt";
3081
+ }
3082
+ const merge = doc["merge"];
3083
+ if (merge !== void 0 && merge !== "latest" && merge !== "richest") return "merge must be \"latest\" or \"richest\"";
3084
+ if (doc["full_text"] !== void 0 && typeof doc["full_text"] !== "string") return "full_text must be a text";
3085
+ const aiInput = doc["ai_input"];
3086
+ if (aiInput !== void 0 && !AI_INPUTS.includes(aiInput)) return "ai_input must be \"yes\", \"no\" or \"unset\"";
3087
+ };
3088
+ /** This function gives the documents of an extractor, or throws an error that names the first fault. */
3089
+ const checkedDocs = (name, docs) => {
3090
+ if (!Array.isArray(docs)) throw new TypeError(`the recipe ${name} gave ${typeof docs}, not an array of documents`);
3091
+ const seen = /* @__PURE__ */ new Set();
3092
+ for (const [index, doc] of docs.entries()) {
3093
+ const fault = faultOf(doc, seen);
3094
+ if (fault !== void 0) throw new TypeError(`the recipe ${name} gave a document that is not valid (${String(index)}): ${fault}`);
3095
+ }
3096
+ return docs;
3097
+ };
3098
+ /**
3099
+ * This function declares a recipe. It checks the definition at once: a name that is not valid, a
3100
+ * reserved name or a duration that it cannot read throws an error. The extractor of the recipe
3101
+ * checks each document of `extract`. A document that is not valid makes the extractor throw, and
3102
+ * the runtime reports it as an error of the extractor.
3103
+ */
3104
+ const defineRecipe = (definition) => {
3105
+ const name = definition.name;
3106
+ if (typeof name !== "string" || name.length > RECIPE_LIMITS.nameChars || !NAME.test(name)) throw new RangeError(`a recipe name is 1 to ${String(RECIPE_LIMITS.nameChars)} letters, digits and . _ : -, and it starts with a letter or a digit, not ${JSON.stringify(name) ?? "undefined"}`);
3107
+ if (RESERVED_RECIPES.includes(name)) throw new RangeError(`the recipe name ${name} belongs to the crawler: give the recipe a name of its own`);
3108
+ if (typeof definition.extract !== "function") throw new TypeError(`the recipe ${name} needs extract, a function that reads a body into documents`);
3109
+ const mode = definition.mode ?? "page";
3110
+ if (mode !== "page" && mode !== "api") throw new RangeError(`the mode of the recipe ${name} must be "page" or "api", not ${String(mode)}`);
3111
+ const accept = definition.accept ?? (mode === "api" ? API_ACCEPT : PAGE_ACCEPT);
3112
+ const minIntervalMs = definition.minInterval === void 0 ? void 0 : durationMs(definition.minInterval);
3113
+ const maxAgeOf = (value) => value === "auto" ? "auto" : durationMs(value);
3114
+ const maxAge = maxAgeOf(definition.maxAge ?? "24h");
3115
+ const maxBytes = definition.maxBytes;
3116
+ if (maxBytes !== void 0 && (!Number.isSafeInteger(maxBytes) || maxBytes < 1)) throw new RangeError(`maxBytes of the recipe ${name} must be a whole number of 1 or more`);
3117
+ const extract = definition.extract;
3118
+ const extractor = async (body, capture) => checkedDocs(name, await extract(body, {
3119
+ url: capture.url,
3120
+ contentType: capture.contentType
3121
+ }));
3122
+ const fetch = definition.fetch;
3123
+ return {
3124
+ name,
3125
+ description: definition.description ?? null,
3126
+ mode,
3127
+ accept,
3128
+ extractor,
3129
+ request(input, options = {}) {
3130
+ const asked = fetch === void 0 ? input : fetch(input);
3131
+ const target = typeof asked === "string" ? { url: asked } : asked;
3132
+ const url = target?.url;
3133
+ let parsed;
3134
+ try {
3135
+ parsed = typeof url === "string" ? new URL(url) : void 0;
3136
+ } catch {
3137
+ parsed = void 0;
3138
+ }
3139
+ if (parsed === void 0 || parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new TypeError(`the recipe ${name} needs an http or https URL for its input, not ${JSON.stringify(url) ?? String(url)}`);
3140
+ return {
3141
+ url: parsed.href,
3142
+ mode,
3143
+ recipe: name,
3144
+ accept: target?.accept ?? accept,
3145
+ headers: target?.headers,
3146
+ secretParams: target?.secretParams,
3147
+ minIntervalMs,
3148
+ maxAgeMs: options.maxAge === void 0 ? maxAge : maxAgeOf(options.maxAge),
3149
+ maxBytes,
3150
+ onBehalfOf: options.onBehalfOf,
3151
+ job: options.job,
3152
+ signal: options.signal,
3153
+ scope: options.scope,
3154
+ labels: options.labels,
3155
+ deadline: options.deadline
3156
+ };
3157
+ }
3158
+ };
3159
+ };
3160
+ /**
3161
+ * This function gives the extractor of a recipe to the extractors of a runtime. A second
3162
+ * registration of the same recipe does nothing. A different extractor with the same name throws
3163
+ * an error, because `reindex` must read each capture with one extractor.
3164
+ */
3165
+ const registerRecipe = (extractors, recipe) => {
3166
+ const known = extractors.get(recipe.name);
3167
+ if (known === recipe.extractor) return;
3168
+ if (known !== void 0) throw new Error(`the recipe ${recipe.name} has a different extractor in this runtime: give each recipe a name of its own`);
3169
+ extractors.register(recipe.name, recipe.extractor);
3170
+ };
3171
+ /** This function gives the runtime of a target. */
3172
+ const runtimeOf = (target) => "crawler" in target ? target : target.runtime;
3173
+ /** This function gives the deadline of a budget, on the clock of the runtime. */
3174
+ const deadlineOf = (runtime, budget) => budget === void 0 ? void 0 : Date.parse(runtime.now()) + durationMs(budget);
3175
+ /** This function gives one document of a run, with the rule for a held-back document. */
3176
+ const recipeDoc = async (runtime, doc, capture, userProvided, action) => {
3177
+ const current = runtime.index.get(doc.doc) ?? doc;
3178
+ const version = (capture === void 0 ? void 0 : runtime.index.versionText(doc.doc, capture)) ?? current;
3179
+ const aiInput = current.ai_input === "no" || version.ai_input === "no" ? "no" : version.ai_input;
3180
+ const held = isHeldBack({ ai_input: aiInput }, userProvided);
3181
+ if (aiInput === "no" && userProvided) await recordOverride(runtime, current, action);
3182
+ const archived = archivedOf(version.meta);
3183
+ return {
3184
+ doc: doc.doc,
3185
+ kind: version.kind,
3186
+ title: held ? "" : version.title,
3187
+ url: version.url,
3188
+ meta: held ? heldBackMeta(version.meta) : version.meta,
3189
+ chars: version.text.length,
3190
+ capture: version.capture,
3191
+ fetched_at: version.fetched_at,
3192
+ held_back: held,
3193
+ ...archived === void 0 ? {} : { archived }
3194
+ };
3195
+ };
3196
+ const noteOf = (parts) => {
3197
+ const kept = parts.filter((part) => typeof part === "string" && part.length > 0);
3198
+ return kept.length > 0 ? kept.join("; ") : null;
3199
+ };
3200
+ /**
3201
+ * This function starts a recipe for one input. It fetches the request through the crawler, and it
3202
+ * reads the capture with the extractor of the recipe. It gives the documents of that capture.
3203
+ * These are its rules:
3204
+ *
3205
+ * - The runtime registers the recipe the first time that it gets it. Give the recipe to
3206
+ * `openCrawler` too, so that `reindex` reads its captures before the first fetch.
3207
+ * - A stored copy younger than `maxAge` answers with no request. When the extractor cannot read a
3208
+ * stored copy, the crawler asks the source one more time.
3209
+ * - An extractor that throws gives no document, and `error` tells why. The fetch still succeeds.
3210
+ * - A document that asked not to be AI input shows no title and only its structural metadata,
3211
+ * unless `userProvided`. The crawler logs each `userProvided` read of a held-back document.
3212
+ * - With `fallback: "archive"`, a page that the site refuses gets an archived copy, with
3213
+ * `archived`. The page reader reads the copy. The extractor of the recipe reads only live
3214
+ * captures. Thus its documents do not mix a live answer and an old copy of a third party.
3215
+ */
3216
+ const runRecipe = async (target, recipe, input, options = {}) => {
3217
+ const runtime = runtimeOf(target);
3218
+ if (options.fallback === "archive" && recipe.mode !== "page") throw new RangeError(`the recipe ${recipe.name} reads an API: an archived copy is for a page, so give fallback only to a recipe of the mode page`);
3219
+ registerRecipe(runtime.extractors, recipe);
3220
+ const deadline = deadlineOf(runtime, options.budget);
3221
+ const request = recipe.request(input, {
3222
+ ...options,
3223
+ deadline
3224
+ });
3225
+ let indexed = await fetchAndIndex(runtime, request);
3226
+ if (indexed.error !== void 0 && indexed.blocked === void 0 && indexed.fetched.fromCache) indexed = await fetchAndIndex(runtime, {
3227
+ ...request,
3228
+ maxAgeMs: 0
3229
+ });
3230
+ const userProvided = options.userProvided ?? false;
3231
+ const action = options.job ?? `recipe ${recipe.name}`;
3232
+ const fetched = indexed.fetched;
3233
+ const notes = [fetched.note, indexed.error];
3234
+ if (indexed.docs.length === 0 && options.fallback === "archive") {
3235
+ const reason = fallbackReason(indexed);
3236
+ if (reason !== void 0) {
3237
+ const page = {
3238
+ render: "never",
3239
+ maxAgeMs: request.maxAgeMs,
3240
+ onBehalfOf: request.onBehalfOf,
3241
+ job: request.job,
3242
+ signal: request.signal,
3243
+ scope: request.scope,
3244
+ labels: request.labels,
3245
+ deadline,
3246
+ fallback: "archive",
3247
+ archiveDespiteRobots: options.archiveDespiteRobots
3248
+ };
3249
+ const answer = await archivedCopy(runtime, request.url, reason, page);
3250
+ if (answer.doc !== void 0 && answer.archived !== void 0) {
3251
+ const doc = await recipeDoc(runtime, answer.doc, answer.doc.capture, userProvided, action);
3252
+ return {
3253
+ recipe: recipe.name,
3254
+ url: fetched.url,
3255
+ outcome: fetched.outcome,
3256
+ status: fetched.status,
3257
+ docs: [doc],
3258
+ capture: doc.capture,
3259
+ fetched_at: doc.fetched_at,
3260
+ from_cache: answer.fromCache,
3261
+ held_back: doc.held_back,
3262
+ error: indexed.error ?? null,
3263
+ archived: answer.archived,
3264
+ note: noteOf([
3265
+ ...notes,
3266
+ ...answer.notes,
3267
+ "the page reader read the archived copy, not the extractor of the recipe",
3268
+ doc.held_back ? HELD_BACK : void 0
3269
+ ])
3270
+ };
3271
+ }
3272
+ notes.push(...answer.notes);
3273
+ }
3274
+ }
3275
+ const capture = fetched.capture;
3276
+ const docs = [];
3277
+ for (const doc of indexed.docs) docs.push(await recipeDoc(runtime, doc, capture, userProvided, action));
3278
+ const held = docs.some((doc) => doc.held_back);
3279
+ return {
3280
+ recipe: recipe.name,
3281
+ url: fetched.url,
3282
+ outcome: fetched.outcome,
3283
+ status: fetched.status,
3284
+ docs,
3285
+ capture: docs[0]?.capture ?? capture ?? null,
3286
+ fetched_at: docs[0]?.fetched_at ?? fetched.fetchedAt ?? null,
3287
+ from_cache: fetched.fromCache,
3288
+ held_back: held,
3289
+ error: indexed.error ?? null,
3290
+ note: noteOf([...notes, held ? HELD_BACK : void 0])
3291
+ };
3292
+ };
3293
+ //#endregion
3294
+ //#region src/vectors.ts
3295
+ /**
3296
+ * The vector store keeps the vectors of hybrid search in `vectors.sqlite`, beside the index. It is
3297
+ * a projection, as the index is. Delete the file, and the next hybrid search or `embed` makes the
3298
+ * vectors again from the stored texts.
3299
+ *
3300
+ * - A row is one chunk of one text for one embedder. Its key is the SHA-256 of the text, the
3301
+ * number of the chunk and the id of the embedder. Thus a reindex keeps each vector, and the same
3302
+ * text gets its vectors one time for each model, also when two documents hold it.
3303
+ * - A text is complete when the table `texts` has it, with the plan of its chunks. A text with a
3304
+ * different plan gets new chunks.
3305
+ * - The table `docs` points each document at the text that has its vectors. A search uses only
3306
+ * the texts that a document points at, and the caller checks each document against the index.
3307
+ * - A search compares the vector of the question with each stored vector (a brute-force cosine).
3308
+ * The store keeps the vectors of each embedder in memory after the first search. It reads them
3309
+ * again when a different process writes the file.
3310
+ *
3311
+ * Each connection waits for the lock of a different process (`busy_timeout`), and each write is
3312
+ * one transaction (`BEGIN IMMEDIATE`). The file has a schema version (`VECTORS_SCHEMA`). A file of
3313
+ * a different version opens empty.
3314
+ */
3315
+ /** This value is the version of the schema of the vector file. */
3316
+ const VECTORS_SCHEMA = 1;
3317
+ const DDL = `
3318
+ CREATE TABLE IF NOT EXISTS vectors_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
3319
+ CREATE TABLE IF NOT EXISTS embedders (
3320
+ id TEXT PRIMARY KEY,
3321
+ dimensions INTEGER NOT NULL,
3322
+ first_at TEXT NOT NULL
3323
+ );
3324
+ CREATE TABLE IF NOT EXISTS texts (
3325
+ sha TEXT NOT NULL,
3326
+ embedder TEXT NOT NULL,
3327
+ plan TEXT NOT NULL,
3328
+ chunks INTEGER NOT NULL,
3329
+ chars INTEGER NOT NULL,
3330
+ embedded_at TEXT NOT NULL,
3331
+ PRIMARY KEY (sha, embedder)
3332
+ );
3333
+ CREATE TABLE IF NOT EXISTS chunks (
3334
+ sha TEXT NOT NULL,
3335
+ chunk INTEGER NOT NULL,
3336
+ embedder TEXT NOT NULL,
3337
+ char_start INTEGER NOT NULL,
3338
+ char_end INTEGER NOT NULL,
3339
+ vector BLOB NOT NULL,
3340
+ PRIMARY KEY (sha, chunk, embedder)
3341
+ );
3342
+ CREATE INDEX IF NOT EXISTS chunks_embedder ON chunks (embedder, sha, chunk);
3343
+ CREATE TABLE IF NOT EXISTS docs (
3344
+ doc TEXT NOT NULL,
3345
+ embedder TEXT NOT NULL,
3346
+ sha TEXT NOT NULL,
3347
+ pointed_at TEXT NOT NULL,
3348
+ PRIMARY KEY (doc, embedder)
3349
+ );
3350
+ CREATE INDEX IF NOT EXISTS docs_sha ON docs (embedder, sha);
3351
+ `;
3352
+ /** This function gives the bytes of a vector for a BLOB. The bytes are in the order of the platform (little-endian on each common platform). */
3353
+ const bytesOf = (vector) => new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
3354
+ /** This function gives the vector of the bytes of a BLOB. It copies the bytes, so an odd offset of the BLOB is not a problem. */
3355
+ const vectorOf = (bytes) => {
3356
+ const out = new Float32Array(Math.floor(bytes.byteLength / 4));
3357
+ new Uint8Array(out.buffer).set(bytes.subarray(0, out.length * 4));
3358
+ return out;
3359
+ };
3360
+ const dot = (data, row, query, dims) => {
3361
+ let sum = 0;
3362
+ const base = row * dims;
3363
+ for (let at = 0; at < dims; at += 1) sum += data[base + at] * query[at];
3364
+ return sum;
3365
+ };
3366
+ var VectorStore = class {
3367
+ path;
3368
+ db;
3369
+ statements = /* @__PURE__ */ new Map();
3370
+ matrices = /* @__PURE__ */ new Map();
3371
+ closed = false;
3372
+ constructor(path, options = {}) {
3373
+ this.path = path;
3374
+ if (path !== ":memory:") mkdirSync(dirname(path), {
3375
+ recursive: true,
3376
+ mode: 448
3377
+ });
3378
+ this.db = new DatabaseSync(path);
3379
+ const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
3380
+ this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
3381
+ if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
3382
+ this.db.exec("PRAGMA synchronous = NORMAL;");
3383
+ this.write(() => {
3384
+ if (this.db.prepare("PRAGMA user_version").get().user_version !== 1) {
3385
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
3386
+ for (const { name } of tables) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
3387
+ }
3388
+ this.db.exec(DDL);
3389
+ this.db.exec(`PRAGMA user_version = ${String(1)}`);
3390
+ });
3391
+ }
3392
+ stmt(sql) {
3393
+ let statement = this.statements.get(sql);
3394
+ if (statement === void 0) {
3395
+ statement = this.db.prepare(sql);
3396
+ this.statements.set(sql, statement);
3397
+ }
3398
+ return statement;
3399
+ }
3400
+ write(work) {
3401
+ if (this.closed) throw new Error("the vector store is closed");
3402
+ this.db.exec("BEGIN IMMEDIATE");
3403
+ try {
3404
+ const result = work();
3405
+ this.db.exec("COMMIT");
3406
+ return result;
3407
+ } catch (error) {
3408
+ if (this.db.isTransaction) this.db.exec("ROLLBACK");
3409
+ throw error;
3410
+ }
3411
+ }
3412
+ live() {
3413
+ if (this.closed) throw new Error("the vector store is closed");
3414
+ }
3415
+ /** This method gives the plan of the chunks of a text when the store has each of its vectors, or undefined. */
3416
+ plan(embedder, sha) {
3417
+ this.live();
3418
+ return this.stmt("SELECT plan FROM texts WHERE sha = ? AND embedder = ?").get(sha, embedder)?.plan;
3419
+ }
3420
+ /** This method gives the text that a document points at, or undefined. */
3421
+ pointed(embedder, doc) {
3422
+ this.live();
3423
+ return this.stmt("SELECT sha FROM docs WHERE doc = ? AND embedder = ?").get(doc, embedder)?.sha;
3424
+ }
3425
+ /** This method gives the dimensions that the store keeps for an embedder, or undefined. */
3426
+ dimensions(embedder) {
3427
+ this.live();
3428
+ return this.stmt("SELECT dimensions FROM embedders WHERE id = ?").get(embedder)?.dimensions;
3429
+ }
3430
+ /**
3431
+ * This method writes the chunks of one text and points its documents at it, in one transaction.
3432
+ * A vector with a length that is different from the dimensions of the embedder throws an error.
3433
+ */
3434
+ put(input) {
3435
+ const dims = input.dimensions;
3436
+ for (const chunk of input.chunks) if (chunk.vector.length !== dims) throw new Error(`the embedder ${input.embedder} gave a vector of ${String(chunk.vector.length)} dimensions, not ${String(dims)}`);
3437
+ let replaced = false;
3438
+ this.write(() => {
3439
+ const known = this.stmt("SELECT dimensions FROM embedders WHERE id = ?").get(input.embedder);
3440
+ if (known === void 0 && input.chunks.length > 0) this.stmt("INSERT INTO embedders (id, dimensions, first_at) VALUES (?, ?, ?)").run(input.embedder, dims, input.at);
3441
+ else if (known !== void 0 && input.chunks.length > 0 && known.dimensions !== dims) throw new Error(`vectors.sqlite holds vectors of ${String(known.dimensions)} dimensions for the embedder ${input.embedder}, and it gave ${String(dims)}: give the embedder a new id, or delete vectors.sqlite`);
3442
+ const before = this.stmt("SELECT plan FROM texts WHERE sha = ? AND embedder = ?").get(input.sha, input.embedder);
3443
+ replaced = before !== void 0 && before.plan !== input.plan;
3444
+ this.stmt("DELETE FROM chunks WHERE sha = ? AND embedder = ?").run(input.sha, input.embedder);
3445
+ const insert = this.stmt("INSERT INTO chunks (sha, chunk, embedder, char_start, char_end, vector) VALUES (?, ?, ?, ?, ?, ?)");
3446
+ input.chunks.forEach((chunk, at) => {
3447
+ insert.run(input.sha, at, input.embedder, chunk.start, chunk.end, bytesOf(chunk.vector));
3448
+ });
3449
+ this.stmt(`INSERT INTO texts (sha, embedder, plan, chunks, chars, embedded_at) VALUES (?, ?, ?, ?, ?, ?)
3450
+ ON CONFLICT(sha, embedder) DO UPDATE SET plan = excluded.plan, chunks = excluded.chunks,
3451
+ chars = excluded.chars, embedded_at = excluded.embedded_at`).run(input.sha, input.embedder, input.plan, input.chunks.length, input.chars, input.at);
3452
+ for (const doc of input.docs) this.pointRow(input.embedder, doc, input.sha, input.at);
3453
+ });
3454
+ const matrix = this.matrices.get(input.embedder);
3455
+ if (matrix === void 0) return;
3456
+ if (replaced || matrix.texts.has(input.sha)) {
3457
+ this.matrices.delete(input.embedder);
3458
+ return;
3459
+ }
3460
+ this.append(matrix, input.sha, input.chunks.map((chunk, at) => ({
3461
+ ...chunk,
3462
+ chunk: at
3463
+ })));
3464
+ for (const doc of input.docs) this.pointMatrix(matrix, doc, input.sha);
3465
+ }
3466
+ pointRow(embedder, doc, sha, at) {
3467
+ this.stmt(`INSERT INTO docs (doc, embedder, sha, pointed_at) VALUES (?, ?, ?, ?)
3468
+ ON CONFLICT(doc, embedder) DO UPDATE SET sha = excluded.sha, pointed_at = excluded.pointed_at`).run(doc, embedder, sha, at);
3469
+ }
3470
+ /** This method points a document at a text that the store has. */
3471
+ point(embedder, doc, sha, at) {
3472
+ this.write(() => this.pointRow(embedder, doc, sha, at));
3473
+ const matrix = this.matrices.get(embedder);
3474
+ if (matrix === void 0) return;
3475
+ if (!matrix.texts.has(sha)) this.append(matrix, sha, this.chunks(embedder, sha));
3476
+ this.pointMatrix(matrix, doc, sha);
3477
+ }
3478
+ /** This method removes the pointer of a document, for example when the document is held back. Its text keeps its vectors. */
3479
+ forget(embedder, doc) {
3480
+ this.write(() => this.stmt("DELETE FROM docs WHERE doc = ? AND embedder = ?").run(doc, embedder));
3481
+ const matrix = this.matrices.get(embedder);
3482
+ if (matrix === void 0) return;
3483
+ const sha = matrix.shaOf.get(doc);
3484
+ if (sha !== void 0) matrix.docsOf.get(sha)?.delete(doc);
3485
+ matrix.shaOf.delete(doc);
3486
+ }
3487
+ /** This method gives the chunks of a text, in their sequence. */
3488
+ chunks(embedder, sha) {
3489
+ this.live();
3490
+ return this.stmt("SELECT chunk, char_start, char_end, vector FROM chunks WHERE sha = ? AND embedder = ? ORDER BY chunk").all(sha, embedder).map((row) => ({
3491
+ chunk: row.chunk,
3492
+ start: row.char_start,
3493
+ end: row.char_end,
3494
+ vector: vectorOf(row.vector)
3495
+ }));
3496
+ }
3497
+ pointMatrix(matrix, doc, sha) {
3498
+ const before = matrix.shaOf.get(doc);
3499
+ if (before !== void 0 && before !== sha) matrix.docsOf.get(before)?.delete(doc);
3500
+ matrix.shaOf.set(doc, sha);
3501
+ let docs = matrix.docsOf.get(sha);
3502
+ if (docs === void 0) {
3503
+ docs = /* @__PURE__ */ new Set();
3504
+ matrix.docsOf.set(sha, docs);
3505
+ }
3506
+ docs.add(doc);
3507
+ }
3508
+ append(matrix, sha, chunks) {
3509
+ const dims = matrix.dimensions;
3510
+ const need = matrix.rows + chunks.length;
3511
+ if (need > matrix.chunk.length) {
3512
+ const size = Math.max(need, matrix.chunk.length * 2, 64);
3513
+ const data = new Float32Array(size * dims);
3514
+ data.set(matrix.data.subarray(0, matrix.rows * dims));
3515
+ const grow = (old) => {
3516
+ const next = new Int32Array(size);
3517
+ next.set(old.subarray(0, matrix.rows));
3518
+ return next;
3519
+ };
3520
+ matrix.data = data;
3521
+ matrix.chunk = grow(matrix.chunk);
3522
+ matrix.start = grow(matrix.start);
3523
+ matrix.end = grow(matrix.end);
3524
+ }
3525
+ const from = matrix.rows;
3526
+ for (const chunk of chunks) {
3527
+ const row = matrix.rows;
3528
+ if (chunk.vector.length === dims) matrix.data.set(chunk.vector, row * dims);
3529
+ matrix.chunk[row] = chunk.chunk;
3530
+ matrix.start[row] = chunk.start;
3531
+ matrix.end[row] = chunk.end;
3532
+ matrix.rows += 1;
3533
+ }
3534
+ matrix.texts.set(sha, {
3535
+ from,
3536
+ to: matrix.rows
3537
+ });
3538
+ }
3539
+ /** This method gives the vectors of an embedder in memory. It reads them again when a different process wrote the file. */
3540
+ matrix(embedder) {
3541
+ this.live();
3542
+ const version = this.db.prepare("PRAGMA data_version").get().data_version;
3543
+ const cached = this.matrices.get(embedder);
3544
+ if (cached !== void 0 && cached.version === version) return cached;
3545
+ const dims = this.dimensions(embedder);
3546
+ if (dims === void 0) return void 0;
3547
+ this.db.exec("BEGIN");
3548
+ try {
3549
+ const matrix = this.load(embedder, dims, version);
3550
+ this.matrices.set(embedder, matrix);
3551
+ return matrix;
3552
+ } finally {
3553
+ if (this.db.isTransaction) this.db.exec("COMMIT");
3554
+ }
3555
+ }
3556
+ load(embedder, dims, version) {
3557
+ const count = this.db.prepare("SELECT count(*) AS n FROM chunks WHERE embedder = ? AND sha IN (SELECT sha FROM docs WHERE embedder = ?)").get(embedder, embedder).n;
3558
+ const matrix = {
3559
+ dimensions: dims,
3560
+ rows: 0,
3561
+ data: new Float32Array(Math.max(1, count) * dims),
3562
+ chunk: new Int32Array(Math.max(1, count)),
3563
+ start: new Int32Array(Math.max(1, count)),
3564
+ end: new Int32Array(Math.max(1, count)),
3565
+ texts: /* @__PURE__ */ new Map(),
3566
+ docsOf: /* @__PURE__ */ new Map(),
3567
+ shaOf: /* @__PURE__ */ new Map(),
3568
+ version
3569
+ };
3570
+ const rows = this.db.prepare(`SELECT sha, chunk, char_start, char_end, vector FROM chunks
3571
+ WHERE embedder = ? AND sha IN (SELECT sha FROM docs WHERE embedder = ?)
3572
+ ORDER BY sha, chunk`).iterate(embedder, embedder);
3573
+ let sha;
3574
+ let from = 0;
3575
+ for (const row of rows) {
3576
+ if (row.sha !== sha) {
3577
+ if (sha !== void 0) matrix.texts.set(sha, {
3578
+ from,
3579
+ to: matrix.rows
3580
+ });
3581
+ sha = row.sha;
3582
+ from = matrix.rows;
3583
+ }
3584
+ if (matrix.rows >= matrix.chunk.length) break;
3585
+ const at = matrix.rows;
3586
+ if (row.vector.byteLength === dims * 4) new Uint8Array(matrix.data.buffer, at * dims * 4, dims * 4).set(row.vector);
3587
+ matrix.chunk[at] = row.chunk;
3588
+ matrix.start[at] = row.char_start;
3589
+ matrix.end[at] = row.char_end;
3590
+ matrix.rows += 1;
3591
+ }
3592
+ if (sha !== void 0) matrix.texts.set(sha, {
3593
+ from,
3594
+ to: matrix.rows
3595
+ });
3596
+ for (const row of this.db.prepare("SELECT doc, sha FROM docs WHERE embedder = ?").all(embedder)) this.pointMatrix(matrix, row.doc, row.sha);
3597
+ return matrix;
3598
+ }
3599
+ /** This method gives the best chunk of one text for a question, or undefined when the store has no vector of the text. */
3600
+ best(embedder, sha, query) {
3601
+ const matrix = this.matrix(embedder);
3602
+ const range = matrix?.texts.get(sha);
3603
+ if (matrix === void 0 || range === void 0 || range.to === range.from) return void 0;
3604
+ return this.hitOf(matrix, sha, range, query);
3605
+ }
3606
+ hitOf(matrix, sha, range, query) {
3607
+ let bestRow = range.from;
3608
+ let bestScore = Number.NEGATIVE_INFINITY;
3609
+ for (let row = range.from; row < range.to; row += 1) {
3610
+ const score = dot(matrix.data, row, query, matrix.dimensions);
3611
+ if (score > bestScore) {
3612
+ bestScore = score;
3613
+ bestRow = row;
3614
+ }
3615
+ }
3616
+ return {
3617
+ sha,
3618
+ chunk: matrix.chunk[bestRow],
3619
+ start: matrix.start[bestRow],
3620
+ end: matrix.end[bestRow],
3621
+ similarity: bestScore,
3622
+ docs: [...matrix.docsOf.get(sha) ?? []]
3623
+ };
3624
+ }
3625
+ /**
3626
+ * This method compares a question with each stored vector of an embedder (a brute-force cosine).
3627
+ * It gives the best chunk of each text that a document points at, the best first. With `within`,
3628
+ * it compares only the texts of those documents.
3629
+ */
3630
+ search(embedder, query, options = {}) {
3631
+ const matrix = this.matrix(embedder);
3632
+ if (matrix === void 0) return [];
3633
+ if (query.length !== matrix.dimensions) throw new Error(`the question has a vector of ${String(query.length)} dimensions, and the store holds ${String(matrix.dimensions)} for the embedder ${embedder}`);
3634
+ const shas = /* @__PURE__ */ new Set();
3635
+ if (options.within === void 0) {
3636
+ for (const [sha, docs] of matrix.docsOf) if (docs.size > 0) shas.add(sha);
3637
+ } else for (const doc of options.within) {
3638
+ const sha = matrix.shaOf.get(doc);
3639
+ if (sha !== void 0) shas.add(sha);
3640
+ }
3641
+ const hits = [];
3642
+ for (const sha of shas) {
3643
+ const range = matrix.texts.get(sha);
3644
+ if (range === void 0 || range.to === range.from) continue;
3645
+ hits.push(this.hitOf(matrix, sha, range, query));
3646
+ }
3647
+ return hits.sort((a, b) => b.similarity - a.similarity || (a.sha < b.sha ? -1 : 1));
3648
+ }
3649
+ /** This method gives the counts of the store, for one embedder or for each embedder. */
3650
+ counts(embedder) {
3651
+ this.live();
3652
+ const where = embedder === void 0 ? "" : " WHERE embedder = ?";
3653
+ const params = embedder === void 0 ? [] : [embedder];
3654
+ const one = (sql) => this.stmt(sql).get(...params).n ?? 0;
3655
+ return {
3656
+ texts: one(`SELECT count(*) AS n FROM texts${where}`),
3657
+ chunks: one(`SELECT count(*) AS n FROM chunks${where}`),
3658
+ docs: one(`SELECT count(*) AS n FROM docs${where}`),
3659
+ bytes: one(`SELECT sum(length(vector)) AS n FROM chunks${where}`)
3660
+ };
3661
+ }
3662
+ /** This method closes the file. A second close does nothing. */
3663
+ close() {
3664
+ if (this.closed) return;
3665
+ this.closed = true;
3666
+ this.statements.clear();
3667
+ this.matrices.clear();
3668
+ this.db.close();
3669
+ }
3670
+ };
3671
+ //#endregion
3672
+ //#region src/watch.ts
3673
+ /**
3674
+ * A watch list keeps the pages that the crawler checks again on a schedule. `watch.run` fetches the
3675
+ * pages that are due through the normal fetch, so each polite rule applies. It gives an event for
3676
+ * each page: new, changed, unchanged, gone, refused or error.
3677
+ *
3678
+ * These are the rules of a watch:
3679
+ *
3680
+ * - A watch compares the title, the text and the JSON-LD of the page with the last confirmed version.
3681
+ * - With `confirm: 2` (the default), a change is suspected when a check first sees it. The next
3682
+ * check confirms it when it sees the same new version. A check that sees the earlier version
3683
+ * again clears the suspicion, because one read can be a glitch. With `confirm: 1`, each check confirms.
3684
+ * - `every` has a floor of 1 hour by default. A check that a stop of the host refused is due again
3685
+ * at the end of the stop, or later.
3686
+ * - The list is in `watch.sqlite` in the store, so it stays after a restart. A run claims each entry
3687
+ * with a lease in that file, so two processes do not check one entry two times.
3688
+ *
3689
+ * A scheduler of the operator starts `watch.run`, or the daemon (`startDaemon`) starts it on a schedule.
3690
+ */
3691
+ /** This value is the version of the schema of `watch.sqlite`. */
3692
+ const WATCH_SCHEMA = 1;
3693
+ /** These are the defaults and the limits of the watch list. */
3694
+ const WATCH_LIMITS = {
3695
+ /** The shortest `every` of a watch by default: 1 hour, so a watch cannot hammer a site. */
3696
+ floorMs: 36e5,
3697
+ /** The default `every`: 24 hours. */
3698
+ everyMs: 864e5,
3699
+ /** The default time of one run: 10 minutes. No request starts after it. */
3700
+ budgetMs: 6e5,
3701
+ /** The default number of entries that one run checks. */
3702
+ maxPages: 100,
3703
+ /** The most hosts that get a check of one run at one time. */
3704
+ hostsAtOnce: 8,
3705
+ /** A run holds the lease of an entry until the end of its budget and this time more: 2 minutes. */
3706
+ leaseMs: 12e4
3707
+ };
3708
+ const iso = (ms) => new Date(ms).toISOString();
3709
+ const parseJson = (text, fallback) => {
3710
+ if (text === null) return fallback;
3711
+ try {
3712
+ return JSON.parse(text);
3713
+ } catch {
3714
+ return fallback;
3715
+ }
3716
+ };
3717
+ const RENDERS = /* @__PURE__ */ new Set([
3718
+ "auto",
3719
+ "never",
3720
+ "always"
3721
+ ]);
3722
+ const entryOf = (row, now) => ({
3723
+ url: row.target,
3724
+ every_ms: row.every_ms,
3725
+ render: row.render,
3726
+ confirm: row.confirm === 1 ? 1 : 2,
3727
+ scope: row.scope,
3728
+ labels: parseJson(row.labels, null),
3729
+ on_behalf_of: parseJson(row.on_behalf_of, []),
3730
+ added_at: iso(row.added_at),
3731
+ next_at: iso(row.next_at),
3732
+ checked_at: row.last_at === null ? null : iso(row.last_at),
3733
+ last_kind: row.last_kind,
3734
+ last_note: row.last_note,
3735
+ checks: row.checks,
3736
+ changes: row.changes,
3737
+ doc: row.doc,
3738
+ capture: row.base_capture,
3739
+ fetched_at: row.base_fetched_at,
3740
+ gone: row.base_state === "gone",
3741
+ pending: row.pend_state === null ? null : {
3742
+ kind: row.pend_state === "gone" ? "gone" : row.base_state === "gone" ? "new" : "changed",
3743
+ capture: row.pend_capture,
3744
+ since: iso(row.pend_since ?? row.last_at ?? row.added_at)
3745
+ },
3746
+ leased_until: row.lease_until > now ? iso(row.lease_until) : null
3747
+ });
3748
+ /** This function gives the canonical key, the URL and the host of a page to watch. A URL that is not http(s) throws a `RangeError`. */
3749
+ const targetOf = (url) => {
3750
+ let parsed;
3751
+ try {
3752
+ parsed = new URL(url);
3753
+ } catch {
3754
+ throw new RangeError(`not a valid URL: ${url}`);
3755
+ }
3756
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new RangeError(`a watch needs an http or https URL, not ${url}`);
3757
+ parsed.hash = "";
3758
+ return {
3759
+ key: cacheKeyOf(parsed.href),
3760
+ target: parsed.href,
3761
+ host: parsed.host.toLowerCase()
3762
+ };
3763
+ };
3764
+ /**
3765
+ * This store keeps the watch list in a SQLite file (`watch.sqlite`). Two or more processes on one
3766
+ * store share it. Each step is one transaction (`BEGIN IMMEDIATE`), and it waits for the lock of a
3767
+ * different process (`busy_timeout`).
3768
+ *
3769
+ * The list is the decision of the operator, not a projection of the capture log. Thus it has its
3770
+ * own file: a deletion of `hosts.sqlite` or of the index does not lose it.
3771
+ */
3772
+ var WatchStore = class {
3773
+ path;
3774
+ db;
3775
+ statements = /* @__PURE__ */ new Map();
3776
+ closed = false;
3777
+ /** This value is the shortest `every` in milliseconds. */
3778
+ floorMs;
3779
+ constructor(path, options = {}) {
3780
+ this.path = path;
3781
+ if (path !== ":memory:") mkdirSync(dirname(path), {
3782
+ recursive: true,
3783
+ mode: 448
3784
+ });
3785
+ const floor = options.floorMs ?? WATCH_LIMITS.floorMs;
3786
+ if (!Number.isFinite(floor) || floor < 0) throw new RangeError(`the floor of every must be 0 or more milliseconds, not ${String(floor)}`);
3787
+ this.floorMs = floor;
3788
+ this.db = new DatabaseSync(path);
3789
+ const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
3790
+ this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
3791
+ if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
3792
+ this.db.exec("PRAGMA synchronous = NORMAL;");
3793
+ this.step(() => {
3794
+ this.db.exec(`CREATE TABLE IF NOT EXISTS watches (
3795
+ url TEXT PRIMARY KEY,
3796
+ target TEXT NOT NULL,
3797
+ host TEXT NOT NULL,
3798
+ every_ms REAL NOT NULL,
3799
+ render TEXT NOT NULL,
3800
+ confirm INTEGER NOT NULL,
3801
+ scope TEXT,
3802
+ labels TEXT,
3803
+ on_behalf_of TEXT,
3804
+ added_at REAL NOT NULL,
3805
+ next_at REAL NOT NULL,
3806
+ lease_until REAL NOT NULL DEFAULT 0,
3807
+ lease_owner TEXT,
3808
+ checks INTEGER NOT NULL DEFAULT 0,
3809
+ changes INTEGER NOT NULL DEFAULT 0,
3810
+ last_at REAL,
3811
+ last_kind TEXT,
3812
+ last_note TEXT,
3813
+ doc TEXT,
3814
+ base_state TEXT,
3815
+ base_sha TEXT,
3816
+ base_capture TEXT,
3817
+ base_fetched_at TEXT,
3818
+ pend_state TEXT,
3819
+ pend_sha TEXT,
3820
+ pend_capture TEXT,
3821
+ pend_fetched_at TEXT,
3822
+ pend_since REAL
3823
+ )`);
3824
+ this.db.exec("CREATE INDEX IF NOT EXISTS watches_due ON watches (next_at)");
3825
+ const { user_version: version } = this.db.prepare("PRAGMA user_version").get();
3826
+ if (version < 1) this.db.exec(`PRAGMA user_version = ${String(1)}`);
3827
+ });
3828
+ }
3829
+ stmt(sql) {
3830
+ let statement = this.statements.get(sql);
3831
+ if (statement === void 0) {
3832
+ statement = this.db.prepare(sql);
3833
+ this.statements.set(sql, statement);
3834
+ }
3835
+ return statement;
3836
+ }
3837
+ /** This method does one step in one transaction. */
3838
+ step(work) {
3839
+ if (this.closed) throw new Error("the watch store is closed");
3840
+ this.db.exec("BEGIN IMMEDIATE");
3841
+ try {
3842
+ const result = work();
3843
+ this.db.exec("COMMIT");
3844
+ return result;
3845
+ } catch (error) {
3846
+ if (this.db.isTransaction) this.db.exec("ROLLBACK");
3847
+ throw error;
3848
+ }
3849
+ }
3850
+ row(key) {
3851
+ return this.stmt("SELECT * FROM watches WHERE url = ?").get(key);
3852
+ }
3853
+ /**
3854
+ * This method adds a page, or changes the options of its watch and keeps its state. A new entry
3855
+ * is due at once. A changed entry is due one `every` after its last check. A bad option throws
3856
+ * a `RangeError`.
3857
+ */
3858
+ add(url, options, now) {
3859
+ const { key, target, host } = targetOf(url);
3860
+ const every = options.every === void 0 ? WATCH_LIMITS.everyMs : durationMs(options.every);
3861
+ if (every < this.floorMs) throw new RangeError(`every must be ${String(this.floorMs / 6e4)} minutes or more, so a watch cannot hammer a site, not ${String(every / 6e4)} minutes`);
3862
+ const render = options.render ?? "auto";
3863
+ if (!RENDERS.has(render)) throw new RangeError(`render is auto, never or always, not ${JSON.stringify(render)}`);
3864
+ const confirm = options.confirm ?? 2;
3865
+ if (confirm !== 1 && confirm !== 2) throw new RangeError(`confirm is 1 or 2, not ${JSON.stringify(confirm)}`);
3866
+ const labels = options.labels === void 0 || Object.keys(options.labels).length === 0 ? null : JSON.stringify(options.labels);
3867
+ const agents = options.onBehalfOf === void 0 || options.onBehalfOf.length === 0 ? null : JSON.stringify([...options.onBehalfOf]);
3868
+ return this.step(() => {
3869
+ this.stmt(`INSERT INTO watches (url, target, host, every_ms, render, confirm, scope, labels, on_behalf_of, added_at, next_at)
3870
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3871
+ ON CONFLICT(url) DO UPDATE SET target = excluded.target, host = excluded.host,
3872
+ every_ms = excluded.every_ms, render = excluded.render, confirm = excluded.confirm,
3873
+ scope = excluded.scope, labels = excluded.labels, on_behalf_of = excluded.on_behalf_of,
3874
+ next_at = CASE WHEN watches.last_at IS NULL THEN watches.next_at
3875
+ ELSE watches.last_at + excluded.every_ms END`).run(key, target, host, every, render, confirm, options.scope ?? null, labels, agents, now, now);
3876
+ return entryOf(this.row(key), now);
3877
+ });
3878
+ }
3879
+ /** This method removes a page. It gives false when the list does not have the page. */
3880
+ remove(url) {
3881
+ const { key } = targetOf(url);
3882
+ return this.step(() => Number(this.stmt("DELETE FROM watches WHERE url = ?").run(key).changes) > 0);
3883
+ }
3884
+ /** This method gives the entry of a page, or undefined. */
3885
+ get(url, now) {
3886
+ if (this.closed) throw new Error("the watch store is closed");
3887
+ const row = this.row(targetOf(url).key);
3888
+ return row === void 0 ? void 0 : entryOf(row, now);
3889
+ }
3890
+ /** This method gives the entries, the first due first. */
3891
+ list(now) {
3892
+ if (this.closed) throw new Error("the watch store is closed");
3893
+ return this.stmt("SELECT * FROM watches ORDER BY next_at, url").all().map((row) => entryOf(row, now));
3894
+ }
3895
+ /** This method counts the entries that are due at a time. */
3896
+ due(now) {
3897
+ if (this.closed) throw new Error("the watch store is closed");
3898
+ return this.stmt("SELECT count(*) AS n FROM watches WHERE next_at <= ?").get(now).n;
3899
+ }
3900
+ /**
3901
+ * This method claims the first due entry with no lease, on a host that is not in `busy`. The
3902
+ * lease of `owner` holds until `until`. Thus two processes do not get one entry at one time.
3903
+ */
3904
+ claim(now, owner, until, busy) {
3905
+ return this.step(() => {
3906
+ const row = this.stmt(`SELECT * FROM watches WHERE next_at <= ? AND lease_until <= ?
3907
+ AND host NOT IN (SELECT value FROM json_each(?)) ORDER BY next_at, url LIMIT 1`).get(now, now, JSON.stringify(busy));
3908
+ if (row === void 0) return void 0;
3909
+ this.stmt("UPDATE watches SET lease_until = ?, lease_owner = ? WHERE url = ?").run(until, owner, row.url);
3910
+ return {
3911
+ ...row,
3912
+ lease_until: until,
3913
+ lease_owner: owner
3914
+ };
3915
+ });
3916
+ }
3917
+ /** This method ends the lease of `owner` and changes nothing else, so the entry stays due. A closed store does nothing. */
3918
+ release(key, owner) {
3919
+ if (this.closed) return;
3920
+ this.step(() => {
3921
+ this.stmt("UPDATE watches SET lease_until = 0, lease_owner = NULL WHERE url = ? AND lease_owner = ?").run(key, owner);
3922
+ });
3923
+ }
3924
+ /**
3925
+ * This method writes the result of a check and ends the lease. It gives false when `owner` does
3926
+ * not hold the lease, or when the store is closed.
3927
+ */
3928
+ settle(key, owner, update) {
3929
+ if (this.closed) return false;
3930
+ return this.step(() => {
3931
+ const row = this.row(key);
3932
+ if (row === void 0 || row.lease_owner !== owner) return false;
3933
+ const base = update.base;
3934
+ const pending = update.pending;
3935
+ this.stmt(`UPDATE watches SET lease_until = 0, lease_owner = NULL, next_at = ?, last_at = ?, last_kind = ?,
3936
+ last_note = ?, checks = checks + 1, changes = changes + ?, doc = ?,
3937
+ base_state = ?, base_sha = ?, base_capture = ?, base_fetched_at = ?,
3938
+ pend_state = ?, pend_sha = ?, pend_capture = ?, pend_fetched_at = ?, pend_since = ?
3939
+ WHERE url = ?`).run(update.next_at, update.last_at, update.last_kind, update.last_note, update.changed ? 1 : 0, update.doc ?? row.doc, base === void 0 ? row.base_state : base.state, base === void 0 ? row.base_sha : base.sha, base === void 0 ? row.base_capture : base.capture, base === void 0 ? row.base_fetched_at : base.fetched_at, pending === void 0 ? row.pend_state : pending?.state ?? null, pending === void 0 ? row.pend_sha : pending?.sha ?? null, pending === void 0 ? row.pend_capture : pending?.capture ?? null, pending === void 0 ? row.pend_fetched_at : pending?.fetched_at ?? null, pending === void 0 ? row.pend_since : pending?.since ?? null, key);
3940
+ return true;
3941
+ });
3942
+ }
3943
+ /** This method closes the file. A second close does nothing. */
3944
+ close() {
3945
+ if (this.closed) return;
3946
+ this.closed = true;
3947
+ this.statements.clear();
3948
+ this.db.close();
3949
+ }
3950
+ };
3951
+ /** The outcomes of a check that the site or the crawler refused. */
3952
+ const REFUSALS = /* @__PURE__ */ new Set([
3953
+ "skipped",
3954
+ "blocked",
3955
+ "challenge",
3956
+ "payment-required"
3957
+ ]);
3958
+ /** This function gives the fingerprint of a version: a hash of its title, its text and its JSON-LD. */
3959
+ const watchFingerprint = (version) => sha256$1(stableStringify([
3960
+ version.title,
3961
+ version.text,
3962
+ version.meta["jsonld"] ?? null
3963
+ ]));
3964
+ const sameState = (a, b) => a !== void 0 && a.state === b.state && (a.state === "gone" || a.sha === b.sha);
3965
+ /**
3966
+ * This function checks the entries of a watch store that are due. `PoliteCrawler.watch.run` uses
3967
+ * it. Each check is a fetch of the page with `fetchPage`, with no stored copy (`maxAgeMs: 0`), so
3968
+ * each polite rule applies. A check that the budget stops before its request stays due. The store
3969
+ * can be a function that opens it: the run uses it before the first check.
3970
+ */
3971
+ const runWatch = (runtime, source, options = {}) => {
3972
+ let resolveSummary = () => void 0;
3973
+ const summary = new Promise((resolve) => {
3974
+ resolveSummary = resolve;
3975
+ });
3976
+ let used = false;
3977
+ async function* events() {
3978
+ const now = () => Date.parse(runtime.now());
3979
+ const startedAt = now();
3980
+ const owner = randomUUID();
3981
+ const abort = new AbortController();
3982
+ const outer = options.signal;
3983
+ const cancel = () => abort.abort(outer?.reason);
3984
+ if (outer?.aborted === true) cancel();
3985
+ outer?.addEventListener("abort", cancel, { once: true });
3986
+ const inflight = /* @__PURE__ */ new Map();
3987
+ const counts = {
3988
+ checked: 0,
3989
+ new: 0,
3990
+ changed: 0,
3991
+ unchanged: 0,
3992
+ gone: 0,
3993
+ refused: 0,
3994
+ error: 0,
3995
+ suspected: 0,
3996
+ confirmed: 0
3997
+ };
3998
+ let started = 0;
3999
+ let stopping;
4000
+ let reason;
4001
+ let finished = false;
4002
+ let budgetMs = WATCH_LIMITS.budgetMs;
4003
+ let maxPages = WATCH_LIMITS.maxPages;
4004
+ let opened;
4005
+ /** This function fetches one entry, compares it with its state, and writes the new state. */
4006
+ const check = async (store, row, deadline) => {
4007
+ const agents = [...options.onBehalfOf ?? [], ...parseJson(row.on_behalf_of, [])];
4008
+ const labels = parseJson(row.labels, void 0);
4009
+ let page;
4010
+ let failure;
4011
+ try {
4012
+ page = await fetchPage(runtime, row.target, {
4013
+ render: row.render,
4014
+ maxAgeMs: 0,
4015
+ onBehalfOf: agents,
4016
+ signal: abort.signal,
4017
+ scope: row.scope ?? void 0,
4018
+ labels,
4019
+ deadline
4020
+ });
4021
+ } catch (error) {
4022
+ failure = `watch error: ${error instanceof Error ? error.message : String(error)}`;
4023
+ }
4024
+ const deferred = (stop) => {
4025
+ store.release(row.url, owner);
4026
+ return {
4027
+ kind: "deferred",
4028
+ host: row.host,
4029
+ stop
4030
+ };
4031
+ };
4032
+ if (abort.signal.aborted) return deferred("cancelled");
4033
+ if (page !== void 0 && pastDeadline(page.note)) return deferred("budget");
4034
+ if (page?.outcome === "skipped" && page.note === "crawler closed") return deferred("closed");
4035
+ const at = now();
4036
+ const base = row.base_state === null ? void 0 : {
4037
+ state: row.base_state,
4038
+ sha: row.base_sha,
4039
+ capture: row.base_capture,
4040
+ fetched_at: row.base_fetched_at
4041
+ };
4042
+ const pending = row.pend_state === null ? void 0 : {
4043
+ state: row.pend_state,
4044
+ sha: row.pend_sha,
4045
+ capture: row.pend_capture,
4046
+ fetched_at: row.pend_fetched_at
4047
+ };
4048
+ let seen;
4049
+ let version;
4050
+ let held = false;
4051
+ let kind;
4052
+ let note = page?.note ?? failure ?? null;
4053
+ const ok = page !== void 0 && (page.outcome === "ok" || page.outcome === "not-modified");
4054
+ if (page !== void 0 && ok && page.doc !== null && page.capture !== null) {
4055
+ version = runtime.index.versionText(page.doc, page.capture);
4056
+ const doc = runtime.index.get(page.doc);
4057
+ held = doc !== void 0 && isHeldBack(doc) || version !== void 0 && isHeldBack(version);
4058
+ if (version !== void 0) seen = {
4059
+ state: "present",
4060
+ sha: watchFingerprint(version),
4061
+ capture: version.capture,
4062
+ fetched_at: version.fetched_at
4063
+ };
4064
+ } else if (page?.outcome === "not-found") seen = {
4065
+ state: "gone",
4066
+ sha: null,
4067
+ capture: null,
4068
+ fetched_at: page.fetched_at
4069
+ };
4070
+ let update;
4071
+ let suspected = false;
4072
+ let confirmed = false;
4073
+ if (seen === void 0) {
4074
+ kind = page !== void 0 && REFUSALS.has(page.outcome) ? "refused" : "error";
4075
+ if (ok) note = note ?? "the page gave no document, so the watch cannot compare it";
4076
+ update = { changed: false };
4077
+ } else if (base === void 0) {
4078
+ kind = seen.state === "gone" ? "gone" : "new";
4079
+ update = {
4080
+ changed: false,
4081
+ base: seen,
4082
+ pending: null
4083
+ };
4084
+ } else if (sameState(base, seen)) {
4085
+ kind = "unchanged";
4086
+ if (pending !== void 0) note = "the change that the last check saw did not repeat, so the watch takes it for a glitch";
4087
+ update = {
4088
+ changed: false,
4089
+ pending: null
4090
+ };
4091
+ } else {
4092
+ kind = seen.state === "gone" ? "gone" : base.state === "gone" ? "new" : "changed";
4093
+ const repeated = sameState(pending, seen) && (seen.state === "gone" || pending?.capture !== seen.capture);
4094
+ if (row.confirm === 1 || repeated) {
4095
+ confirmed = true;
4096
+ update = {
4097
+ changed: true,
4098
+ base: seen,
4099
+ pending: null
4100
+ };
4101
+ } else {
4102
+ suspected = true;
4103
+ note = pending === void 0 ? "the watch saw this change one time: the next check confirms it when it sees the same version" : "the watch saw a different change than the last check: the next check confirms it when it sees the same version";
4104
+ update = {
4105
+ changed: false,
4106
+ pending: {
4107
+ ...seen,
4108
+ since: at
4109
+ }
4110
+ };
4111
+ }
4112
+ }
4113
+ let diff = null;
4114
+ if (kind === "changed" && base?.state === "present" && base.capture !== null && version !== void 0 && !held) {
4115
+ const docId = row.doc ?? version.doc;
4116
+ const before = runtime.index.versionText(docId, base.capture);
4117
+ if (before !== void 0 && !isHeldBack(before)) diff = diffTexts({
4118
+ text: before.text,
4119
+ fields: {
4120
+ title: before.title,
4121
+ meta: before.meta
4122
+ }
4123
+ }, {
4124
+ text: version.text,
4125
+ fields: {
4126
+ title: version.title,
4127
+ meta: version.meta
4128
+ }
4129
+ }).summary;
4130
+ }
4131
+ let next = at + row.every_ms;
4132
+ const stop = runtime.crawler.hostStop(row.target);
4133
+ if (stop !== void 0 && Number.isFinite(stop.until) && stop.until > next) next = stop.until;
4134
+ store.settle(row.url, owner, {
4135
+ ...update,
4136
+ next_at: next,
4137
+ last_at: at,
4138
+ last_kind: kind,
4139
+ last_note: note,
4140
+ doc: page?.doc ?? null
4141
+ });
4142
+ const event = {
4143
+ url: row.target,
4144
+ doc: page?.doc ?? row.doc,
4145
+ kind,
4146
+ suspected,
4147
+ confirmed,
4148
+ capture: seen?.capture ?? null,
4149
+ fetched_at: seen?.fetched_at ?? null,
4150
+ previous: base?.capture ?? null,
4151
+ previous_fetched_at: base?.fetched_at ?? null,
4152
+ outcome: page?.outcome ?? "error",
4153
+ status: page?.status ?? 0,
4154
+ diff,
4155
+ held_back: held,
4156
+ next_at: iso(next),
4157
+ note
4158
+ };
4159
+ return {
4160
+ kind: "event",
4161
+ host: row.host,
4162
+ event
4163
+ };
4164
+ };
4165
+ try {
4166
+ const store = typeof source === "function" ? await source() : source;
4167
+ opened = store;
4168
+ await options.ready?.();
4169
+ budgetMs = options.budgetMs ?? WATCH_LIMITS.budgetMs;
4170
+ if (!(budgetMs >= 0)) throw new RangeError(`budgetMs must be 0 or more, not ${String(budgetMs)}`);
4171
+ maxPages = options.maxPages ?? WATCH_LIMITS.maxPages;
4172
+ if (!Number.isInteger(maxPages) || maxPages < 1) throw new RangeError(`maxPages must be an integer of 1 or more, not ${String(maxPages)}`);
4173
+ const deadline = startedAt + budgetMs;
4174
+ for (;;) {
4175
+ if (stopping === void 0 && abort.signal.aborted) stopping = "cancelled";
4176
+ if (stopping === void 0 && options.drain?.aborted === true) {
4177
+ stopping = "cancelled";
4178
+ reason = "the run stopped for a shutdown: the checks in progress ended first";
4179
+ }
4180
+ if (stopping === void 0 && now() >= deadline) stopping = "budget";
4181
+ while (stopping === void 0 && inflight.size < WATCH_LIMITS.hostsAtOnce && started < maxPages) {
4182
+ const claimed = store.claim(now(), owner, Math.max(now(), deadline) + WATCH_LIMITS.leaseMs, [...inflight.keys()]);
4183
+ if (claimed === void 0) break;
4184
+ started += 1;
4185
+ inflight.set(claimed.host, check(store, claimed, deadline));
4186
+ }
4187
+ if (inflight.size === 0) break;
4188
+ const done = await Promise.race(inflight.values());
4189
+ inflight.delete(done.host);
4190
+ if (done.kind === "deferred") {
4191
+ started -= 1;
4192
+ stopping ??= done.stop;
4193
+ continue;
4194
+ }
4195
+ const event = done.event;
4196
+ counts.checked += 1;
4197
+ counts[event.kind] += 1;
4198
+ if (event.suspected) counts.suspected += 1;
4199
+ if (event.confirmed) counts.confirmed += 1;
4200
+ yield event;
4201
+ }
4202
+ stopping ??= started >= maxPages && store.due(now()) > 0 ? "max-pages" : "done";
4203
+ finished = true;
4204
+ } catch (error) {
4205
+ stopping = "error";
4206
+ reason = error instanceof Error ? error.message : String(error);
4207
+ finished = true;
4208
+ throw error;
4209
+ } finally {
4210
+ if (!finished) {
4211
+ stopping ??= "cancelled";
4212
+ reason ??= "the caller stopped the loop";
4213
+ }
4214
+ outer?.removeEventListener("abort", cancel);
4215
+ abort.abort(/* @__PURE__ */ new Error("the watch run ended"));
4216
+ await Promise.allSettled(inflight.values());
4217
+ const stop = stopping ?? "done";
4218
+ reason ??= stop === "done" ? "the run checked each entry that was due" : stop === "max-pages" ? `the run checked ${String(maxPages)} entries, its limit (maxPages)` : stop === "budget" ? `the budget of ${String(budgetMs)} ms ended (budgetMs)` : stop === "cancelled" ? "the caller cancelled the run" : "the crawler closed";
4219
+ let due = 0;
4220
+ try {
4221
+ due = opened?.due(now()) ?? 0;
4222
+ } catch {}
4223
+ resolveSummary({
4224
+ ...counts,
4225
+ due,
4226
+ stopped: stop,
4227
+ reason,
4228
+ elapsed_ms: Math.max(0, now() - startedAt)
4229
+ });
4230
+ }
4231
+ }
4232
+ return {
4233
+ summary,
4234
+ [Symbol.asyncIterator]() {
4235
+ if (used) throw new Error("a watch run operates one time: call run again for a new run");
4236
+ used = true;
4237
+ return events();
4238
+ }
4239
+ };
4240
+ };
4241
+ //#endregion
4242
+ //#region src/open.ts
4243
+ var open_exports = /* @__PURE__ */ __exportAll({
4244
+ BOT_PAGE: () => BOT_PAGE,
4245
+ DEFAULT_TOKEN: () => DEFAULT_TOKEN,
4246
+ createRuntime: () => createRuntime,
4247
+ defaultUserAgent: () => defaultUserAgent,
4248
+ isMailbox: () => isMailbox,
4249
+ openCrawler: () => openCrawler
4250
+ });
4251
+ /**
4252
+ * This function gives the default `User-Agent` header. The header names the crawler, its version
4253
+ * and the page about it, so a site owner can find out what made each request.
4254
+ */
4255
+ const defaultUserAgent = (contact) => `${DEFAULT_TOKEN}/${VERSION} (+${BOT_PAGE}${contact === void 0 ? "" : `; ${contact}`})`;
4256
+ /** A mailbox is `name@host`, or a name and `<name@host>`. A URL is not a mailbox, also when it holds an `@`. */
4257
+ const MAILBOX = /^(?:[^\s<>@:/]+@[^\s<>@/]+|[^<>]*<[^\s<>@:/]+@[^\s<>@/]+>)$/;
4258
+ /**
4259
+ * This function tells if a contact is a mailbox. The `From` header holds only a mailbox (RFC 9110,
4260
+ * section 10.1.2). Thus the crawler sends a mailbox in `From` and in the `User-Agent`, and a URL in
4261
+ * the `User-Agent` only.
4262
+ */
4263
+ const isMailbox = (contact) => MAILBOX.test(contact.trim());
4264
+ /** The parts of a store that can be a symbolic link to a different place only by mistake or by attack. */
4265
+ const STORE_PARTS = [
4266
+ "captures",
4267
+ "blobs",
4268
+ "index.sqlite",
4269
+ "hosts.sqlite",
4270
+ "watch.sqlite",
4271
+ "vectors.sqlite"
4272
+ ];
4273
+ /**
4274
+ * This function makes the folder of a store for the owner only, where the system has modes. It
4275
+ * refuses a store folder of a different user, and a part of the store that is a symbolic link.
4276
+ * The store folder itself can be a link, for example to a larger disk.
4277
+ */
4278
+ const guardStore = (dir) => {
4279
+ mkdirSync(dir, {
4280
+ recursive: true,
4281
+ mode: 448
4282
+ });
4283
+ const real = lstatSync(dir).isSymbolicLink() ? realpathSync(dir) : dir;
4284
+ const info = statSync(real);
4285
+ if (!info.isDirectory()) throw new Error(`the store ${dir} is not a folder`);
4286
+ if (process.platform !== "win32" && typeof process.getuid === "function" && info.uid !== process.getuid()) throw new Error(`the store ${dir} belongs to a different user: use a folder of your own`);
4287
+ for (const part of STORE_PARTS) {
4288
+ let link = false;
4289
+ try {
4290
+ link = lstatSync(join(dir, part)).isSymbolicLink();
4291
+ } catch {}
4292
+ if (link) throw new Error(`${part} in the store ${dir} is a symbolic link: the crawler does not follow it`);
4293
+ }
4294
+ };
4295
+ /**
4296
+ * This function makes a runtime. It is the one place that puts the log, the blob store, the index,
4297
+ * the host state, the crawler and the extractors together. The function `openCrawler` uses it.
4298
+ * An application that needs the parts uses it too. The runtime and the crawler share one clock.
4299
+ */
4300
+ const createRuntime = (options) => {
4301
+ const dir = options.dir;
4302
+ if (dir === void 0 && (options.log === void 0 || options.blobs === void 0 || options.index === void 0)) throw new Error("a runtime needs dir, or a log, a blob store and an index");
4303
+ const contact = options.contact;
4304
+ if (contact !== void 0 && !/^[\t -~]*$/.test(contact)) throw new Error(`contact "${contact}" has a character that an HTTP header cannot hold: use printable ASCII`);
4305
+ const extractors = new Extractors();
4306
+ for (const [recipe, extractor] of Object.entries(options.extractors ?? {})) extractors.register(recipe, extractor);
4307
+ for (const recipe of options.recipes ?? []) registerRecipe(extractors, recipe);
4308
+ if (dir !== void 0) guardStore(dir);
4309
+ const owned = [];
4310
+ const log = options.log ?? new CaptureLog(join(dir, "captures"));
4311
+ const blobs = options.blobs ?? new FileBlobStore(join(dir, "blobs"));
4312
+ let index = options.index;
4313
+ if (index === void 0) {
4314
+ const made = new DocIndex(join(dir, "index.sqlite"));
4315
+ owned.push(() => made.close());
4316
+ index = made;
4317
+ }
4318
+ let hostState = options.hostState;
4319
+ if (hostState === void 0) {
4320
+ if (dir === void 0) hostState = new MemoryHostStateStore();
4321
+ else {
4322
+ const made = new SqliteHostStateStore(join(dir, "hosts.sqlite"));
4323
+ owned.push(() => made.close());
4324
+ hostState = made;
4325
+ }
4326
+ }
4327
+ const clock = options.clock ?? realClock;
4328
+ const renderer = options.renderer === false ? void 0 : options.renderer ?? checkedRenderer(playwrightRenderer());
4329
+ const crawler = new Crawler({
4330
+ token: options.token ?? "PoliteCrawler",
4331
+ userAgent: options.userAgent ?? defaultUserAgent(contact),
4332
+ from: contact !== void 0 && isMailbox(contact) ? contact.trim() : void 0,
4333
+ log,
4334
+ blobs,
4335
+ index,
4336
+ renderer,
4337
+ maxActive: options.maxActive,
4338
+ maxRenders: options.maxRenders,
4339
+ dispatcher: options.dispatcher,
4340
+ egress: options.egress,
4341
+ politeness: options.politeness,
4342
+ hostState,
4343
+ clock,
4344
+ random: options.random,
4345
+ scope: options.scope,
4346
+ scopeRank: options.scopeRank,
4347
+ obeyDefaultToken: options.obeyDefaultToken,
4348
+ onEvent: options.onEvent,
4349
+ signer: options.signer
4350
+ });
4351
+ let closing;
4352
+ return {
4353
+ crawler,
4354
+ index,
4355
+ blobs,
4356
+ captures: log,
4357
+ log,
4358
+ extractors,
4359
+ hostState,
4360
+ now: () => new Date(clock.now()).toISOString(),
4361
+ close() {
4362
+ closing ??= (async () => {
4363
+ await crawler.close();
4364
+ for (const close of owned) await close();
4365
+ })();
4366
+ return closing;
4367
+ }
4368
+ };
4369
+ };
4370
+ /**
4371
+ * This function opens a crawler on a folder. The folder keeps the capture log (`captures/`), the
4372
+ * response bodies (`blobs/`), the search index (`index.sqlite`) and the state of each host
4373
+ * (`hosts.sqlite`). The index is a projection: `reindex` makes it again from the log and the bodies.
4374
+ * At open, `catchUpIndex` rebuilds an index of an earlier version, and gives an index that is
4375
+ * behind the log the captures that it missed.
4376
+ *
4377
+ * Each method gives a promise, and none throws before its promise. After `close`, each method
4378
+ * rejects.
4379
+ */
4380
+ const openCrawler = (options) => {
4381
+ const floorMs = options.watchFloor === void 0 ? WATCH_LIMITS.floorMs : durationMs(options.watchFloor);
4382
+ const runtime = createRuntime(options);
4383
+ let closed = false;
4384
+ let rebuilt;
4385
+ const ready = existsSync(join(options.dir, "captures")) ? catchUpIndex(runtime).then((done) => {
4386
+ if (done?.mode === "rebuild") {
4387
+ const { mode, ...counts } = done;
4388
+ rebuilt = mode === "rebuild" ? counts : void 0;
4389
+ }
4390
+ }, () => void 0) : Promise.resolve();
4391
+ const live = async () => {
4392
+ if (closed) throw new Error("the crawler is closed");
4393
+ await ready;
4394
+ if (closed) throw new Error("the crawler is closed");
4395
+ };
4396
+ let watches;
4397
+ const watchStore = async () => {
4398
+ await live();
4399
+ watches ??= new WatchStore(join(options.dir, "watch.sqlite"), { floorMs });
4400
+ return watches;
4401
+ };
4402
+ const clockNow = () => Date.parse(runtime.now());
4403
+ const embedder = options.embedder;
4404
+ let vectors;
4405
+ let search;
4406
+ const hybridSearch = async () => {
4407
+ await live();
4408
+ if (embedder === void 0) throw new Error(NO_EMBEDDER);
4409
+ vectors ??= new VectorStore(join(options.dir, "vectors.sqlite"));
4410
+ search ??= createHybridSearch(runtime, {
4411
+ embedder,
4412
+ store: vectors
4413
+ });
4414
+ return search;
4415
+ };
4416
+ const hybrid = embedder === void 0 ? void 0 : {
4417
+ embedder,
4418
+ find: async (q, findOptions) => (await hybridSearch()).find(q, findOptions),
4419
+ rankWithin: async (q, docs, rankOptions) => (await hybridSearch()).rankWithin(q, docs, rankOptions),
4420
+ embed: async (embedOptions) => (await hybridSearch()).embed(embedOptions)
4421
+ };
4422
+ let closing;
4423
+ return {
4424
+ runtime,
4425
+ async fetch(url, fetchOptions = {}) {
4426
+ await live();
4427
+ rebuilt = void 0;
4428
+ const maxAge = fetchOptions.maxAge ?? "24h";
4429
+ return fetchPage(runtime, url, {
4430
+ render: fetchOptions.render ?? "auto",
4431
+ maxAgeMs: maxAge === "auto" ? "auto" : durationMs(maxAge),
4432
+ onBehalfOf: [...options.onBehalfOf ?? [], ...fetchOptions.onBehalfOf ?? []],
4433
+ signal: fetchOptions.signal,
4434
+ scope: fetchOptions.scope,
4435
+ labels: fetchOptions.labels,
4436
+ fallback: fetchOptions.fallback,
4437
+ archiveDespiteRobots: fetchOptions.archiveDespiteRobots
4438
+ });
4439
+ },
4440
+ async find(q, findOptions = {}) {
4441
+ await live();
4442
+ const { rank, ...rest } = findOptions;
4443
+ if (rank === "hybrid") return (await hybridSearch()).find(q, rest);
4444
+ return findDocuments(runtime, q, {
4445
+ ...rest,
4446
+ ...rank === void 0 ? {} : { rank }
4447
+ });
4448
+ },
4449
+ hybrid,
4450
+ async embed(embedOptions = {}) {
4451
+ return (await hybridSearch()).embed(embedOptions);
4452
+ },
4453
+ async rankWithin(q, docs, rankOptions = {}) {
4454
+ await live();
4455
+ const { rank, ...rest } = rankOptions;
4456
+ if (rank === "hybrid") return (await hybridSearch()).rankWithin(q, docs, rest);
4457
+ return runtime.index.rankWithin(q, docs, rank === void 0 ? {} : { rank });
4458
+ },
4459
+ async read(id, readOptions = {}) {
4460
+ await live();
4461
+ return readDocument(runtime, id, readOptions);
4462
+ },
4463
+ async verify(id, quote, verifyOptions = {}) {
4464
+ await live();
4465
+ return verifyQuote(runtime, id, quote, verifyOptions);
4466
+ },
4467
+ async extract(id, fields, extractOptions = {}) {
4468
+ await live();
4469
+ return extractDocument(runtime, id, fields, extractOptions);
4470
+ },
4471
+ async versions(id, versionOptions = {}) {
4472
+ await live();
4473
+ const held = runtime.index.get(id)?.ai_input === "no";
4474
+ return runtime.index.versions(id, versionOptions).map((version) => held || version.ai_input === "no" ? {
4475
+ ...version,
4476
+ title: ""
4477
+ } : version);
4478
+ },
4479
+ async discover(site, discoverOptions = {}) {
4480
+ await live();
4481
+ rebuilt = void 0;
4482
+ return discoverSite(runtime, site, {
4483
+ ...discoverOptions,
4484
+ onBehalfOf: [...options.onBehalfOf ?? [], ...discoverOptions.onBehalfOf ?? []]
4485
+ });
4486
+ },
4487
+ crawl(seeds, crawlOptions = {}) {
4488
+ return crawlSite(runtime, seeds, {
4489
+ ...crawlOptions,
4490
+ onBehalfOf: [...options.onBehalfOf ?? [], ...crawlOptions.onBehalfOf ?? []],
4491
+ ready: async () => {
4492
+ await live();
4493
+ rebuilt = void 0;
4494
+ }
4495
+ });
4496
+ },
4497
+ async reindex() {
4498
+ await live();
4499
+ const done = rebuilt;
4500
+ rebuilt = void 0;
4501
+ return done ?? reindex(runtime);
4502
+ },
4503
+ async diff(id, diffOptions = {}) {
4504
+ await live();
4505
+ return diffDocument(runtime, id, diffOptions);
4506
+ },
4507
+ watch: {
4508
+ async add(url, watchOptions = {}) {
4509
+ return (await watchStore()).add(url, watchOptions, clockNow());
4510
+ },
4511
+ async remove(url) {
4512
+ return (await watchStore()).remove(url);
4513
+ },
4514
+ async list() {
4515
+ return (await watchStore()).list(clockNow());
4516
+ },
4517
+ run(runOptions = {}) {
4518
+ return runWatch(runtime, watchStore, {
4519
+ ...runOptions,
4520
+ onBehalfOf: options.onBehalfOf,
4521
+ ready: () => {
4522
+ rebuilt = void 0;
4523
+ return Promise.resolve();
4524
+ }
4525
+ });
4526
+ }
4527
+ },
4528
+ close() {
4529
+ closed = true;
4530
+ closing ??= (async () => {
4531
+ await ready;
4532
+ await runtime.close();
4533
+ watches?.close();
4534
+ vectors?.close();
4535
+ await embedder?.close?.();
4536
+ })();
4537
+ return closing;
4538
+ }
4539
+ };
4540
+ };
4541
+ //#endregion
4542
+ export { crawlSite as A, chunkText as C, unitVector as D, fuseRanks as E, readSitemap as F, DocIndex as I, FAST_RANK_SHARE as L, discoverSite as M, SITEMAP_LIMITS as N, SqliteHostStateStore as O, feedDate as P, NO_EMBEDDER as R, HYBRID_DEFAULTS as S, estimateTokens as T, registerRecipe as _, open_exports as a, createHybridSearch as b, WatchStore as c, VectorStore as d, RECIPE_LIMITS as f, readJson as g, defineRecipe as h, openCrawler as i, DISCOVER_LIMITS as j, CRAWL_LIMITS as k, runWatch as l, deadlineOf as m, defaultUserAgent as n, WATCH_LIMITS as o, RESERVED_RECIPES as p, isMailbox as r, WATCH_SCHEMA as s, createRuntime as t, VECTORS_SCHEMA as u, runRecipe as v, chunkTokensOf as w, CHUNK_LIMITS as x, runtimeOf as y };
4543
+
4544
+ //# sourceMappingURL=open-C4fhXJgD.mjs.map