polite-crawler 0.1.0 → 0.1.2

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 (52) hide show
  1. package/README.md +601 -12
  2. package/build/browser-CKRNbIDT.mjs +62 -0
  3. package/build/browser-CKRNbIDT.mjs.map +1 -0
  4. package/build/cli.mjs +801 -120
  5. package/build/cli.mjs.map +1 -1
  6. package/build/diff-Dk2eSvi-.mjs +648 -0
  7. package/build/diff-Dk2eSvi-.mjs.map +1 -0
  8. package/build/duration-iIuMMofi.mjs +38 -0
  9. package/build/duration-iIuMMofi.mjs.map +1 -0
  10. package/build/extract-DcwNrTK7.mjs +1793 -0
  11. package/build/extract-DcwNrTK7.mjs.map +1 -0
  12. package/build/{html-nWYFVsRU.d.mts → html-BACljlkf.d.mts} +8 -9
  13. package/build/{html-oNpGAHwi.mjs → html-DkQUAE_d.mjs} +18 -39
  14. package/build/html-DkQUAE_d.mjs.map +1 -0
  15. package/build/html.d.mts +2 -1
  16. package/build/html.mjs +3 -1
  17. package/build/index.d.mts +106 -6
  18. package/build/index.mjs +14 -6
  19. package/build/log-Dx-wXjJl.mjs +342 -0
  20. package/build/log-Dx-wXjJl.mjs.map +1 -0
  21. package/build/mcp.d.mts +7 -4
  22. package/build/mcp.mjs +561 -35
  23. package/build/mcp.mjs.map +1 -1
  24. package/build/open-Ca1lcWR3.d.mts +2875 -0
  25. package/build/open-DnrWgkC0.mjs +3161 -0
  26. package/build/open-DnrWgkC0.mjs.map +1 -0
  27. package/build/{politeness-C5bZ1OKM.d.mts → politeness-yI3xBEx9.d.mts} +95 -2
  28. package/build/politeness.d.mts +2 -2
  29. package/build/politeness.mjs +102 -1
  30. package/build/politeness.mjs.map +1 -1
  31. package/build/{browser-gJ_h4AhB.mjs → render-C1CUraWF.mjs} +31 -74
  32. package/build/render-C1CUraWF.mjs.map +1 -0
  33. package/build/rolldown-runtime-D7D4PA-g.mjs +13 -0
  34. package/build/{open-CJR4b44v.mjs → runtime-k8mo1_7U.mjs} +1018 -1582
  35. package/build/runtime-k8mo1_7U.mjs.map +1 -0
  36. package/build/urls-BHFeSyEK.mjs +62 -0
  37. package/build/urls-BHFeSyEK.mjs.map +1 -0
  38. package/build/urls-BTDSgDLC.d.mts +16 -0
  39. package/build/wacz-sfOLT8e9.mjs +275 -0
  40. package/build/wacz-sfOLT8e9.mjs.map +1 -0
  41. package/build/warc-VIPscWxY.mjs +728 -0
  42. package/build/warc-VIPscWxY.mjs.map +1 -0
  43. package/build/webbotauth-CjJdTAfE.mjs +568 -0
  44. package/build/webbotauth-CjJdTAfE.mjs.map +1 -0
  45. package/package.json +1 -1
  46. package/record.schema.json +20 -1
  47. package/build/browser-gJ_h4AhB.mjs.map +0 -1
  48. package/build/html-oNpGAHwi.mjs.map +0 -1
  49. package/build/open-CJR4b44v.mjs.map +0 -1
  50. package/build/open-CZqwY7GH.d.mts +0 -1461
  51. package/build/version-rVx8_y1_.mjs +0 -11
  52. package/build/version-rVx8_y1_.mjs.map +0 -1
@@ -1,140 +1,19 @@
1
- import { a as RenderRefused, o as playwrightRenderer, r as checkedRenderer, s as __exportAll } from "./browser-gJ_h4AhB.mjs";
1
+ import { n as RenderRefused } from "./render-C1CUraWF.mjs";
2
2
  import { classify, isClientError, parseHttpDate } from "./classify.mjs";
3
- import { DEFAULT_POLICY, HostScheduler, HostStopped, MAX_STOP_MS, MemoryHostStateStore, checkPolicy, equalJitterMs, policyFor, realClock, restAfter } from "./politeness.mjs";
3
+ import { DEFAULT_POLICY, HostScheduler, HostStopped, MAX_STOP_MS, checkPolicy, equalJitterMs, policyFor, realClock, restAfter, validatorsToSend } from "./politeness.mjs";
4
4
  import { decide, isProductToken, robotsFromResponse, robotsFromStatus, signalsFor, unreachableRobots } from "./robots.mjs";
5
5
  import { NO_SIGNALS, metaSignals, readSignals } from "./signals.mjs";
6
- import { n as DEFAULT_TOKEN, r as VERSION, t as BOT_PAGE } from "./version-rVx8_y1_.mjs";
7
- import { c as canonicalUrl, o as decodeBody, r as typesOf, s as cacheKeyOf, t as extractPage } from "./html-oNpGAHwi.mjs";
8
- import { createHash, randomUUID } from "node:crypto";
9
- import { mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
10
- import { dirname, join } from "node:path";
11
- import { createReadStream, existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
6
+ import { r as DEFAULT_TOKEN } from "./duration-iIuMMofi.mjs";
7
+ import { n as canonicalUrl, t as cacheKeyOf } from "./urls-BHFeSyEK.mjs";
8
+ import { a as signerOf } from "./webbotauth-CjJdTAfE.mjs";
9
+ import { o as decodeBody, r as typesOf, t as extractPage } from "./html-DkQUAE_d.mjs";
10
+ import { createHash } from "node:crypto";
12
11
  import { Transform } from "node:stream";
13
- import { constants, createBrotliDecompress, createGunzip, createInflate, createInflateRaw, inflateSync } from "node:zlib";
12
+ import { brotliDecompressSync, constants, createBrotliDecompress, createGunzip, createInflate, createInflateRaw, gunzipSync, inflateRawSync, inflateSync } from "node:zlib";
14
13
  import { Agent, request } from "undici";
15
14
  import { lookup } from "node:dns";
16
15
  import { isIP } from "node:net";
17
- import { DatabaseSync } from "node:sqlite";
18
16
  import { getDocumentProxy, getMeta } from "unpdf";
19
- //#region src/blobs.ts
20
- const HASH = /^[0-9a-f]{64}$/;
21
- /** A rename can fail with these codes when a different writer puts the same blob at the same time. */
22
- const RACE_CODES = /* @__PURE__ */ new Set([
23
- "EPERM",
24
- "EEXIST",
25
- "EBUSY",
26
- "EACCES",
27
- "ENOTEMPTY"
28
- ]);
29
- const blobPath = (root, hash) => join(root, hash.slice(0, 2), hash.slice(2, 4), hash);
30
- const hashOf = (bytes) => createHash("sha256").update(bytes).digest("hex");
31
- const codeOf = (error) => typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
32
- const statOf = (path) => stat(path).catch(() => void 0);
33
- /** This function writes a folder to the disk, so a rename in it stays after a power cut. Windows cannot open a folder, so it skips this step. */
34
- const syncFolder = async (path) => {
35
- if (process.platform === "win32") return;
36
- try {
37
- const handle = await open(path, "r");
38
- try {
39
- await handle.sync();
40
- } finally {
41
- await handle.close();
42
- }
43
- } catch {}
44
- };
45
- /**
46
- * This store keeps each blob in a file: `<dir>/ab/cd/abcd…`, where the name is the SHA-256 hash
47
- * of the bytes. These are its rules:
48
- *
49
- * - Equal bytes are written one time only. A file of the wrong size (a write that a crash cut) is
50
- * written again.
51
- * - A write goes to a temporary file with a random name, goes to the disk, and then gets its name.
52
- * A crash thus cannot leave half a blob under its name.
53
- * - Two writers can put the same bytes at the same time. When the rename of one fails because the
54
- * other was first, the put succeeds if the stored file has the same hash.
55
- * - The method `get` examines the hash of the bytes. A damaged file gets a new name
56
- * (`.damaged`), and `get` gives no bytes, so the crawler fetches the body again.
57
- */
58
- var FileBlobStore = class FileBlobStore {
59
- dir;
60
- writing = /* @__PURE__ */ new Map();
61
- sync;
62
- constructor(dir, options = {}) {
63
- this.dir = dir;
64
- this.sync = options.sync ?? true;
65
- }
66
- put(bytes) {
67
- const hash = hashOf(bytes);
68
- const running = this.writing.get(hash);
69
- if (running !== void 0) return running;
70
- const work = this.write(hash, bytes).finally(() => this.writing.delete(hash));
71
- this.writing.set(hash, work);
72
- return work;
73
- }
74
- /** This method tells if a file holds exactly the bytes of a hash. */
75
- static async holds(path, hash) {
76
- try {
77
- return hashOf(await readFile(path)) === hash;
78
- } catch {
79
- return false;
80
- }
81
- }
82
- async write(hash, bytes) {
83
- const target = blobPath(this.dir, hash);
84
- const existing = await statOf(target);
85
- if (existing?.isFile() === true && existing.size === bytes.length) return {
86
- hash,
87
- existed: true
88
- };
89
- const folder = dirname(target);
90
- await mkdir(folder, {
91
- recursive: true,
92
- mode: 448
93
- });
94
- const temporary = `${target}.${randomUUID()}.tmp`;
95
- try {
96
- const handle = await open(temporary, "wx", 384);
97
- try {
98
- await handle.writeFile(bytes);
99
- if (this.sync) await handle.sync();
100
- } finally {
101
- await handle.close();
102
- }
103
- try {
104
- await rename(temporary, target);
105
- } catch (error) {
106
- if (!RACE_CODES.has(codeOf(error)) || !await FileBlobStore.holds(target, hash)) throw error;
107
- return {
108
- hash,
109
- existed: true
110
- };
111
- }
112
- if (this.sync) await syncFolder(folder);
113
- return {
114
- hash,
115
- existed: false
116
- };
117
- } finally {
118
- await rm(temporary, { force: true }).catch(() => void 0);
119
- }
120
- }
121
- async get(hash) {
122
- if (!HASH.test(hash)) return void 0;
123
- const target = blobPath(this.dir, hash);
124
- let bytes;
125
- try {
126
- bytes = await readFile(target);
127
- } catch {
128
- return;
129
- }
130
- if (hashOf(bytes) !== hash) {
131
- await rename(target, `${target}.${randomUUID()}.damaged`).catch(() => void 0);
132
- return;
133
- }
134
- return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
135
- }
136
- };
137
- //#endregion
138
17
  //#region src/egress.ts
139
18
  /** The connection hook gives this error when the policy refuses each address of a host. */
140
19
  var EgressRefused = class extends Error {
@@ -551,6 +430,8 @@ const makeRecord = (input) => {
551
430
  };
552
431
  //#endregion
553
432
  //#region src/crawler.ts
433
+ /** The note of a request starts with this text when its host was not free before its deadline, so the request did not start. */
434
+ const DEADLINE_NOTE = "deadline reached";
554
435
  /**
555
436
  * This function gives the key of the cache entry of a rendered page in the form of version 0.1.
556
437
  * The index keeps the variant in a column of its own, and it reads this form too.
@@ -639,8 +520,25 @@ const CREDENTIAL_HEADERS = /* @__PURE__ */ new Set([
639
520
  const CONSTANT_HEADERS = /* @__PURE__ */ new Set([
640
521
  "accept-encoding",
641
522
  "user-agent",
642
- "from"
523
+ "from",
524
+ "signature",
525
+ "signature-input",
526
+ "signature-agent"
527
+ ]);
528
+ /** With a signer, the crawler sets these headers, and a caller cannot. */
529
+ const SIGNATURE_HEADERS = /* @__PURE__ */ new Set([
530
+ "signature",
531
+ "signature-input",
532
+ "signature-agent"
643
533
  ]);
534
+ /** These response headers hold usage signals. With `originalHeaderPrefix`, the archived forms of them count too. */
535
+ const SIGNAL_HEADERS = [
536
+ "content-usage",
537
+ "content-signal",
538
+ "x-robots-tag",
539
+ "tdm-reservation",
540
+ "tdm-policy"
541
+ ];
644
542
  /**
645
543
  * These content codings change nothing in the body. A server that stored an object with
646
544
  * `aws-chunked` can send the name with the decoded body.
@@ -711,12 +609,21 @@ var Slots = class {
711
609
  const missingBrowser = (message) => message.startsWith("rendering needs the package playwright-core and a browser: npm install playwright-core, then npx playwright-core install chromium-headless-shell. For the MCP server with npx, start it with npx -y -p polite-crawler -p playwright-core polite-crawler mcp") || /Executable doesn't exist|Cannot find (?:module|package) 'playwright-core'/.test(message);
712
610
  /** This text tells why the crawler did not fetch a page: a rule of the site, or no robots.txt to read. */
713
611
  const robotsRefusal = (robots, by) => robots.unreachable === void 0 ? `robots.txt disallows it for ${by}` : `robots.txt could not be read (${robots.unreachable}), so nothing on the site is fetched`;
714
- const messageOf$2 = (error) => error instanceof Error ? error.message.split(/\r?\n/)[0] ?? "" : String(error);
612
+ const messageOf$1 = (error) => error instanceof Error ? error.message.split(/\r?\n/)[0] ?? "" : String(error);
715
613
  const isoOf = (ms) => Number.isFinite(ms) && Math.abs(ms) < LAST_DATE_MS ? new Date(ms).toISOString() : void 0;
716
614
  const stopNote = (reason, until) => {
717
615
  const end = isoOf(until);
718
616
  return end === void 0 ? `host stopped: ${reason}` : `host stopped until ${end}: ${reason}`;
719
617
  };
618
+ /** A request did not start before its deadline. The crawler gives a skip with `DEADLINE_NOTE` for it. */
619
+ var DeadlineReached = class extends Error {
620
+ readyAt;
621
+ constructor(readyAt) {
622
+ super(DEADLINE_NOTE);
623
+ this.readyAt = readyAt;
624
+ }
625
+ };
626
+ const deadlineNote = (error, deadline) => `${DEADLINE_NOTE}: the host is free at ${isoOf(error.readyAt) ?? "a later time"}, and the request must start before ${isoOf(deadline ?? NaN) ?? "its deadline"}`;
720
627
  const secretSet = (secretParams) => new Set([...DEFAULT_SECRET_PARAMS, ...secretParams].map((name) => name.toLowerCase()));
721
628
  const nameOfParam = (raw) => {
722
629
  try {
@@ -794,7 +701,7 @@ var Lru = class {
794
701
  var BodyError = class extends Error {
795
702
  kind;
796
703
  constructor(kind, cause) {
797
- super(messageOf$2(cause), { cause });
704
+ super(messageOf$1(cause), { cause });
798
705
  this.kind = kind;
799
706
  }
800
707
  };
@@ -832,7 +739,7 @@ const decoderFor = (coding) => {
832
739
  throw new BodyError("decode", /* @__PURE__ */ new Error(`unsupported content-encoding "${coding}"`));
833
740
  };
834
741
  /** This function tells if a decoder stopped because the compressed body ended early: the server cut it. */
835
- const endedEarly = (error) => typeof error === "object" && error !== null && "code" in error && (error.code === "Z_BUF_ERROR" || /unexpected end of file/i.test(messageOf$2(error)));
742
+ const endedEarly = (error) => typeof error === "object" && error !== null && "code" in error && (error.code === "Z_BUF_ERROR" || /unexpected end of file/i.test(messageOf$1(error)));
836
743
  /**
837
744
  * This function reads a body as a stream and decodes each content coding in sequence. It counts
838
745
  * the decoded bytes and keeps at most `maxBytes`. Thus a decompression bomb stops at the
@@ -1027,15 +934,29 @@ var Crawler = class {
1027
934
  renders;
1028
935
  /** These tokens apply because the `User-Agent` links the bot page (`obeyDefaultToken`). */
1029
936
  pageTokens;
937
+ /** The deadlines of the requests that have one, by the signal that the scheduler gets for each of them. */
938
+ deadlines = /* @__PURE__ */ new WeakMap();
939
+ /** This signer signs each request. It holds the key, and the crawler writes only its key id and its agent. */
940
+ signer;
1030
941
  constructor(options) {
1031
942
  this.options = options;
943
+ this.signer = signerOf(options.signer);
1032
944
  if (!isProductToken(options.token)) throw new Error(`the token "${options.token}" is not a product token: use only letters, "_" and "-" (RFC 9309)`);
1033
945
  for (const [name, value] of [["userAgent", options.userAgent], ["from", options.from]]) if (value !== void 0 && !HEADER_TEXT.test(value)) throw new Error(`${name} "${value}" has a character that an HTTP header cannot hold: use printable ASCII`);
1034
946
  checkPolicy(options.politeness);
1035
947
  this.clock = options.clock ?? realClock;
1036
948
  this.random = options.random ?? Math.random;
1037
949
  this.egress = options.egress ?? {};
1038
- this.scheduler = new HostScheduler(this.clock, options.maxActive ?? 16, { store: options.hostState });
950
+ const clock = this.clock;
951
+ const deadlines = this.deadlines;
952
+ this.scheduler = new HostScheduler({
953
+ now: () => clock.now(),
954
+ sleep: (ms, signal) => {
955
+ const deadline = signal === void 0 ? void 0 : deadlines.get(signal);
956
+ const readyAt = clock.now() + ms;
957
+ return deadline !== void 0 && readyAt >= deadline ? Promise.reject(new DeadlineReached(readyAt)) : clock.sleep(ms, signal);
958
+ }
959
+ }, options.maxActive ?? 16, { store: options.hostState });
1039
960
  this.pageTokens = options.obeyDefaultToken !== false && options.token.toLowerCase() !== "PoliteCrawler".toLowerCase() && options.userAgent.includes("https://mark1russell7.github.io/crawler/bot/") ? [DEFAULT_TOKEN] : [];
1040
961
  const maxRenders = options.maxRenders ?? 2;
1041
962
  if (!Number.isInteger(maxRenders) || maxRenders < 1) throw new RangeError(`maxRenders must be an integer of 1 or more, not ${String(maxRenders)}`);
@@ -1059,6 +980,21 @@ var Crawler = class {
1059
980
  policyOf(url) {
1060
981
  return policyFor(this.options.politeness, url.hostname);
1061
982
  }
983
+ /**
984
+ * This method gives the signal of a request for the scheduler. A request with a deadline gets a
985
+ * signal of its own, and the clock of the scheduler reads the deadline of that signal.
986
+ */
987
+ paced(signal, deadline) {
988
+ if (deadline === void 0) return signal;
989
+ const own = AbortSignal.any([signal]);
990
+ this.deadlines.set(own, deadline);
991
+ return own;
992
+ }
993
+ /** This method throws `DeadlineReached` when the deadline of a request came before its slot. */
994
+ beforeDeadline(deadline) {
995
+ const now = this.clock.now();
996
+ if (deadline !== void 0 && now >= deadline) throw new DeadlineReached(now);
997
+ }
1062
998
  emit(event) {
1063
999
  try {
1064
1000
  this.options.onEvent?.(event);
@@ -1069,6 +1005,14 @@ var Crawler = class {
1069
1005
  work.then(() => this.running.delete(work), () => this.running.delete(work));
1070
1006
  return work;
1071
1007
  }
1008
+ /** This method gives the validator memo of a host. The memo is a hint, so an error of its store gives no memo. */
1009
+ memoOf(host) {
1010
+ try {
1011
+ return this.scheduler.store.validators?.(host);
1012
+ } catch {
1013
+ return;
1014
+ }
1015
+ }
1072
1016
  stopHost(host, until, reason) {
1073
1017
  this.scheduler.store.stop(host, until, reason);
1074
1018
  this.emit({
@@ -1215,11 +1159,41 @@ var Crawler = class {
1215
1159
  fetchedAt: entry.fetched_at
1216
1160
  } };
1217
1161
  }
1162
+ /**
1163
+ * This method gives the signature headers of one request, and the facts of the capture: the key id
1164
+ * and the agent. With no signer, it gives nothing. A signer that throws gives the note of a skip,
1165
+ * because the crawler sends no unsigned request when it has a signer.
1166
+ */
1167
+ signFor(target) {
1168
+ if (this.signer === void 0) return void 0;
1169
+ let given;
1170
+ try {
1171
+ given = this.signer.sign(target, this.clock.now());
1172
+ } catch (error) {
1173
+ return { error: `the signer did not sign the request: ${messageOf$1(error)}` };
1174
+ }
1175
+ const headers = {};
1176
+ for (const [name, value] of Object.entries(given)) {
1177
+ const lower = name.toLowerCase();
1178
+ if (RESERVED_HEADERS.has(lower)) continue;
1179
+ if (typeof value !== "string" || !HEADER_TEXT.test(value)) return { error: `the signer gave a ${lower} header that HTTP cannot send` };
1180
+ headers[lower] = value;
1181
+ }
1182
+ return {
1183
+ headers,
1184
+ facts: { signed: {
1185
+ key_id: this.signer.keyId,
1186
+ agent: this.signer.signatureAgent
1187
+ } }
1188
+ };
1189
+ }
1218
1190
  requestHeaders(request_) {
1219
1191
  const headers = {};
1220
1192
  for (const [name, value] of Object.entries(request_.headers ?? {})) {
1221
1193
  const lower = name.toLowerCase();
1222
- if (!RESERVED_HEADERS.has(lower)) headers[lower] = value;
1194
+ if (RESERVED_HEADERS.has(lower)) continue;
1195
+ if (this.signer !== void 0 && SIGNATURE_HEADERS.has(lower)) continue;
1196
+ headers[lower] = value;
1223
1197
  }
1224
1198
  headers["accept"] = request_.accept ?? headers["accept"] ?? "*/*";
1225
1199
  headers["user-agent"] = this.options.userAgent;
@@ -1408,7 +1382,7 @@ var Crawler = class {
1408
1382
  return {
1409
1383
  ...closedResult(request_.url),
1410
1384
  outcome: "error",
1411
- note: `crawler error: ${messageOf$2(error)}`
1385
+ note: `crawler error: ${messageOf$1(error)}`
1412
1386
  };
1413
1387
  }
1414
1388
  }
@@ -1428,7 +1402,11 @@ var Crawler = class {
1428
1402
  request_.onBehalfOf ?? null,
1429
1403
  request_.job ?? null,
1430
1404
  request_.scope ?? null,
1431
- request_.labels ?? null
1405
+ request_.labels ?? null,
1406
+ request_.deadline ?? null,
1407
+ request_.range ?? null,
1408
+ request_.signalLines ?? null,
1409
+ request_.originalHeaderPrefix ?? null
1432
1410
  ]);
1433
1411
  const running = this.inFlight.get(key);
1434
1412
  if (running !== void 0) return running;
@@ -1471,7 +1449,7 @@ var Crawler = class {
1471
1449
  good: cached?.good
1472
1450
  };
1473
1451
  entry.verdict = this.fetchRobots(target, policy, waiting).catch((error) => ({
1474
- robots: unreachableRobots(`robots.txt failed: ${messageOf$2(error)}`),
1452
+ robots: unreachableRobots(`robots.txt failed: ${messageOf$1(error)}`),
1475
1453
  capture: void 0,
1476
1454
  retryAfterMs: void 0
1477
1455
  })).then(async (read) => {
@@ -1506,13 +1484,63 @@ var Crawler = class {
1506
1484
  * come from its memory, or from a fetch of `/robots.txt` with its redirects. A robots.txt that
1507
1485
  * the crawler cannot read gives rules that permit nothing, and `unreachable` gives the reason.
1508
1486
  */
1509
- async readRobots(url) {
1487
+ async readRobots(url, marks = {}) {
1510
1488
  const target = new URL(url);
1511
1489
  if (!WEB_PROTOCOLS.has(target.protocol)) return {
1512
1490
  robots: unreachableRobots("not an http(s) URL"),
1513
1491
  capture: void 0
1514
1492
  };
1515
- return this.robotsFor(target, this.policyOf(target));
1493
+ return this.robotsFor(target, this.policyOf(target), marks);
1494
+ }
1495
+ /**
1496
+ * This method tells if robots.txt lets the crawler fetch a URL as a page. The rules of the token
1497
+ * of the crawler and of the agents of `onBehalfOf` apply, as for a fetch. It reads robots.txt as
1498
+ * a fetch does, so a robots.txt that the crawler cannot read permits nothing. When the signal
1499
+ * aborts, the promise rejects, and a fetch of robots.txt in progress continues.
1500
+ */
1501
+ async robotsAllow(url, options = {}) {
1502
+ let target;
1503
+ try {
1504
+ target = new URL(url);
1505
+ } catch {
1506
+ return {
1507
+ allowed: false,
1508
+ note: "not a valid URL"
1509
+ };
1510
+ }
1511
+ if (!WEB_PROTOCOLS.has(target.protocol)) return {
1512
+ allowed: false,
1513
+ note: "not an http(s) URL"
1514
+ };
1515
+ if (this.closed) return {
1516
+ allowed: false,
1517
+ note: "crawler closed"
1518
+ };
1519
+ const tokens = [
1520
+ this.options.token,
1521
+ ...options.onBehalfOf ?? [],
1522
+ ...this.pageTokens
1523
+ ];
1524
+ const badToken = tokens.find((token) => !isProductToken(token));
1525
+ if (badToken !== void 0) return {
1526
+ allowed: false,
1527
+ note: `not a valid product token: "${badToken}"`
1528
+ };
1529
+ const work = this.robotsFor(target, this.policyOf(target), options);
1530
+ const verdict = await (options.signal === void 0 ? work : raceAbort(work, options.signal));
1531
+ const path = `${target.pathname}${target.search}`;
1532
+ const decision = decide(verdict.robots, tokens, path);
1533
+ const lines = signalsFor(verdict.robots, tokens, path);
1534
+ const signals = lines.length > 0 ? { signals: lines } : {};
1535
+ return decision.allowed ? {
1536
+ allowed: true,
1537
+ note: void 0,
1538
+ ...signals
1539
+ } : {
1540
+ allowed: false,
1541
+ note: robotsRefusal(verdict.robots, decision.by ?? this.options.token),
1542
+ ...signals
1543
+ };
1516
1544
  }
1517
1545
  /** This method reads the stored copy of a robots.txt, when it is younger than the limit of the policy. */
1518
1546
  async storedRobots(target, policy) {
@@ -1602,21 +1630,23 @@ var Crawler = class {
1602
1630
  return request_.labels;
1603
1631
  }
1604
1632
  };
1633
+ const range = request_.range;
1634
+ const badRange = range === void 0 || Number.isSafeInteger(range.offset) && range.offset >= 0 && Number.isSafeInteger(range.length) && range.length >= 1 ? void 0 : `invalid option: range is ${JSON.stringify(range)}`;
1605
1635
  const admission = await this.admit({
1606
1636
  transport: "http",
1607
1637
  url: request_.url,
1608
1638
  mode: request_.mode,
1609
1639
  base,
1610
1640
  secretParams,
1611
- invalid: invalidNumber("minIntervalMs", request_.minIntervalMs, false) ?? invalidNumber("maxBytes", request_.maxBytes, false) ?? invalidNumber("maxAgeMs", request_.maxAgeMs, true),
1641
+ invalid: invalidNumber("minIntervalMs", request_.minIntervalMs, false) ?? invalidNumber("maxBytes", request_.maxBytes, false) ?? invalidNumber("maxAgeMs", request_.maxAgeMs, true) ?? badRange,
1612
1642
  onBehalfOf: request_.onBehalfOf,
1613
1643
  scope: request_.scope,
1614
1644
  labels: request_.labels,
1615
- cached: (url) => this.fromCache(url, "plain", request_.maxAgeMs, sent, secretParams)
1645
+ cached: (url) => range !== void 0 ? Promise.resolve(void 0) : this.fromCache(url, "plain", request_.maxAgeMs, sent, secretParams)
1616
1646
  });
1617
1647
  if ("answer" in admission) return admission.answer;
1618
1648
  const { target, url, host, policy, check, robotsLines, crawlDelaySeconds, skip } = admission.admitted;
1619
- const maxBytes = request_.maxBytes ?? policy.maxBytes;
1649
+ const maxBytes = Math.min(request_.maxBytes ?? policy.maxBytes, range?.length ?? Infinity);
1620
1650
  const rest = (elapsedMs) => ({
1621
1651
  kind: request_.mode,
1622
1652
  elapsedMs,
@@ -1626,23 +1656,41 @@ var Crawler = class {
1626
1656
  const robotsFile = request_.recipe === "robots.txt";
1627
1657
  try {
1628
1658
  return await this.scheduler.run(host, async () => {
1629
- const again = await this.fromCache(url, "plain", request_.maxAgeMs, sent, secretParams);
1659
+ const again = range === void 0 ? await this.fromCache(url, "plain", request_.maxAgeMs, sent, secretParams) : void 0;
1630
1660
  if (again !== void 0) return {
1631
1661
  value: again,
1632
1662
  delayMs: 0
1633
1663
  };
1634
- const latest = this.lookup(url);
1664
+ this.beforeDeadline(request_.deadline);
1665
+ const latest = range === void 0 ? this.lookup(url) : void 0;
1635
1666
  const usable = latest !== void 0 && latest.truncated !== true && varyMatches(latest.vary_key, sent) ? latest : void 0;
1636
1667
  let stored;
1637
1668
  if (usable?.blob && (usable.etag || usable.last_modified)) stored = await this.options.blobs.get(usable.blob);
1638
1669
  const headers = { ...sent };
1639
- if (stored !== void 0 && usable?.etag) headers["if-none-match"] = usable.etag;
1640
- if (stored !== void 0 && usable?.last_modified) headers["if-modified-since"] = usable.last_modified;
1670
+ const send = stored === void 0 || usable === void 0 ? {
1671
+ etag: false,
1672
+ lastModified: false
1673
+ } : validatorsToSend(robotsFile ? void 0 : this.memoOf(host), {
1674
+ etag: Boolean(usable.etag),
1675
+ lastModified: Boolean(usable.last_modified)
1676
+ }, this.clock.now());
1677
+ if (send.etag && usable?.etag) headers["if-none-match"] = usable.etag;
1678
+ if (send.lastModified && usable?.last_modified) headers["if-modified-since"] = usable.last_modified;
1679
+ const validators = ["if-none-match", "if-modified-since"].filter((name) => headers[name] !== void 0);
1680
+ const rangeText = range === void 0 ? void 0 : `bytes=${String(range.offset)}-${String(range.offset + range.length - 1)}`;
1681
+ if (rangeText !== void 0) headers["range"] = rangeText;
1682
+ const signed = this.signFor(target);
1683
+ if (signed !== void 0 && "error" in signed) return {
1684
+ value: { fetched: await skip(signed.error) },
1685
+ delayMs: 0
1686
+ };
1687
+ if (signed !== void 0) Object.assign(headers, signed.headers);
1688
+ const signedFacts = signed?.facts ?? {};
1641
1689
  const started = this.clock.now();
1642
1690
  const timeout = AbortSignal.timeout(policy.requestTimeoutMs);
1643
1691
  const abort = AbortSignal.any([timeout, signal]);
1644
1692
  const cancelled = () => request_.signal?.aborted === true;
1645
- const failedNote = (prefix, error) => timeout.aborted ? `${prefix}: timed out after ${String(Math.round(policy.requestTimeoutMs / 1e3))} s` : `${prefix}: ${messageOf$2(error)}`;
1693
+ const failedNote = (prefix, error) => timeout.aborted ? `${prefix}: timed out after ${String(Math.round(policy.requestTimeoutMs / 1e3))} s` : `${prefix}: ${messageOf$1(error)}`;
1646
1694
  this.emit({
1647
1695
  type: "request",
1648
1696
  url,
@@ -1692,7 +1740,9 @@ var Crawler = class {
1692
1740
  outcome: "error",
1693
1741
  note,
1694
1742
  elapsed_ms: elapsedMs,
1695
- ...check ? { robots_check: check } : {}
1743
+ ...check ? { robots_check: check } : {},
1744
+ ...rangeText !== void 0 ? { range: rangeText } : {},
1745
+ ...signedFacts
1696
1746
  }, url, marks);
1697
1747
  return {
1698
1748
  value: { fetched: {
@@ -1746,7 +1796,7 @@ var Crawler = class {
1746
1796
  blob = (await this.options.blobs.put(body)).hash;
1747
1797
  } catch (error) {
1748
1798
  outcome = "error";
1749
- note = `blob store: ${messageOf$2(error)}`;
1799
+ note = `blob store: ${messageOf$1(error)}`;
1750
1800
  body = void 0;
1751
1801
  result = "neutral";
1752
1802
  }
@@ -1775,7 +1825,16 @@ var Crawler = class {
1775
1825
  }
1776
1826
  }
1777
1827
  const revisit = outcome === "not-modified" && usable !== void 0;
1778
- let signals = readSignals(robotsLines, header, pageSignals(body, header("content-type")));
1828
+ const prefix = request_.originalHeaderPrefix?.toLowerCase();
1829
+ const archivedLines = prefix === void 0 ? [] : SIGNAL_HEADERS.flatMap((name) => {
1830
+ const value = header(`${prefix}${name}`);
1831
+ return value === void 0 ? [] : [`${prefix}${name}: ${value}`];
1832
+ });
1833
+ let signals = readSignals([
1834
+ ...robotsLines,
1835
+ ...request_.signalLines ?? [],
1836
+ ...archivedLines
1837
+ ], header, pageSignals(body, header("content-type")));
1779
1838
  if (revisit) {
1780
1839
  const merged = signals.ai_input === "no" || usable.ai_input === "no" ? "no" : signals.ai_input !== "unset" ? signals.ai_input : usable.ai_input;
1781
1840
  signals = {
@@ -1824,6 +1883,7 @@ var Crawler = class {
1824
1883
  blob: blob ?? null,
1825
1884
  etag,
1826
1885
  last_modified: lastModified,
1886
+ ...validators.length > 0 ? { validators } : {},
1827
1887
  ...revisit ? { revisit_of: usable.capture } : {},
1828
1888
  robots: request_.mode === "page" ? "allowed" : "not-checked",
1829
1889
  ...check !== void 0 ? { robots_check: check } : {},
@@ -1835,10 +1895,12 @@ var Crawler = class {
1835
1895
  ...freshness !== null ? { fresh_until: freshness } : {},
1836
1896
  ...varyKey !== null ? { vary_key: varyKey } : {},
1837
1897
  ...location !== void 0 ? { location } : {},
1838
- ...note !== void 0 ? { note } : {}
1898
+ ...note !== void 0 ? { note } : {},
1899
+ ...rangeText !== void 0 ? { range: rangeText } : {},
1900
+ ...signedFacts
1839
1901
  }, url, marks);
1840
1902
  const fetchedIso = new Date(fetchedAt).toISOString();
1841
- if (capture !== void 0 && (outcome === "ok" || outcome === "not-modified") && blob !== void 0 && !cut && !unasked) this.store({
1903
+ if (capture !== void 0 && (outcome === "ok" || outcome === "not-modified") && blob !== void 0 && !cut && !unasked && range === void 0) this.store({
1842
1904
  url,
1843
1905
  capture,
1844
1906
  blob,
@@ -1866,6 +1928,14 @@ var Crawler = class {
1866
1928
  fresh_until: freshness,
1867
1929
  vary_key: varyKey
1868
1930
  }, next);
1931
+ const answer = validators.length === 0 || capture === void 0 || robotsFile ? void 0 : outcome === "not-modified" ? "not-modified" : outcome === "ok" && blob !== void 0 && !cut ? blob === usable?.blob ? "same" : "changed" : void 0;
1932
+ if (answer !== void 0) try {
1933
+ this.scheduler.store.noteValidators?.(host, {
1934
+ etag: headers["if-none-match"] !== void 0,
1935
+ lastModified: headers["if-modified-since"] !== void 0,
1936
+ answer
1937
+ }, fetchedAt);
1938
+ } catch {}
1869
1939
  const delayMs = this.settle(host, policy, {
1870
1940
  outcome,
1871
1941
  status: response.statusCode,
@@ -1896,9 +1966,10 @@ var Crawler = class {
1896
1966
  },
1897
1967
  delayMs
1898
1968
  };
1899
- }, signal);
1969
+ }, this.paced(signal, request_.deadline));
1900
1970
  } catch (error) {
1901
1971
  if (error instanceof HostStopped) return { fetched: await skip(stopNote(error.reason, error.until)) };
1972
+ if (error instanceof DeadlineReached) return { fetched: await skip(deadlineNote(error, request_.deadline)) };
1902
1973
  if (this.closed) return { fetched: closedResult(url) };
1903
1974
  if (request_.signal?.aborted === true) return { fetched: await skip("cancelled by the caller") };
1904
1975
  throw error;
@@ -1993,7 +2064,7 @@ var Crawler = class {
1993
2064
  return this.track(this.renderOnce(request_).catch((error) => this.closed ? closedResult(request_.url) : {
1994
2065
  ...closedResult(request_.url),
1995
2066
  outcome: "error",
1996
- note: `crawler error: ${messageOf$2(error)}`
2067
+ note: `crawler error: ${messageOf$1(error)}`
1997
2068
  }));
1998
2069
  }
1999
2070
  async renderOnce(request_) {
@@ -2043,6 +2114,20 @@ var Crawler = class {
2043
2114
  hosts: /* @__PURE__ */ new Set()
2044
2115
  };
2045
2116
  const allowRequest = this.allowRequestFor(target, verdict.robots, tokens, signal, tally);
2117
+ const sign = this.signer === void 0 ? void 0 : (raw) => {
2118
+ let to;
2119
+ try {
2120
+ to = new URL(raw);
2121
+ } catch {
2122
+ return;
2123
+ }
2124
+ const signed = this.signFor(to);
2125
+ return signed === void 0 || "error" in signed ? void 0 : signed.headers;
2126
+ };
2127
+ const signedFacts = this.signer === void 0 ? {} : { signed: {
2128
+ key_id: this.signer.keyId,
2129
+ agent: this.signer.signatureAgent
2130
+ } };
2046
2131
  const rest = (elapsedMs) => ({
2047
2132
  kind: "render",
2048
2133
  elapsedMs,
@@ -2058,6 +2143,7 @@ var Crawler = class {
2058
2143
  let done;
2059
2144
  try {
2060
2145
  done = await this.scheduler.run(host, async () => {
2146
+ this.beforeDeadline(request_.deadline);
2061
2147
  const started = this.clock.now();
2062
2148
  this.emit({
2063
2149
  type: "request",
@@ -2072,7 +2158,8 @@ var Crawler = class {
2072
2158
  ...this.options.from !== void 0 ? { headers: { from: this.options.from } } : {},
2073
2159
  allowRequest,
2074
2160
  maxRedirects: policy.maxRedirects,
2075
- signal
2161
+ signal,
2162
+ ...sign === void 0 ? {} : { sign }
2076
2163
  }), signal);
2077
2164
  const header = (name) => rendered.headers[name.toLowerCase()];
2078
2165
  const classified = rendered.status === null ? {
@@ -2110,7 +2197,7 @@ var Crawler = class {
2110
2197
  },
2111
2198
  delayMs: restAfter(policy, rest(0)).ms
2112
2199
  };
2113
- const refused = error instanceof RenderRefused ? error : missingBrowser(messageOf$2(error)) ? new RenderRefused("browser", `rendering needs a browser: ${messageOf$2(error)}`) : void 0;
2200
+ const refused = error instanceof RenderRefused ? error : missingBrowser(messageOf$1(error)) ? new RenderRefused("browser", `rendering needs a browser: ${messageOf$1(error)}`) : void 0;
2114
2201
  if (refused !== void 0) {
2115
2202
  if (refused.reason === "browser") this.renderOff = refused.message;
2116
2203
  const delayMs = this.settle(host, policy, {
@@ -2142,15 +2229,16 @@ var Crawler = class {
2142
2229
  return {
2143
2230
  value: {
2144
2231
  kind: "failed",
2145
- note: `render failed: ${messageOf$2(error)}`,
2232
+ note: `render failed: ${messageOf$1(error)}`,
2146
2233
  elapsedMs
2147
2234
  },
2148
2235
  delayMs
2149
2236
  };
2150
2237
  }
2151
- }, signal);
2238
+ }, this.paced(signal, request_.deadline));
2152
2239
  } catch (error) {
2153
2240
  if (error instanceof HostStopped) return skip(stopNote(error.reason, error.until));
2241
+ if (error instanceof DeadlineReached) return skip(deadlineNote(error, request_.deadline));
2154
2242
  if (this.closed) return closedResult(url);
2155
2243
  if (request_.signal?.aborted === true) return skip("cancelled by the caller");
2156
2244
  throw error;
@@ -2188,7 +2276,8 @@ var Crawler = class {
2188
2276
  note: done.note,
2189
2277
  elapsed_ms: done.elapsedMs,
2190
2278
  robots_check: check,
2191
- subrequests
2279
+ subrequests,
2280
+ ...signedFacts
2192
2281
  }, url, marks);
2193
2282
  return {
2194
2283
  ...closedResult(url),
@@ -2221,7 +2310,8 @@ var Crawler = class {
2221
2310
  note,
2222
2311
  elapsed_ms: done.elapsedMs,
2223
2312
  robots_check: check,
2224
- subrequests
2313
+ subrequests,
2314
+ ...signedFacts
2225
2315
  }, url, marks),
2226
2316
  blob: void 0,
2227
2317
  body: void 0,
@@ -2265,7 +2355,7 @@ var Crawler = class {
2265
2355
  blob = (await this.options.blobs.put(body)).hash;
2266
2356
  } catch (error) {
2267
2357
  outcome = "error";
2268
- note = `blob store: ${messageOf$2(error)}`;
2358
+ note = `blob store: ${messageOf$1(error)}`;
2269
2359
  body = void 0;
2270
2360
  }
2271
2361
  }
@@ -2296,7 +2386,8 @@ var Crawler = class {
2296
2386
  signals,
2297
2387
  subrequests,
2298
2388
  ...freshness !== null ? { fresh_until: freshness } : {},
2299
- ...note !== void 0 ? { note } : {}
2389
+ ...note !== void 0 ? { note } : {},
2390
+ ...signedFacts
2300
2391
  }, url, marks);
2301
2392
  const fetchedIso = new Date(fetchedAt).toISOString();
2302
2393
  if (capture !== void 0 && blob !== void 0) this.store({
@@ -2341,6 +2432,17 @@ var Crawler = class {
2341
2432
  }
2342
2433
  }
2343
2434
  /**
2435
+ * This method gives the stop of the host of a URL, or undefined when the host is not stopped. A
2436
+ * watch uses it, so a page of a stopped host is due again at the end of the stop.
2437
+ */
2438
+ hostStop(url) {
2439
+ try {
2440
+ return this.scheduler.store.stopped(hostKey(new URL(url)), this.clock.now());
2441
+ } catch {
2442
+ return;
2443
+ }
2444
+ }
2445
+ /**
2344
2446
  * This method closes the crawler. Work in the queue ends as `skipped` with the note
2345
2447
  * `crawler closed`, and the crawler writes no capture after the close. Then it closes the
2346
2448
  * browser, the log and its own dispatcher. A second close waits for the first.
@@ -2593,6 +2695,7 @@ const parseQuery = (q) => {
2593
2695
  return {
2594
2696
  all: parts.map((part) => part.text).join(" AND "),
2595
2697
  any: any.map((part) => part.text).join(" OR "),
2698
+ anyParts: any.map((part) => part.text),
2596
2699
  terms: any.map((part) => part.term),
2597
2700
  parts: parts.length
2598
2701
  };
@@ -2635,1179 +2738,93 @@ const snippetOf = (text, terms, width = 160) => {
2635
2738
  const clean = (part) => part.replace(/\s+/g, " ");
2636
2739
  return `${from > 0 ? "…" : ""}${clean(text.slice(from, start))}[${clean(text.slice(start, end))}]${clean(text.slice(end, to))}${to < text.length ? "…" : ""}`;
2637
2740
  };
2638
- /** The search index reads this many characters of each text, at most. */
2639
- const MAX_INDEX_CHARS = 2e5;
2640
- const namesOf = (generation) => ({
2641
- docs: `docs_${String(generation)}`,
2642
- fts: `fts_${String(generation)}`,
2643
- versions: `versions_${String(generation)}`,
2644
- texts: `texts_${String(generation)}`,
2645
- latest: `latest_${String(generation)}`
2646
- });
2647
- const GENERATION_TABLE = /^(docs|fts|versions|texts|latest)_(\d+)$/;
2648
- /** A build that started this long ago and did not finish belongs to a process that stopped. */
2649
- const ABANDONED_MS = 864e5;
2650
- /** The FTS5 tokenizer: folded text, English stems, and spacing marks kept in the words. */
2651
- const TOKENIZER = `tokenize = "porter unicode61 remove_diacritics 0 categories 'L* N* Co Mc'"`;
2652
- const ddlOf = (n) => `
2653
- CREATE TABLE IF NOT EXISTS ${n.texts} (
2654
- sha TEXT PRIMARY KEY,
2655
- body TEXT NOT NULL
2656
- );
2657
- CREATE TABLE IF NOT EXISTS ${n.versions} (
2658
- doc TEXT NOT NULL,
2659
- capture TEXT NOT NULL,
2660
- recipe TEXT NOT NULL,
2661
- rendered INTEGER NOT NULL,
2662
- fetched_at TEXT NOT NULL,
2663
- blob TEXT,
2664
- kind TEXT NOT NULL,
2665
- title TEXT NOT NULL,
2666
- url TEXT,
2667
- meta_sha TEXT NOT NULL,
2668
- text_sha TEXT NOT NULL,
2669
- text_chars INTEGER NOT NULL,
2670
- full_sha TEXT,
2671
- ai_input TEXT NOT NULL,
2672
- merge TEXT NOT NULL,
2673
- scope TEXT,
2674
- labels TEXT,
2675
- PRIMARY KEY (doc, capture)
2676
- );
2677
- CREATE INDEX IF NOT EXISTS ${n.versions}_time ON ${n.versions} (doc, recipe, rendered, fetched_at);
2678
- CREATE INDEX IF NOT EXISTS ${n.versions}_capture ON ${n.versions} (capture);
2679
- CREATE INDEX IF NOT EXISTS ${n.versions}_fetched ON ${n.versions} (fetched_at);
2680
- CREATE INDEX IF NOT EXISTS ${n.versions}_recipe ON ${n.versions} (recipe);
2681
- CREATE TABLE IF NOT EXISTS ${n.docs} (
2682
- doc TEXT PRIMARY KEY,
2683
- kind TEXT NOT NULL,
2684
- title TEXT NOT NULL,
2685
- url TEXT,
2686
- meta TEXT NOT NULL,
2687
- text_sha TEXT NOT NULL,
2688
- full_sha TEXT,
2689
- captures TEXT NOT NULL,
2690
- capture_count INTEGER NOT NULL,
2691
- capture TEXT NOT NULL,
2692
- blob TEXT,
2693
- fetched_at TEXT NOT NULL,
2694
- recipe TEXT NOT NULL,
2695
- rendered INTEGER NOT NULL,
2696
- ai_input TEXT NOT NULL,
2697
- scope TEXT,
2698
- labels TEXT,
2699
- updated_at TEXT NOT NULL
2700
- );
2701
- CREATE VIRTUAL TABLE IF NOT EXISTS ${n.fts} USING fts5(
2702
- title, text, content = '', contentless_delete = 1, ${TOKENIZER}
2703
- );
2704
- CREATE TABLE IF NOT EXISTS ${n.latest} (
2705
- url TEXT NOT NULL,
2706
- variant TEXT NOT NULL,
2707
- capture TEXT NOT NULL,
2708
- blob TEXT,
2709
- etag TEXT,
2710
- last_modified TEXT,
2711
- content_type TEXT,
2712
- fetched_at TEXT NOT NULL,
2713
- status INTEGER NOT NULL,
2714
- ai_input TEXT NOT NULL,
2715
- location TEXT,
2716
- truncated INTEGER,
2717
- fresh_until TEXT,
2718
- vary_key TEXT,
2719
- PRIMARY KEY (url, variant)
2720
- );
2721
- CREATE INDEX IF NOT EXISTS ${n.latest}_fetched ON ${n.latest} (fetched_at);
2722
- `;
2723
- const RENDERED_MARK$1 = "#rendered";
2724
- /** 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. */
2725
- const keyOf = (url, variant) => {
2726
- if (url.endsWith(RENDERED_MARK$1)) return {
2727
- url: cacheKeyOf(url.slice(0, -9)),
2728
- variant: "rendered"
2729
- };
2730
- return {
2731
- url: cacheKeyOf(url),
2732
- variant: variant ?? "plain"
2733
- };
2741
+ //#endregion
2742
+ //#region src/pdf.ts
2743
+ const MAX_PAGES = 500;
2744
+ const MAX_CHARS = 2e5;
2745
+ const TIME_BUDGET_MS = 2e4;
2746
+ /**
2747
+ * The text streams of 35 stored menus and papers inflate to 0.6 MB or less. The parser of pdf.js
2748
+ * reads a page with no pause. Thus a page of 8 MB of text operators stops the process for
2749
+ * approximately one second.
2750
+ */
2751
+ const MAX_INFLATED_BYTES$1 = 8388608;
2752
+ /** This function finds a PDF from its first bytes or its content type. Some servers put bytes before `%PDF-`. */
2753
+ const isPdf = (body, contentType) => {
2754
+ if (/\bpdf\b/i.test(contentType ?? "")) return true;
2755
+ const end = Math.min(body.length - 4, 1024);
2756
+ for (let index = 0; index < end; index += 1) if (body[index] === 37 && body[index + 1] === 80 && body[index + 2] === 68 && body[index + 3] === 70 && body[index + 4] === 45) return true;
2757
+ return false;
2734
2758
  };
2735
- const sha = (text) => createHash("sha256").update(text, "utf8").digest("hex");
2736
- const parseJson = (text, fallback) => {
2737
- if (text === null || text === void 0) return fallback;
2738
- try {
2739
- return JSON.parse(text);
2740
- } catch {
2741
- return fallback;
2742
- }
2759
+ /** This function gives one line of text for each line of the PDF. It removes the empty lines. */
2760
+ const tidy = (page) => page.split(/\r?\n/).map((line) => line.replace(/\s+/g, " ").trim()).filter((line) => line.length > 0).join("\n");
2761
+ /** A word processor writes the name of its file as the title ("Microsoft Word - menu-final-v3.docx"). */
2762
+ const JUNK_TITLE = /^(?:microsoft (?:word|powerpoint|excel) - |untitled\b|document\d*$|presentation\d*$)|\.(?:docx?|pdf|pptx?|xlsx?|indd|ai|psd|rtf|odt|pages|txt)$/i;
2763
+ const latin1 = (bytes, start, end) => {
2764
+ let out = "";
2765
+ for (let index = Math.max(0, start); index < end && index < bytes.length; index += 1) out += String.fromCharCode(bytes[index]);
2766
+ return out;
2743
2767
  };
2744
- const toLatest = (row) => ({
2745
- url: row.url,
2746
- capture: row.capture,
2747
- blob: row.blob,
2748
- etag: row.etag,
2749
- last_modified: row.last_modified,
2750
- content_type: row.content_type,
2751
- fetched_at: row.fetched_at,
2752
- status: row.status,
2753
- ai_input: row.ai_input,
2754
- location: row.location,
2755
- truncated: row.truncated === 1 ? true : void 0,
2756
- fresh_until: row.fresh_until,
2757
- vary_key: row.vary_key,
2758
- variant: row.variant
2759
- });
2760
- const toVersion = (row) => ({
2761
- doc: row.doc,
2762
- capture: row.capture,
2763
- recipe: row.recipe,
2764
- rendered: row.rendered === 1,
2765
- fetched_at: row.fetched_at,
2766
- blob: row.blob,
2767
- kind: row.kind,
2768
- title: row.title,
2769
- url: row.url,
2770
- ai_input: row.ai_input,
2771
- chars: row.text_chars,
2772
- cut: row.full_sha !== null
2773
- });
2774
- const messageOf$1 = (error) => error instanceof Error ? error.message : String(error);
2775
- var DocIndex = class {
2776
- path;
2777
- db;
2778
- statements = /* @__PURE__ */ new Map();
2779
- depth = 0;
2780
- closed = false;
2781
- empty;
2782
- /**
2783
- * This value is true when the file had a different schema, or no tables, so the index opened
2784
- * empty. A store with captures then needs `reindex`. The commit of a rebuild makes it false.
2785
- */
2786
- get stale() {
2787
- return this.empty;
2788
- }
2789
- constructor(path, options = {}) {
2790
- this.path = path;
2791
- if (path !== ":memory:") mkdirSync(dirname(path), {
2792
- recursive: true,
2793
- mode: 448
2794
- });
2795
- this.db = new DatabaseSync(path);
2796
- const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
2797
- this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
2798
- try {
2799
- this.db.exec("CREATE VIRTUAL TABLE temp.polite_fts5_probe USING fts5(x, content = '', contentless_delete = 1); DROP TABLE temp.polite_fts5_probe;");
2800
- } catch (cause) {
2801
- this.db.close();
2802
- 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 });
2803
- }
2804
- if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
2805
- this.db.exec("PRAGMA synchronous = NORMAL;");
2806
- this.empty = this.write(() => this.prepareSchema());
2807
- }
2808
- /** This method makes the tables of the schema. It gives true when the index opened empty. */
2809
- prepareSchema() {
2810
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
2811
- const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
2812
- if (version === 2 && tables.some((table) => table.name === "index_meta")) {
2813
- this.dropAbandoned();
2814
- return false;
2815
- }
2816
- const virtual = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'").all();
2817
- for (const { name } of virtual) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
2818
- const rest = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
2819
- for (const { name } of rest) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
2820
- this.db.exec("CREATE TABLE index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
2821
- this.db.exec(ddlOf(namesOf(1)));
2822
- this.statements.clear();
2823
- this.setMeta("generation", "1");
2824
- this.setMeta("next_generation", "2");
2825
- this.db.exec(`PRAGMA user_version = ${String(2)}`);
2826
- return true;
2827
- }
2828
- setMeta(key, value) {
2829
- this.stmt("INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
2830
- }
2831
- getMeta(key) {
2832
- return this.stmt("SELECT value FROM index_meta WHERE key = ?").get(key)?.value;
2833
- }
2834
- stmt(sql) {
2835
- let statement = this.statements.get(sql);
2836
- if (statement === void 0) {
2837
- statement = this.db.prepare(sql);
2838
- this.statements.set(sql, statement);
2839
- }
2840
- return statement;
2841
- }
2842
- /**
2843
- * This method does a write in one transaction. A transaction in progress takes the write in,
2844
- * also a transaction that the caller opened on the connection.
2845
- */
2846
- write(work) {
2847
- if (this.closed) throw new Error("the index is closed");
2848
- if (this.depth > 0 || this.db.isTransaction) {
2849
- this.depth += 1;
2850
- try {
2851
- return work();
2852
- } finally {
2853
- this.depth -= 1;
2854
- }
2855
- }
2856
- this.db.exec("BEGIN IMMEDIATE");
2857
- this.depth = 1;
2858
- try {
2859
- const result = work();
2860
- this.db.exec("COMMIT");
2861
- return result;
2862
- } catch (error) {
2863
- if (this.db.isTransaction) this.db.exec("ROLLBACK");
2864
- throw error;
2865
- } finally {
2866
- this.depth = 0;
2768
+ const indexOf = (bytes, needle, from) => {
2769
+ const first = needle.charCodeAt(0);
2770
+ for (let index = bytes.indexOf(first, from); index !== -1; index = bytes.indexOf(first, index + 1)) {
2771
+ let match = true;
2772
+ for (let at = 1; at < needle.length; at += 1) if (bytes[index + at] !== needle.charCodeAt(at)) {
2773
+ match = false;
2774
+ break;
2867
2775
  }
2776
+ if (match) return index;
2868
2777
  }
2869
- generation() {
2870
- return Number(this.getMeta("generation") ?? "1");
2871
- }
2872
- /**
2873
- * This method does a step on the tables of the current generation. A rebuild of a different
2874
- * process can remove those tables in the meantime. Then the method reads the generation again
2875
- * and does the step once more.
2876
- */
2877
- live(step) {
2878
- if (this.closed) throw new Error("the index is closed");
2778
+ return -1;
2779
+ };
2780
+ /**
2781
+ * This function adds up the bytes that the compressed streams of a file inflate to, and stops at
2782
+ * the limit. It tries each stream: a stream in another format fails at its first bytes. An image
2783
+ * stream does not count, because the text reader does not decode it. The function gives true when
2784
+ * the file stays inside the limit.
2785
+ */
2786
+ const inflatesWithin = (bytes, limit) => {
2787
+ let total = 0;
2788
+ let from = 0;
2789
+ for (;;) {
2790
+ const keyword = indexOf(bytes, "stream", from);
2791
+ if (keyword === -1) return true;
2792
+ from = keyword + 6;
2793
+ if (latin1(bytes, keyword - 3, keyword) === "end") continue;
2794
+ const next = bytes[keyword + 6];
2795
+ if (next !== 10 && next !== 13) continue;
2796
+ const dictionary = latin1(bytes, keyword - 1024, keyword);
2797
+ const head = dictionary.slice(Math.max(0, dictionary.lastIndexOf("<<")));
2798
+ if (/\/Subtype\s*\/Image\b/.test(head)) continue;
2799
+ let start = keyword + 7;
2800
+ if (next === 13 && bytes[start] === 10) start += 1;
2801
+ const declared = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(head);
2802
+ const stop = indexOf(bytes, "endstream", start);
2803
+ const end = declared !== null && start + Number(declared[1]) <= bytes.length ? start + Number(declared[1]) : stop === -1 ? bytes.length : stop;
2879
2804
  try {
2880
- return step(namesOf(this.generation()));
2805
+ total += inflateSync(bytes.subarray(start, end), {
2806
+ maxOutputLength: Math.max(1, limit - total + 1),
2807
+ finishFlush: constants.Z_SYNC_FLUSH
2808
+ }).length;
2881
2809
  } catch (error) {
2882
- if (!/no such table/i.test(messageOf$1(error)) || this.depth > 0) throw error;
2883
- this.statements.clear();
2884
- return step(namesOf(this.generation()));
2885
- }
2886
- }
2887
- /** This method removes the tables of a generation. */
2888
- dropGeneration(generation) {
2889
- const n = namesOf(generation);
2890
- for (const table of [
2891
- n.fts,
2892
- n.docs,
2893
- n.versions,
2894
- n.texts,
2895
- n.latest
2896
- ]) this.db.exec(`DROP TABLE IF EXISTS ${table}`);
2897
- this.db.prepare("DELETE FROM index_meta WHERE key = ?").run(`building:${String(generation)}`);
2898
- this.statements.clear();
2899
- }
2900
- /** This method removes the generations of builds that a stopped process left. */
2901
- dropAbandoned() {
2902
- const current = this.generation();
2903
- const generations = /* @__PURE__ */ new Set();
2904
- for (const { name } of this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()) {
2905
- const match = GENERATION_TABLE.exec(name);
2906
- if (match !== null) generations.add(Number(match[2]));
2907
- }
2908
- for (const generation of generations) {
2909
- if (generation === current) continue;
2910
- const started = this.getMeta(`building:${String(generation)}`);
2911
- if (!((started === void 0 ? Number.POSITIVE_INFINITY : Date.now() - Date.parse(started)) < ABANDONED_MS)) this.dropGeneration(generation);
2912
- }
2913
- }
2914
- putText(n, body) {
2915
- const key = sha(body);
2916
- this.stmt(`INSERT OR IGNORE INTO ${n.texts} (sha, body) VALUES (?, ?)`).run(key, body);
2917
- return key;
2918
- }
2919
- textOf(n, key) {
2920
- if (key === null) return void 0;
2921
- return this.stmt(`SELECT body FROM ${n.texts} WHERE sha = ?`).get(key)?.body;
2922
- }
2923
- putVersion(n, input, now) {
2924
- const full = input.full_text !== void 0 && input.full_text !== input.text ? this.putText(n, input.full_text) : null;
2925
- this.stmt(`INSERT INTO ${n.versions} (doc, capture, recipe, rendered, fetched_at, blob, kind, title, url,
2926
- meta_sha, text_sha, text_chars, full_sha, ai_input, merge, scope, labels)
2927
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2928
- ON CONFLICT(doc, capture) DO UPDATE SET recipe = excluded.recipe, rendered = excluded.rendered,
2929
- fetched_at = excluded.fetched_at, blob = excluded.blob, kind = excluded.kind, title = excluded.title,
2930
- url = excluded.url, meta_sha = excluded.meta_sha, text_sha = excluded.text_sha,
2931
- text_chars = excluded.text_chars, full_sha = excluded.full_sha, ai_input = excluded.ai_input,
2932
- 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));
2933
- }
2934
- newest(n, doc, recipe, rendered) {
2935
- return this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = ?
2936
- ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered);
2937
- }
2938
- /** This method chooses the current version of a document from its versions, and writes it with its search entry. */
2939
- recompute(n, doc, now) {
2940
- const groups = this.stmt(`SELECT DISTINCT recipe, rendered FROM ${n.versions} WHERE doc = ?`).all(doc);
2941
- const before = this.stmt(`SELECT rowid, text_sha, title FROM ${n.docs} WHERE doc = ?`).get(doc);
2942
- if (groups.length === 0) {
2943
- if (before !== void 0) {
2944
- this.stmt(`DELETE FROM ${n.fts} WHERE rowid = ?`).run(before.rowid);
2945
- this.stmt(`DELETE FROM ${n.docs} WHERE doc = ?`).run(doc);
2946
- }
2947
- return;
2948
- }
2949
- const byRecipe = /* @__PURE__ */ new Map();
2950
- for (const group of groups) {
2951
- const row = this.newest(n, doc, group.recipe, group.rendered);
2952
- if (row === void 0) continue;
2953
- const entry = byRecipe.get(group.recipe) ?? {};
2954
- if (group.rendered === 1) entry.rendered = row;
2955
- else entry.plain = row;
2956
- byRecipe.set(group.recipe, entry);
2957
- }
2958
- const candidates = [];
2959
- for (const [recipe, { plain, rendered }] of byRecipe) {
2960
- let pick;
2961
- if (rendered === void 0) pick = plain;
2962
- else if (plain === void 0 || rendered.fetched_at >= plain.fetched_at) pick = rendered;
2963
- else {
2964
- const base = this.stmt(`SELECT blob FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = 0 AND fetched_at <= ?
2965
- ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered.fetched_at);
2966
- pick = base !== void 0 && base.blob !== null && base.blob === plain.blob ? rendered : plain;
2967
- }
2968
- const newest = plain === void 0 ? rendered : rendered === void 0 || plain.fetched_at > rendered.fetched_at ? plain : rendered;
2969
- const others = [plain, rendered].filter((row) => row !== void 0 && row !== pick);
2970
- candidates.push({
2971
- row: pick,
2972
- ai: newest.ai_input,
2973
- others
2974
- });
2975
- }
2976
- let winner = candidates[0];
2977
- if (candidates.length > 1) {
2978
- const latest = candidates.every((candidate) => candidate.row.merge === "latest");
2979
- for (const candidate of candidates.slice(1)) {
2980
- const a = candidate.row;
2981
- const b = winner.row;
2982
- 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;
2983
- }
2984
- }
2985
- const row = winner.row;
2986
- const aiInput = row.ai_input === "no" || candidates.some((candidate) => candidate.ai === "no") ? "no" : winner.ai !== "unset" ? winner.ai : candidates.some((candidate) => candidate.ai === "yes") ? "yes" : "unset";
2987
- let meta = parseJson(this.textOf(n, row.meta_sha), {});
2988
- for (const candidate of candidates) if (candidate !== winner) meta = {
2989
- ...parseJson(this.textOf(n, candidate.row.meta_sha), {}),
2990
- ...meta
2991
- };
2992
- const same = this.stmt(`SELECT capture FROM ${n.versions} WHERE doc = ? AND text_sha = ?
2993
- ORDER BY fetched_at DESC, capture DESC LIMIT ?`).all(doc, row.text_sha, 10);
2994
- const captures = [.../* @__PURE__ */ new Set([
2995
- row.capture,
2996
- ...same.map((entry) => entry.capture),
2997
- ...winner.others.map((other) => other.capture),
2998
- ...candidates.filter((candidate) => candidate !== winner).map((candidate) => candidate.row.capture)
2999
- ])].slice(0, 10);
3000
- const count = this.stmt(`SELECT count(*) AS n FROM ${n.versions} WHERE doc = ?`).get(doc).n;
3001
- const url = row.url ?? candidates.map((candidate) => candidate.row.url).find((value) => value !== null) ?? null;
3002
- const saved = this.stmt(`INSERT INTO ${n.docs} (doc, kind, title, url, meta, text_sha, full_sha, captures, capture_count, capture,
3003
- blob, fetched_at, recipe, rendered, ai_input, scope, labels, updated_at)
3004
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3005
- ON CONFLICT(doc) DO UPDATE SET kind = excluded.kind, title = excluded.title, url = excluded.url,
3006
- meta = excluded.meta, text_sha = excluded.text_sha, full_sha = excluded.full_sha,
3007
- captures = excluded.captures, capture_count = excluded.capture_count, capture = excluded.capture,
3008
- blob = excluded.blob, fetched_at = excluded.fetched_at, recipe = excluded.recipe,
3009
- rendered = excluded.rendered, ai_input = excluded.ai_input, scope = excluded.scope,
3010
- labels = excluded.labels, updated_at = excluded.updated_at
3011
- 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);
3012
- const text = this.textOf(n, row.text_sha) ?? "";
3013
- if (before === void 0 || before.text_sha !== row.text_sha || before.title !== row.title) {
3014
- if (before !== void 0) this.stmt(`DELETE FROM ${n.fts} WHERE rowid = ?`).run(before.rowid);
3015
- 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"));
2810
+ if (error instanceof RangeError) return false;
3016
2811
  }
3017
- return this.docOf(n, doc, text);
3018
- }
3019
- docOf(n, doc, knownText) {
3020
- const row = this.stmt(`SELECT rowid, * FROM ${n.docs} WHERE doc = ?`).get(doc);
3021
- if (row === void 0) return void 0;
3022
- return this.toDoc(n, row, knownText);
3023
- }
3024
- toDoc(n, row, knownText) {
3025
- const labels = parseJson(row.labels, void 0);
3026
- return {
3027
- doc: row.doc,
3028
- kind: row.kind,
3029
- title: row.title,
3030
- text: knownText ?? this.textOf(n, row.text_sha) ?? "",
3031
- url: row.url,
3032
- meta: parseJson(row.meta, {}),
3033
- captures: parseJson(row.captures, []),
3034
- capture_count: row.capture_count,
3035
- capture: row.capture,
3036
- blob: row.blob,
3037
- fetched_at: row.fetched_at,
3038
- recipe: row.recipe,
3039
- rendered: row.rendered === 1,
3040
- ai_input: row.ai_input,
3041
- cut: row.full_sha !== null,
3042
- ...row.scope !== null ? { scope: row.scope } : {},
3043
- ...labels !== void 0 ? { labels } : {},
3044
- updated_at: row.updated_at
3045
- };
3046
- }
3047
- versionTextOf(n, row) {
3048
- return {
3049
- ...toVersion(row),
3050
- text: this.textOf(n, row.text_sha) ?? "",
3051
- full_text: this.textOf(n, row.full_sha),
3052
- meta: parseJson(this.textOf(n, row.meta_sha), {})
3053
- };
2812
+ if (total > limit) return false;
2813
+ from = Math.max(from, end);
3054
2814
  }
3055
- get(doc) {
3056
- return this.live((n) => this.docOf(n, doc));
3057
- }
3058
- /**
3059
- * This method adds one version of a document, and it gives the current document. The rules of
3060
- * the class choose the current version. An upsert of a known capture replaces that version.
3061
- */
3062
- upsert(input, now) {
3063
- return this.live((n) => this.write(() => {
3064
- this.putVersion(n, input, now);
3065
- return this.recompute(n, input.doc, now);
3066
- }));
3067
- }
3068
- /** This method gives the versions of a document, the newest first. */
3069
- versions(doc, options = {}) {
3070
- const limit = Math.max(1, Math.floor(options.limit ?? 100));
3071
- 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));
3072
- }
3073
- /**
3074
- * This method gives the text of one version of a document. With no capture, it gives the current
3075
- * version. It also gives the text of a held-back version: the caller applies `isHeldBack` to the
3076
- * `ai_input` of the version.
3077
- */
3078
- versionText(doc, capture) {
3079
- return this.live((n) => {
3080
- const id = capture ?? this.docOf(n, doc)?.capture;
3081
- if (id === void 0) return void 0;
3082
- const row = this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND capture = ?`).get(doc, id);
3083
- return row === void 0 ? void 0 : this.versionTextOf(n, row);
3084
- });
3085
- }
3086
- /**
3087
- * This method gives the texts of the earlier versions of a document, the newest first. Each
3088
- * text comes one time, with the newest version that has it. It skips the current text. It also
3089
- * gives held-back texts: the caller applies `isHeldBack` to the `ai_input` of each version.
3090
- */
3091
- earlierTexts(doc, options = {}) {
3092
- const limit = Math.max(1, Math.floor(options.limit ?? 50));
3093
- return this.live((n) => {
3094
- const head = this.stmt(`SELECT text_sha FROM ${n.docs} WHERE doc = ?`).get(doc);
3095
- if (head === void 0) return [];
3096
- 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);
3097
- const seen = /* @__PURE__ */ new Set();
3098
- const out = [];
3099
- for (const row of rows) {
3100
- if (seen.has(row.text_sha)) continue;
3101
- seen.add(row.text_sha);
3102
- out.push(this.versionTextOf(n, row));
3103
- if (out.length >= limit) break;
3104
- }
3105
- return out;
3106
- });
3107
- }
3108
- /** This method tells if a capture gave a version of a document. */
3109
- hasVersion(capture) {
3110
- return this.live((n) => this.stmt(`SELECT 1 AS found FROM ${n.versions} WHERE capture = ? LIMIT 1`).get(capture) !== void 0);
3111
- }
3112
- /** This method gives the newest fetch time of the cache entries, or undefined for an empty index. */
3113
- newestFetch() {
3114
- return this.live((n) => this.stmt(`SELECT max(fetched_at) AS newest FROM ${n.latest}`).get()?.newest ?? void 0);
3115
- }
3116
- /** This method gives the current documents that a capture gave. */
3117
- docsOfCapture(capture) {
3118
- 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));
3119
- }
3120
- /**
3121
- * This method searches the documents with BM25, the best first. The syntax of the query is the
3122
- * syntax of `parseQuery`. It first asks for each part. When that gives too few documents, it
3123
- * adds the documents that match one part. A document that asked not to be AI input is not in
3124
- * the results, so a search cannot show its text.
3125
- */
3126
- find(q, options) {
3127
- const query = parseQuery(q);
3128
- if (query === void 0) return [];
3129
- const limit = Math.max(1, Math.min(1e3, Math.floor(options.limit) || 10));
3130
- return this.live((n) => {
3131
- const kindClause = options.kind === void 0 || options.kind === null ? "" : "AND d.kind = ?";
3132
- const sql = `SELECT d.rowid AS rowid, d.*, bm25(${n.fts}, 3.0, 1.0) AS rank
3133
- FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
3134
- WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' ${kindClause}
3135
- ORDER BY rank LIMIT ?`;
3136
- const run = (match) => {
3137
- const params = [match];
3138
- if (kindClause.length > 0) params.push(options.kind);
3139
- params.push(limit);
3140
- return this.stmt(sql).all(...params);
3141
- };
3142
- const rows = run(query.all);
3143
- if (rows.length < limit && query.parts > 1) {
3144
- const seen = new Set(rows.map((row) => row.doc));
3145
- for (const row of run(query.any)) {
3146
- if (rows.length >= limit) break;
3147
- if (!seen.has(row.doc)) rows.push(row);
3148
- }
3149
- }
3150
- return rows.map((row) => ({
3151
- doc: row.doc,
3152
- kind: row.kind,
3153
- title: row.title,
3154
- url: row.url,
3155
- snippet: snippetOf(this.textOf(n, row.text_sha) ?? "", query.terms),
3156
- score: Math.round(-row.rank * 1e3) / 1e3,
3157
- ai_input: row.ai_input,
3158
- fetched_at: row.fetched_at,
3159
- capture: row.capture
3160
- }));
3161
- });
3162
- }
3163
- /**
3164
- * This method scores a set of documents against a query, with the best first. It leaves out a
3165
- * document that matches no term, and a document that asked not to be AI input. It ranks the
3166
- * results of a source again, because the order of the source is only as good as its search engine.
3167
- */
3168
- rankWithin(q, docs) {
3169
- const query = parseQuery(q);
3170
- if (query === void 0 || docs.length === 0) return [];
3171
- return this.live((n) => this.stmt(`SELECT d.doc AS doc, bm25(${n.fts}, 3.0, 1.0) AS rank
3172
- FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
3173
- WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' AND d.doc IN (SELECT value FROM json_each(?))
3174
- ORDER BY rank`).all(query.any, JSON.stringify(docs)).map((row) => ({
3175
- doc: row.doc,
3176
- score: Math.round(-row.rank * 1e3) / 1e3
3177
- })));
3178
- }
3179
- /** This method gives the cache entry of a URL. The key is canonical, so two spellings of one URL give one entry. */
3180
- latest(url, variant) {
3181
- const key = keyOf(url, variant);
3182
- return this.live((n) => {
3183
- const row = this.stmt(`SELECT * FROM ${n.latest} WHERE url = ? AND variant = ?`).get(key.url, key.variant);
3184
- return row === void 0 ? void 0 : toLatest(row);
3185
- });
3186
- }
3187
- putLatest(n, entry) {
3188
- const key = keyOf(entry.url, entry.variant);
3189
- this.stmt(`INSERT INTO ${n.latest} (url, variant, capture, blob, etag, last_modified, content_type, fetched_at,
3190
- status, ai_input, location, truncated, fresh_until, vary_key)
3191
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3192
- ON CONFLICT(url, variant) DO UPDATE SET capture = excluded.capture, blob = excluded.blob,
3193
- etag = excluded.etag, last_modified = excluded.last_modified, content_type = excluded.content_type,
3194
- fetched_at = excluded.fetched_at, status = excluded.status, ai_input = excluded.ai_input,
3195
- location = excluded.location, truncated = excluded.truncated, fresh_until = excluded.fresh_until,
3196
- vary_key = excluded.vary_key
3197
- 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);
3198
- }
3199
- /** This method keeps the last answer for a URL. An entry that is older than the stored entry changes nothing. */
3200
- setLatest(entry) {
3201
- this.live((n) => this.write(() => this.putLatest(n, entry)));
3202
- }
3203
- counts() {
3204
- return this.live((n) => ({
3205
- docs: this.stmt(`SELECT count(*) AS n FROM ${n.docs}`).get().n,
3206
- urls: this.stmt(`SELECT count(*) AS n FROM ${n.latest}`).get().n
3207
- }));
3208
- }
3209
- /** This method empties the current generation. A rebuild uses `beginRebuild`, which keeps the index until its commit. */
3210
- reset() {
3211
- this.live((n) => this.write(() => {
3212
- for (const table of [
3213
- n.fts,
3214
- n.docs,
3215
- n.versions,
3216
- n.texts,
3217
- n.latest
3218
- ]) this.db.exec(`DELETE FROM ${table}`);
3219
- }));
3220
- }
3221
- /**
3222
- * This method starts a rebuild into a new generation of the tables. The current generation
3223
- * answers each read and takes each write until the commit. A failed rebuild thus leaves the
3224
- * index as it was.
3225
- */
3226
- beginRebuild(options = {}) {
3227
- const batchSize = Math.max(1, Math.floor(options.batch ?? 500));
3228
- const generation = this.write(() => {
3229
- const next = Number(this.getMeta("next_generation") ?? "2");
3230
- this.setMeta("next_generation", String(next + 1));
3231
- this.setMeta(`building:${String(next)}`, (/* @__PURE__ */ new Date()).toISOString());
3232
- this.db.exec(ddlOf(namesOf(next)));
3233
- return next;
3234
- });
3235
- const n = namesOf(generation);
3236
- const versions = [];
3237
- const latest = /* @__PURE__ */ new Map();
3238
- let open = true;
3239
- const keyText = (url, variant) => {
3240
- const key = keyOf(url, variant);
3241
- return `${key.variant} ${key.url}`;
3242
- };
3243
- const flush = () => {
3244
- if (!open) throw new Error("the rebuild is over");
3245
- if (versions.length === 0 && latest.size === 0) return;
3246
- this.write(() => {
3247
- const touched = /* @__PURE__ */ new Map();
3248
- for (const { input, now } of versions) {
3249
- this.putVersion(n, input, now);
3250
- touched.set(input.doc, now);
3251
- }
3252
- for (const entry of latest.values()) this.putLatest(n, entry);
3253
- for (const [doc, now] of touched) this.recompute(n, doc, now);
3254
- });
3255
- versions.length = 0;
3256
- latest.clear();
3257
- };
3258
- /** This function copies the texts of the versions of the current generation that agree with `where`. */
3259
- const copyTexts = (current, where, params) => {
3260
- for (const column of [
3261
- "text_sha",
3262
- "meta_sha",
3263
- "full_sha"
3264
- ]) this.db.prepare(`INSERT OR IGNORE INTO ${n.texts} (sha, body)
3265
- SELECT t.sha, t.body FROM ${current.texts} t
3266
- WHERE t.sha IN (SELECT ${column} FROM ${current.versions} WHERE ${where})`).run(...params);
3267
- };
3268
- return {
3269
- upsert: (input, now) => {
3270
- if (!open) throw new Error("the rebuild is over");
3271
- versions.push({
3272
- input,
3273
- now
3274
- });
3275
- if (versions.length >= batchSize) flush();
3276
- },
3277
- setLatest: (entry) => {
3278
- if (!open) throw new Error("the rebuild is over");
3279
- const key = keyText(entry.url, entry.variant);
3280
- const before = latest.get(key);
3281
- if (before === void 0 || entry.fetched_at >= before.fetched_at) latest.set(key, entry);
3282
- if (latest.size >= batchSize) flush();
3283
- },
3284
- latest: (url, variant) => {
3285
- const waiting = latest.get(keyText(url, variant));
3286
- if (waiting !== void 0) return waiting;
3287
- const key = keyOf(url, variant);
3288
- const row = this.stmt(`SELECT * FROM ${n.latest} WHERE url = ? AND variant = ?`).get(key.url, key.variant);
3289
- return row === void 0 ? void 0 : toLatest(row);
3290
- },
3291
- flush,
3292
- carry: (recipes) => {
3293
- flush();
3294
- const known = JSON.stringify([...recipes]);
3295
- const current = namesOf(this.generation());
3296
- const docs = this.db.prepare(`SELECT DISTINCT doc FROM ${current.versions} WHERE recipe NOT IN (SELECT value FROM json_each(?))`).all(known);
3297
- const where = "recipe NOT IN (SELECT value FROM json_each(?)) AND doc IN (SELECT value FROM json_each(?))";
3298
- for (let at = 0; at < docs.length; at += batchSize) {
3299
- const part = JSON.stringify(docs.slice(at, at + batchSize).map((row) => row.doc));
3300
- this.write(() => {
3301
- copyTexts(current, where, [known, part]);
3302
- this.db.prepare(`INSERT OR IGNORE INTO ${n.versions} SELECT * FROM ${current.versions} WHERE ${where}`).run(known, part);
3303
- const now = (/* @__PURE__ */ new Date()).toISOString();
3304
- for (const doc of JSON.parse(part)) this.recompute(n, doc, now);
3305
- });
3306
- }
3307
- return docs.length;
3308
- },
3309
- commit: (since) => {
3310
- flush();
3311
- open = false;
3312
- return this.write(() => {
3313
- const current = this.generation();
3314
- const old = namesOf(current);
3315
- copyTexts(old, "fetched_at >= ?", [since]);
3316
- const added = this.db.prepare(`SELECT DISTINCT doc FROM ${old.versions} WHERE fetched_at >= ?`).all(since);
3317
- this.db.prepare(`INSERT OR IGNORE INTO ${n.versions} SELECT * FROM ${old.versions} WHERE fetched_at >= ?`).run(since);
3318
- const now = (/* @__PURE__ */ new Date()).toISOString();
3319
- for (const { doc } of added) this.recompute(n, doc, now);
3320
- for (const row of this.db.prepare(`SELECT * FROM ${old.latest} WHERE fetched_at >= ?`).all(since)) this.putLatest(n, toLatest(row));
3321
- this.setMeta("generation", String(generation));
3322
- this.db.prepare("DELETE FROM index_meta WHERE key = ?").run(`building:${String(generation)}`);
3323
- this.dropGeneration(current);
3324
- this.empty = false;
3325
- return {
3326
- docs: this.db.prepare(`SELECT count(*) AS n FROM ${n.docs}`).get().n,
3327
- urls: this.db.prepare(`SELECT count(*) AS n FROM ${n.latest}`).get().n
3328
- };
3329
- });
3330
- },
3331
- abort: () => {
3332
- open = false;
3333
- versions.length = 0;
3334
- latest.clear();
3335
- if (this.closed) return;
3336
- this.write(() => this.dropGeneration(generation));
3337
- }
3338
- };
3339
- }
3340
- /** This method closes the file. A second close does nothing. */
3341
- close() {
3342
- if (this.closed) return;
3343
- this.closed = true;
3344
- this.statements.clear();
3345
- this.db.close();
3346
- }
3347
- };
3348
- //#endregion
3349
- //#region src/hoststate.ts
3350
- /** A stop with no end goes into the table as this value, which is past each date that JavaScript can hold. */
3351
- const NO_END = 9e15;
3352
- /** A process that shares the store asks again after this wait, at most, during the lease of a different process. */
3353
- const SHARED_WAIT_MS = 1e3;
3354
- /** The store removes the rows of idle hosts after this many reservations. */
3355
- const SWEEP_EVERY = 1e3;
3356
- /**
3357
- * This store keeps the state of each host in a SQLite file. Two or more processes on one store
3358
- * share it, so they share the pace and the stops of each host. Thus a host gets one request at a
3359
- * time from all of them. Each method is one transaction (`BEGIN IMMEDIATE`), and it waits for the lock
3360
- * of a different process (`busy_timeout`).
3361
- *
3362
- * The lease of a request belongs to the store that gave it. A different process that asks during
3363
- * the lease gets a wait of one second or less, and asks again. A process that stops during a
3364
- * request holds its lease until the lease ends (5 minutes by default).
3365
- *
3366
- * The state is not a projection of the capture log. A deletion of the file loses only the pace,
3367
- * the counts and the stops, and the crawler starts them again.
3368
- */
3369
- var SqliteHostStateStore = class {
3370
- path;
3371
- db;
3372
- statements = /* @__PURE__ */ new Map();
3373
- /** This id names the leases of this store. */
3374
- owner = randomUUID();
3375
- reservations = 0;
3376
- closed = false;
3377
- pollMs;
3378
- constructor(path, options = {}) {
3379
- this.path = path;
3380
- if (path !== ":memory:") mkdirSync(dirname(path), {
3381
- recursive: true,
3382
- mode: 448
3383
- });
3384
- this.pollMs = Math.max(1, Math.min(SHARED_WAIT_MS, options.pollMs ?? SHARED_WAIT_MS));
3385
- this.db = new DatabaseSync(path);
3386
- const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
3387
- this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
3388
- if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
3389
- this.db.exec("PRAGMA synchronous = NORMAL;");
3390
- this.db.exec(`CREATE TABLE IF NOT EXISTS hosts (
3391
- host TEXT PRIMARY KEY,
3392
- next_at REAL NOT NULL DEFAULT 0,
3393
- leased_until REAL NOT NULL DEFAULT 0,
3394
- owner TEXT,
3395
- failures INTEGER NOT NULL DEFAULT 0,
3396
- waits INTEGER NOT NULL DEFAULT 0,
3397
- stop_until REAL,
3398
- stop_reason TEXT
3399
- )`);
3400
- }
3401
- stmt(sql) {
3402
- let statement = this.statements.get(sql);
3403
- if (statement === void 0) {
3404
- statement = this.db.prepare(sql);
3405
- this.statements.set(sql, statement);
3406
- }
3407
- return statement;
3408
- }
3409
- /** This method does one step in one transaction. */
3410
- step(work) {
3411
- if (this.closed) throw new Error("the host state store is closed");
3412
- this.db.exec("BEGIN IMMEDIATE");
3413
- try {
3414
- const result = work();
3415
- this.db.exec("COMMIT");
3416
- return result;
3417
- } catch (error) {
3418
- if (this.db.isTransaction) this.db.exec("ROLLBACK");
3419
- throw error;
3420
- }
3421
- }
3422
- row(host) {
3423
- this.stmt("INSERT OR IGNORE INTO hosts (host) VALUES (?)").run(host);
3424
- return this.stmt("SELECT next_at, leased_until, owner, failures, waits, stop_until, stop_reason FROM hosts WHERE host = ?").get(host);
3425
- }
3426
- /** This method removes the hosts that have nothing to remember: no rest, no lease, no counts and no stop. */
3427
- sweep(now) {
3428
- this.stmt(`DELETE FROM hosts WHERE next_at <= ? AND leased_until <= ? AND failures = 0 AND waits = 0
3429
- AND (stop_until IS NULL OR stop_until <= ?)`).run(now, now, now);
3430
- }
3431
- reserve(host, now, leaseMs) {
3432
- return this.step(() => {
3433
- this.reservations += 1;
3434
- if (this.reservations % SWEEP_EVERY === 0) this.sweep(now);
3435
- const row = this.row(host);
3436
- if (row.leased_until > now) return row.owner === this.owner ? row.leased_until - now : Math.min(this.pollMs, row.leased_until - now);
3437
- if (row.next_at > now) return row.next_at - now;
3438
- this.stmt("UPDATE hosts SET leased_until = ?, owner = ? WHERE host = ?").run(now + leaseMs, this.owner, host);
3439
- return 0;
3440
- });
3441
- }
3442
- record(host, result) {
3443
- return this.step(() => {
3444
- const row = this.row(host);
3445
- let { failures, waits } = row;
3446
- if (result === "success") {
3447
- failures = 0;
3448
- waits = 0;
3449
- } else if (result === "failure") failures += 1;
3450
- else if (result === "wait") waits += 1;
3451
- if (failures !== row.failures || waits !== row.waits) this.stmt("UPDATE hosts SET failures = ?, waits = ? WHERE host = ?").run(failures, waits, host);
3452
- return {
3453
- failures,
3454
- waits
3455
- };
3456
- });
3457
- }
3458
- release(host, nextAt) {
3459
- this.step(() => {
3460
- this.row(host);
3461
- this.stmt(`UPDATE hosts SET next_at = max(next_at, ?),
3462
- leased_until = CASE WHEN owner = ? THEN 0 ELSE leased_until END,
3463
- owner = CASE WHEN owner = ? THEN NULL ELSE owner END
3464
- WHERE host = ?`).run(nextAt, this.owner, this.owner, host);
3465
- });
3466
- }
3467
- defer(host, until) {
3468
- this.step(() => {
3469
- this.row(host);
3470
- this.stmt("UPDATE hosts SET next_at = max(next_at, ?) WHERE host = ?").run(until, host);
3471
- });
3472
- }
3473
- stop(host, until, reason) {
3474
- const end = Number.isFinite(until) ? until : NO_END;
3475
- this.step(() => {
3476
- this.row(host);
3477
- this.stmt(`UPDATE hosts SET stop_until = ?, stop_reason = ?
3478
- WHERE host = ? AND (stop_until IS NULL OR stop_until < ?)`).run(end, reason, host, end);
3479
- });
3480
- }
3481
- stopped(host, now) {
3482
- if (this.closed) throw new Error("the host state store is closed");
3483
- const row = this.stmt("SELECT stop_until, stop_reason FROM hosts WHERE host = ?").get(host);
3484
- if (row === void 0 || row.stop_until === null || row.stop_until <= now) return void 0;
3485
- return {
3486
- until: row.stop_until >= NO_END ? Number.POSITIVE_INFINITY : row.stop_until,
3487
- reason: row.stop_reason ?? "stopped"
3488
- };
3489
- }
3490
- reset(host) {
3491
- this.step(() => {
3492
- if (host === void 0) this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL").run();
3493
- else this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL WHERE host = ?").run(host);
3494
- });
3495
- }
3496
- /** This value is the number of hosts that the store holds. */
3497
- get size() {
3498
- return this.stmt("SELECT count(*) AS n FROM hosts").get().n;
3499
- }
3500
- /** This method closes the file. A second close does nothing. */
3501
- close() {
3502
- if (this.closed) return;
3503
- this.closed = true;
3504
- this.statements.clear();
3505
- this.db.close();
3506
- }
3507
- };
3508
- //#endregion
3509
- //#region src/log.ts
3510
- const MONTH_FILE = /^(\d{2})(?:\.[^.]+)?\.ndjson$/;
3511
- const YEAR_DIR = /^\d{4}$/;
3512
- const NEWLINE = 10;
3513
- /** This function writes the whole buffer. A write to a file can write less than the buffer, and the loop writes the rest. */
3514
- const writeAll = async (handle, bytes) => {
3515
- let at = 0;
3516
- while (at < bytes.length) {
3517
- const { bytesWritten } = await handle.write(bytes, at, bytes.length - at);
3518
- at += bytesWritten;
3519
- }
3520
- };
3521
- /**
3522
- * This function reads a file line by line, as bytes, so a long file does not become one string. It
3523
- * gives each line and tells if a line break ends it.
3524
- */
3525
- async function* linesOf(path) {
3526
- const decoder = new TextDecoder("utf-8");
3527
- let rest;
3528
- for await (const chunk of createReadStream(path)) {
3529
- const data = rest === void 0 ? chunk : Buffer.concat([rest, chunk]);
3530
- rest = void 0;
3531
- let start = 0;
3532
- for (;;) {
3533
- const end = data.indexOf(NEWLINE, start);
3534
- if (end === -1) break;
3535
- yield {
3536
- text: decoder.decode(data.subarray(start, end)),
3537
- complete: true
3538
- };
3539
- start = end + 1;
3540
- }
3541
- if (start < data.length) rest = Buffer.from(data.subarray(start));
3542
- }
3543
- if (rest !== void 0 && rest.length > 0) yield {
3544
- text: decoder.decode(rest),
3545
- complete: false
3546
- };
3547
- }
3548
- const monthKey = (year, file) => `${year}-${MONTH_FILE.exec(file)?.[1] ?? "00"}`;
3549
- /**
3550
- * The capture log keeps each fetch decision as one line of JSON in
3551
- * `<dir>/<source>/<yyyy>/<mm>.ndjson`. It is the record of each access that the crawler makes.
3552
- *
3553
- * These are the rules of the log:
3554
- *
3555
- * - The log appends a batch of records with one write, and it writes the batch to the disk before
3556
- * the appends finish.
3557
- * - An append always starts on a new line. After a crash leaves a partial line, the next append
3558
- * starts after a line break. The partial line stays as a damaged line, and the record is good.
3559
- * - A scan reads each file as a stream. It skips a damaged line, counts it, and continues.
3560
- * - The log does not remove or change a line.
3561
- *
3562
- * Two processes can append to one log, because each batch is one write to a file in append mode.
3563
- */
3564
- var CaptureLog = class CaptureLog {
3565
- dir;
3566
- handles = /* @__PURE__ */ new Map();
3567
- pending = [];
3568
- draining;
3569
- sync;
3570
- constructor(dir, options = {}) {
3571
- this.dir = dir;
3572
- this.sync = options.sync ?? true;
3573
- }
3574
- pathOf(record) {
3575
- return join(this.dir, record.source, record.received_at.slice(0, 4), `${record.received_at.slice(5, 7)}.ndjson`);
3576
- }
3577
- async handleFor(path) {
3578
- const known = this.handles.get(path);
3579
- if (known !== void 0) return known;
3580
- await mkdir(dirname(path), {
3581
- recursive: true,
3582
- mode: 448
3583
- });
3584
- const handle = await open(path, "a+", 384);
3585
- this.handles.set(path, handle);
3586
- return handle;
3587
- }
3588
- /** This method tells if the last byte of a file is not a line break: a partial line that a crash left. */
3589
- static async needsBreak(handle) {
3590
- const { size } = await handle.stat();
3591
- if (size === 0) return false;
3592
- const last = Buffer.alloc(1);
3593
- await handle.read(last, 0, 1, size - 1);
3594
- return last[0] !== NEWLINE;
3595
- }
3596
- append(record) {
3597
- let line;
3598
- try {
3599
- line = `${JSON.stringify(record)}\n`;
3600
- } catch (error) {
3601
- return Promise.reject(error instanceof Error ? error : new Error(String(error)));
3602
- }
3603
- return new Promise((resolve, reject) => {
3604
- this.pending.push({
3605
- path: this.pathOf(record),
3606
- line,
3607
- resolve,
3608
- reject
3609
- });
3610
- this.kick();
3611
- });
3612
- }
3613
- /** This method starts the writer when it is not in progress. A record that comes as the writer stops starts it again. */
3614
- kick() {
3615
- if (this.draining !== void 0) return;
3616
- this.draining = this.drain().finally(() => {
3617
- this.draining = void 0;
3618
- if (this.pending.length > 0) this.kick();
3619
- });
3620
- }
3621
- /** This method writes the waiting records in batches: one write and one sync for each file. */
3622
- async drain() {
3623
- while (this.pending.length > 0) {
3624
- const batch = this.pending.splice(0);
3625
- const byPath = /* @__PURE__ */ new Map();
3626
- for (const item of batch) {
3627
- const list = byPath.get(item.path);
3628
- if (list === void 0) byPath.set(item.path, [item]);
3629
- else list.push(item);
3630
- }
3631
- for (const [path, items] of byPath) try {
3632
- const handle = await this.handleFor(path);
3633
- const lead = await CaptureLog.needsBreak(handle) ? "\n" : "";
3634
- await writeAll(handle, Buffer.from(`${lead}${items.map((item) => item.line).join("")}`, "utf8"));
3635
- if (this.sync) await handle.datasync();
3636
- for (const item of items) item.resolve();
3637
- } catch (error) {
3638
- const handle = this.handles.get(path);
3639
- this.handles.delete(path);
3640
- await handle?.close().catch(() => void 0);
3641
- for (const item of items) item.reject(error);
3642
- }
3643
- }
3644
- }
3645
- /** This method waits until each append in progress is on the disk. */
3646
- async flush() {
3647
- while (this.draining !== void 0) await this.draining;
3648
- }
3649
- /** This method gives the record files of one source, in the sequence of time. It skips the months before `since`. */
3650
- async filesOf(source, since) {
3651
- const sourceDir = join(this.dir, source);
3652
- if (!existsSync(sourceDir)) return [];
3653
- const from = since?.slice(0, 7);
3654
- const years = (await readdir(sourceDir, { withFileTypes: true })).filter((entry) => entry.isDirectory() && YEAR_DIR.test(entry.name)).map((entry) => entry.name).sort();
3655
- const files = [];
3656
- for (const year of years) {
3657
- if (from !== void 0 && year < from.slice(0, 4)) continue;
3658
- const months = (await readdir(join(sourceDir, year))).filter((name) => MONTH_FILE.test(name)).sort();
3659
- for (const month of months) {
3660
- if (from !== void 0 && monthKey(year, month) < from) continue;
3661
- files.push(join(sourceDir, year, month));
3662
- }
3663
- }
3664
- return files;
3665
- }
3666
- /** This method reads the records of one source in the sequence of the files. */
3667
- async *readSource(source, filter, options) {
3668
- for (const path of await this.filesOf(source, filter?.since)) {
3669
- let number = 0;
3670
- for await (const { text, complete } of linesOf(path)) {
3671
- number += 1;
3672
- const trimmed = text.trim();
3673
- if (trimmed.length === 0) continue;
3674
- let row;
3675
- try {
3676
- row = JSON.parse(trimmed);
3677
- if (typeof row !== "object" || row === null || typeof row.received_at !== "string") throw new Error("not a record");
3678
- } catch {
3679
- if (complete) options?.onDamaged?.({
3680
- path,
3681
- line: number
3682
- });
3683
- continue;
3684
- }
3685
- if (filter?.kind !== void 0 && row.kind !== filter.kind) continue;
3686
- if (filter?.since !== void 0 && row.received_at < filter.since) continue;
3687
- yield row;
3688
- }
3689
- }
3690
- }
3691
- /**
3692
- * This method reads the records that agree with the filter. It first waits for the appends that
3693
- * are in progress. It reads each file as a stream, so its memory does not grow with the log.
3694
- *
3695
- * The records of one source come in the sequence of the files. The records of two or more sources
3696
- * merge in the sequence of `received_at`.
3697
- */
3698
- async *scan(filter, options) {
3699
- await this.flush();
3700
- const sources = filter?.source !== void 0 ? [filter.source] : existsSync(this.dir) ? (await readdir(this.dir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort() : [];
3701
- if (sources.length === 1) {
3702
- yield* this.readSource(sources[0], filter, options);
3703
- return;
3704
- }
3705
- const streams = sources.map((source) => this.readSource(source, filter, options)[Symbol.asyncIterator]());
3706
- const heads = [];
3707
- for (const stream of streams) heads.push((await stream.next()).value);
3708
- for (;;) {
3709
- let pick = -1;
3710
- for (const [index, head] of heads.entries()) if (head !== void 0 && (pick === -1 || head.received_at < heads[pick].received_at)) pick = index;
3711
- if (pick === -1) return;
3712
- yield heads[pick];
3713
- heads[pick] = (await streams[pick].next()).value;
3714
- }
3715
- }
3716
- /** This method writes the appends in progress, then closes the files. A later append opens them again. */
3717
- async close() {
3718
- await this.flush();
3719
- const handles = [...this.handles.values()];
3720
- this.handles.clear();
3721
- for (const handle of handles) await handle.close();
3722
- }
3723
- };
3724
- //#endregion
3725
- //#region src/pdf.ts
3726
- const MAX_PAGES = 500;
3727
- const MAX_CHARS = 2e5;
3728
- const TIME_BUDGET_MS = 2e4;
3729
- /**
3730
- * The text streams of 35 stored menus and papers inflate to 0.6 MB or less. The parser of pdf.js
3731
- * reads a page with no pause. Thus a page of 8 MB of text operators stops the process for
3732
- * approximately one second.
3733
- */
3734
- const MAX_INFLATED_BYTES = 8388608;
3735
- /** This function finds a PDF from its first bytes or its content type. Some servers put bytes before `%PDF-`. */
3736
- const isPdf = (body, contentType) => {
3737
- if (/\bpdf\b/i.test(contentType ?? "")) return true;
3738
- const end = Math.min(body.length - 4, 1024);
3739
- for (let index = 0; index < end; index += 1) if (body[index] === 37 && body[index + 1] === 80 && body[index + 2] === 68 && body[index + 3] === 70 && body[index + 4] === 45) return true;
3740
- return false;
3741
- };
3742
- /** This function gives one line of text for each line of the PDF. It removes the empty lines. */
3743
- const tidy = (page) => page.split(/\r?\n/).map((line) => line.replace(/\s+/g, " ").trim()).filter((line) => line.length > 0).join("\n");
3744
- /** A word processor writes the name of its file as the title ("Microsoft Word - menu-final-v3.docx"). */
3745
- const JUNK_TITLE = /^(?:microsoft (?:word|powerpoint|excel) - |untitled\b|document\d*$|presentation\d*$)|\.(?:docx?|pdf|pptx?|xlsx?|indd|ai|psd|rtf|odt|pages|txt)$/i;
3746
- const latin1 = (bytes, start, end) => {
3747
- let out = "";
3748
- for (let index = Math.max(0, start); index < end && index < bytes.length; index += 1) out += String.fromCharCode(bytes[index]);
3749
- return out;
3750
- };
3751
- const indexOf = (bytes, needle, from) => {
3752
- const first = needle.charCodeAt(0);
3753
- for (let index = bytes.indexOf(first, from); index !== -1; index = bytes.indexOf(first, index + 1)) {
3754
- let match = true;
3755
- for (let at = 1; at < needle.length; at += 1) if (bytes[index + at] !== needle.charCodeAt(at)) {
3756
- match = false;
3757
- break;
3758
- }
3759
- if (match) return index;
3760
- }
3761
- return -1;
3762
- };
3763
- /**
3764
- * This function adds up the bytes that the compressed streams of a file inflate to, and stops at
3765
- * the limit. It tries each stream: a stream in another format fails at its first bytes. An image
3766
- * stream does not count, because the text reader does not decode it. The function gives true when
3767
- * the file stays inside the limit.
3768
- */
3769
- const inflatesWithin = (bytes, limit) => {
3770
- let total = 0;
3771
- let from = 0;
3772
- for (;;) {
3773
- const keyword = indexOf(bytes, "stream", from);
3774
- if (keyword === -1) return true;
3775
- from = keyword + 6;
3776
- if (latin1(bytes, keyword - 3, keyword) === "end") continue;
3777
- const next = bytes[keyword + 6];
3778
- if (next !== 10 && next !== 13) continue;
3779
- const dictionary = latin1(bytes, keyword - 1024, keyword);
3780
- const head = dictionary.slice(Math.max(0, dictionary.lastIndexOf("<<")));
3781
- if (/\/Subtype\s*\/Image\b/.test(head)) continue;
3782
- let start = keyword + 7;
3783
- if (next === 13 && bytes[start] === 10) start += 1;
3784
- const declared = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(head);
3785
- const stop = indexOf(bytes, "endstream", start);
3786
- const end = declared !== null && start + Number(declared[1]) <= bytes.length ? start + Number(declared[1]) : stop === -1 ? bytes.length : stop;
3787
- try {
3788
- total += inflateSync(bytes.subarray(start, end), {
3789
- maxOutputLength: Math.max(1, limit - total + 1),
3790
- finishFlush: constants.Z_SYNC_FLUSH
3791
- }).length;
3792
- } catch (error) {
3793
- if (error instanceof RangeError) return false;
3794
- }
3795
- if (total > limit) return false;
3796
- from = Math.max(from, end);
3797
- }
3798
- };
3799
- /** This function lets the other work of the process run, for example another request. */
3800
- const pause = () => new Promise((resolve) => setImmediate(resolve));
3801
- /** This function gives the value of a promise, or undefined when the time ends first. */
3802
- const within = async (promise, ms) => {
3803
- let timer;
3804
- const late = new Promise((resolve) => {
3805
- timer = setTimeout(() => resolve(void 0), Math.max(0, ms));
3806
- });
3807
- try {
3808
- return await Promise.race([promise, late]);
3809
- } finally {
3810
- clearTimeout(timer);
2815
+ };
2816
+ /** This function lets the other work of the process run, for example another request. */
2817
+ const pause = () => new Promise((resolve) => setImmediate(resolve));
2818
+ /** This function gives the value of a promise, or undefined when the time ends first. */
2819
+ const within = async (promise, ms) => {
2820
+ let timer;
2821
+ const late = new Promise((resolve) => {
2822
+ timer = setTimeout(() => resolve(void 0), Math.max(0, ms));
2823
+ });
2824
+ try {
2825
+ return await Promise.race([promise, late]);
2826
+ } finally {
2827
+ clearTimeout(timer);
3811
2828
  }
3812
2829
  };
3813
2830
  /** This function keeps the first characters of a text, and does not cut a surrogate pair. */
@@ -3821,7 +2838,7 @@ const readPdf = async (bytes, options = {}) => {
3821
2838
  const maxPages = options.maxPages ?? MAX_PAGES;
3822
2839
  const maxChars = options.maxChars ?? MAX_CHARS;
3823
2840
  const budgetMs = options.timeBudgetMs ?? TIME_BUDGET_MS;
3824
- const maxInflated = options.maxInflatedBytes ?? MAX_INFLATED_BYTES;
2841
+ const maxInflated = options.maxInflatedBytes ?? MAX_INFLATED_BYTES$1;
3825
2842
  if (!inflatesWithin(bytes, maxInflated)) return {
3826
2843
  title: null,
3827
2844
  text: "",
@@ -3882,6 +2899,511 @@ const readPdf = async (bytes, options = {}) => {
3882
2899
  }
3883
2900
  };
3884
2901
  //#endregion
2902
+ //#region src/fallback.ts
2903
+ /** The recipe of the request for an archived copy. Its extractor reads a Wayback Machine copy or a Common Crawl WARC record. */
2904
+ const ARCHIVE_COPY_RECIPE = "archive-copy";
2905
+ /** The recipe of the requests to the indexes of the archives. They give no document. */
2906
+ const ARCHIVE_INDEX_RECIPE = "archive-index";
2907
+ /** The kind of the document of an archived copy. */
2908
+ const ARCHIVED_KIND = "archived";
2909
+ /** This function gives the document id of the archived copy of a page. */
2910
+ const archivedDocId = (url) => `archive:${canonicalUrl(url)}`;
2911
+ /** The addresses of the archives. */
2912
+ const WAYBACK_AVAILABLE = "https://archive.org/wayback/available";
2913
+ const WAYBACK = "https://web.archive.org/web/";
2914
+ const CC_INDEX = "https://index.commoncrawl.org/";
2915
+ const CC_DATA = "https://data.commoncrawl.org/";
2916
+ /** The Common Crawl indexes that the fallback asks, the newest first. */
2917
+ const CC_INDEXES = 3;
2918
+ /** The largest compressed WARC record that the fallback asks for. */
2919
+ const MAX_RECORD_BYTES = 5242880;
2920
+ /** The largest decompressed WARC record or body that the fallback reads. */
2921
+ const MAX_INFLATED_BYTES = 20971520;
2922
+ /** The Wayback Machine path of a raw copy: the 14 digits of the time, `id_`, and the URL of the page. */
2923
+ const WAYBACK_RAW = /^\/web\/(\d{14})id_\/(https?:\/\/.+)$/s;
2924
+ const HTML_ACCEPT = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5";
2925
+ const JSON_ACCEPT = "application/json";
2926
+ const ROBOTS_DISALLOW = "robots.txt disallows it";
2927
+ const ROBOTS_UNREACHABLE = "robots.txt could not be read";
2928
+ const HOST_STOPPED = "host stopped";
2929
+ /**
2930
+ * This function tells if the result of a live fetch can get an archived copy, and why. These
2931
+ * refusals can: robots.txt, a stop of the host, a 401, a 402, a 403 and a challenge. A 502, 503 or
2932
+ * 504, a 5xx error and a dead host can too. A 404, a 429, a cancel and a refusal of the egress
2933
+ * policy cannot.
2934
+ */
2935
+ const fallbackReason = (indexed) => {
2936
+ const { fetched } = indexed;
2937
+ if (indexed.blocked !== void 0) return "challenge";
2938
+ const note = fetched.note ?? "";
2939
+ switch (fetched.outcome) {
2940
+ case "skipped":
2941
+ if (note.startsWith(ROBOTS_DISALLOW)) return "robots";
2942
+ if (note.startsWith(ROBOTS_UNREACHABLE)) return "unreachable";
2943
+ if (note.startsWith(HOST_STOPPED)) return "stopped";
2944
+ return;
2945
+ case "blocked":
2946
+ case "challenge":
2947
+ case "payment-required":
2948
+ case "unavailable": return fetched.outcome;
2949
+ case "error":
2950
+ if (fetched.status === 0 && note.startsWith("no response")) return "unreachable";
2951
+ return fetched.status >= 500 ? "error" : void 0;
2952
+ default: return;
2953
+ }
2954
+ };
2955
+ /** This function gives the `archived` of the metadata of a document, when the document is an archived copy. */
2956
+ const archivedOf = (meta) => {
2957
+ const value = meta["archived"];
2958
+ if (typeof value !== "object" || value === null) return void 0;
2959
+ const { source, timestamp, url, original, record } = value;
2960
+ if (source !== "wayback" && source !== "commoncrawl" || typeof timestamp !== "string" || typeof url !== "string" || typeof original !== "string") return void 0;
2961
+ return {
2962
+ source,
2963
+ timestamp,
2964
+ url,
2965
+ original,
2966
+ ...typeof record === "string" ? { record } : {}
2967
+ };
2968
+ };
2969
+ const ARCHIVE_NAMES = {
2970
+ wayback: "the Wayback Machine",
2971
+ commoncrawl: "Common Crawl"
2972
+ };
2973
+ /** This function gives the note of an archived copy: the archive and the date, and that it is not a fresh fetch. */
2974
+ const archivedNote = (archived) => `an archived copy from ${ARCHIVE_NAMES[archived.source]}, captured ${archived.timestamp}, not a fresh fetch of the page`;
2975
+ /** This function gives an ISO 8601 time with no milliseconds, or undefined for a time that is not valid. */
2976
+ const isoSeconds = (ms) => Number.isFinite(ms) ? new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z") : void 0;
2977
+ /** This function reads the 14 digits of a Wayback Machine time (`20240131235959`) as an ISO 8601 time. */
2978
+ const waybackTime = (digits) => {
2979
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(digits);
2980
+ if (match === null) return void 0;
2981
+ const [, year, month, day, hour, minute, second] = match;
2982
+ const ms = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
2983
+ const iso = isoSeconds(ms);
2984
+ return iso !== void 0 && iso.replace(/\D/g, "").startsWith(digits) ? iso : void 0;
2985
+ };
2986
+ const httpUrlOf = (text) => {
2987
+ try {
2988
+ const url = new URL(text);
2989
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : void 0;
2990
+ } catch {
2991
+ return;
2992
+ }
2993
+ };
2994
+ const CRLF2 = Buffer.from("\r\n\r\n");
2995
+ /** This function reads the header lines of a block as a map. A name can be in any case, and two lines of one name join with ", ". */
2996
+ const headerMap = (text) => {
2997
+ const out = /* @__PURE__ */ new Map();
2998
+ for (const line of text.split(/\r?\n/)) {
2999
+ const colon = line.indexOf(":");
3000
+ if (colon <= 0) continue;
3001
+ const name = line.slice(0, colon).trim().toLowerCase();
3002
+ const value = line.slice(colon + 1).trim();
3003
+ const known = out.get(name);
3004
+ out.set(name, known === void 0 ? value : `${known}, ${value}`);
3005
+ }
3006
+ return out;
3007
+ };
3008
+ /** This function joins the chunks of a body with `Transfer-Encoding: chunked`. A body that is not in that form gives undefined. */
3009
+ const dechunk = (body) => {
3010
+ const parts = [];
3011
+ let at = 0;
3012
+ let total = 0;
3013
+ for (;;) {
3014
+ const end = body.indexOf("\r\n", at);
3015
+ if (end === -1) return void 0;
3016
+ const size = Number.parseInt(body.subarray(at, end).toString("latin1").split(";")[0].trim(), 16);
3017
+ if (!Number.isFinite(size) || size < 0) return void 0;
3018
+ if (size === 0) return Buffer.concat(parts);
3019
+ const start = end + 2;
3020
+ if (start + size > body.length) return void 0;
3021
+ total += size;
3022
+ if (total > MAX_INFLATED_BYTES) return void 0;
3023
+ parts.push(body.subarray(start, start + size));
3024
+ at = start + size + 2;
3025
+ }
3026
+ };
3027
+ /** This function decodes the content coding of an archived body, with a limit on the output. */
3028
+ const decodeCoding = (body, coding) => {
3029
+ const options = { maxOutputLength: MAX_INFLATED_BYTES };
3030
+ let out = body;
3031
+ for (const name of (coding ?? "").split(",").map((part) => part.trim().toLowerCase()).filter((part) => part.length > 0 && part !== "identity").reverse()) if (name === "gzip" || name === "x-gzip") out = gunzipSync(out, options);
3032
+ else if (name === "br") out = brotliDecompressSync(out, options);
3033
+ else if (name === "deflate") try {
3034
+ out = inflateSync(out, options);
3035
+ } catch {
3036
+ out = inflateRawSync(out, options);
3037
+ }
3038
+ else return void 0;
3039
+ return out;
3040
+ };
3041
+ /**
3042
+ * This function reads one WARC record of the type `response`, as Common Crawl gives it. The
3043
+ * record is a gzip member with the WARC headers, the HTTP headers and the body. It gives undefined for a different
3044
+ * record, or for bytes that do not decode. Common Crawl keeps the decoded body and renames
3045
+ * `Content-Encoding` to `X-Crawler-Content-Encoding`. A record with the original codings is decoded too.
3046
+ */
3047
+ const readWarcResponse = (bytes) => {
3048
+ if (bytes.length < 2 || bytes[0] !== 31 || bytes[1] !== 139) return void 0;
3049
+ let record;
3050
+ try {
3051
+ record = gunzipSync(bytes, { maxOutputLength: MAX_INFLATED_BYTES });
3052
+ } catch {
3053
+ return;
3054
+ }
3055
+ if (!record.subarray(0, 8).toString("latin1").startsWith("WARC/1.")) return void 0;
3056
+ const warcEnd = record.indexOf(CRLF2);
3057
+ if (warcEnd === -1) return void 0;
3058
+ const warc = headerMap(record.subarray(0, warcEnd).toString("utf8"));
3059
+ if (warc.get("warc-type")?.toLowerCase() !== "response") return void 0;
3060
+ const target = httpUrlOf(warc.get("warc-target-uri")?.replace(/^<|>$/g, "") ?? "");
3061
+ const date = isoSeconds(Date.parse(warc.get("warc-date") ?? ""));
3062
+ if (target === void 0 || date === void 0) return void 0;
3063
+ const length = Number(warc.get("content-length") ?? NaN);
3064
+ const start = warcEnd + 4;
3065
+ const block = record.subarray(start, Number.isInteger(length) && length >= 0 ? Math.min(record.length, start + length) : record.length);
3066
+ const httpEnd = block.indexOf(CRLF2);
3067
+ if (httpEnd === -1) return void 0;
3068
+ const head = block.subarray(0, httpEnd).toString("latin1");
3069
+ const statusLine = /^HTTP\/\d(?:\.\d)? (\d{3})/.exec(head);
3070
+ if (statusLine === null) return void 0;
3071
+ const headers = headerMap(head.slice(head.indexOf("\n") + 1));
3072
+ let body = block.subarray(httpEnd + 4);
3073
+ try {
3074
+ if (/\bchunked\b/i.test(headers.get("transfer-encoding") ?? "")) body = dechunk(body);
3075
+ if (body !== void 0) body = decodeCoding(body, headers.get("content-encoding"));
3076
+ } catch {
3077
+ body = void 0;
3078
+ }
3079
+ if (body === void 0) return void 0;
3080
+ return {
3081
+ target,
3082
+ date,
3083
+ record: warc.get("warc-record-id")?.replace(/^<|>$/g, ""),
3084
+ status: Number(statusLine[1]),
3085
+ header: (name) => headers.get(name.toLowerCase()),
3086
+ body: new Uint8Array(body)
3087
+ };
3088
+ };
3089
+ /** This function changes the documents of an archived body into the documents of the copy. A bot check is not the content, so it gives no document. */
3090
+ const asArchived = (docs, archived, aiInput) => docs.filter((doc) => typeof doc.meta["blocked"] !== "string" || doc.meta["blocked"] === "").map((doc) => ({
3091
+ ...doc,
3092
+ doc: archivedDocId(archived.original),
3093
+ kind: ARCHIVED_KIND,
3094
+ url: archived.url,
3095
+ meta: {
3096
+ ...doc.meta,
3097
+ archived_kind: doc.kind,
3098
+ archived
3099
+ },
3100
+ ...aiInput === "no" ? { ai_input: "no" } : {}
3101
+ }));
3102
+ /**
3103
+ * This extractor reads the captures of the recipe `archive-copy`. It has no side effects, so
3104
+ * `reindex` replays them. It reads two forms:
3105
+ *
3106
+ * - A Wayback Machine copy in the raw form (`/web/<time>id_/<url>`). The body is the body of the page.
3107
+ * - A Common Crawl WARC record (a gzip member). It reads the HTTP headers and the body from the record.
3108
+ *
3109
+ * It reads the body with the page reader, and gives a document of the kind `archived` with
3110
+ * `archived` in its metadata. The usage signals of the HTTP headers in a WARC record apply too.
3111
+ */
3112
+ const archiveExtractor = async (body, capture) => {
3113
+ let url;
3114
+ try {
3115
+ url = new URL(capture.url);
3116
+ } catch {
3117
+ return [];
3118
+ }
3119
+ const raw = WAYBACK_RAW.exec(`${url.pathname}${url.search}`);
3120
+ if (raw !== null) {
3121
+ const timestamp = waybackTime(raw[1]);
3122
+ const original = httpUrlOf(raw[2]);
3123
+ if (timestamp === void 0 || original === void 0) return [];
3124
+ const archived = {
3125
+ source: "wayback",
3126
+ timestamp,
3127
+ url: `${url.origin}/web/${raw[1]}/${original}`,
3128
+ original
3129
+ };
3130
+ return asArchived(await pageExtractor(body, {
3131
+ url: original,
3132
+ contentType: capture.contentType
3133
+ }), archived, void 0);
3134
+ }
3135
+ const response = readWarcResponse(body);
3136
+ if (response === void 0 || response.status < 200 || response.status >= 300) return [];
3137
+ const archived = {
3138
+ source: "commoncrawl",
3139
+ timestamp: response.date,
3140
+ url: capture.url,
3141
+ original: response.target,
3142
+ ...response.record === void 0 ? {} : { record: response.record }
3143
+ };
3144
+ const signals = readSignals([], response.header);
3145
+ return asArchived(await pageExtractor(response.body, {
3146
+ url: response.target,
3147
+ contentType: response.header("content-type") ?? null
3148
+ }), archived, signals.ai_input === "no" ? "no" : void 0);
3149
+ };
3150
+ /** The parts of each request to an archive: the identity of the request, its budget and its labels. */
3151
+ const common = (options) => ({
3152
+ maxAgeMs: options.maxAgeMs,
3153
+ onBehalfOf: options.onBehalfOf,
3154
+ job: options.job,
3155
+ signal: options.signal,
3156
+ scope: options.scope,
3157
+ labels: options.labels,
3158
+ deadline: options.deadline
3159
+ });
3160
+ const got = (fetched) => (fetched.outcome === "ok" || fetched.outcome === "not-modified") && fetched.body !== void 0;
3161
+ const said = (fetched) => `${fetched.outcome}${fetched.status > 0 ? ` ${String(fetched.status)}` : ""}${fetched.note === void 0 ? "" : `: ${fetched.note}`}`;
3162
+ const jsonOf = (fetched) => {
3163
+ try {
3164
+ return JSON.parse(new TextDecoder().decode(fetched.body));
3165
+ } catch {
3166
+ return;
3167
+ }
3168
+ };
3169
+ /** This function gives the archived document of a fetch of a copy, or a note that tells why there is none. */
3170
+ const copyOf = (indexed, name) => {
3171
+ const doc = indexed.docs.find((one) => one.kind === ARCHIVED_KIND);
3172
+ const archived = doc === void 0 ? void 0 : archivedOf(doc.meta);
3173
+ if (doc !== void 0 && archived !== void 0) return {
3174
+ doc,
3175
+ archived
3176
+ };
3177
+ if (!got(indexed.fetched)) return { note: `${name}: the copy did not come (${said(indexed.fetched)})` };
3178
+ return { note: `${name}: the copy gave no text${indexed.error === void 0 ? " (a bot check or an empty page)" : ` (${indexed.error})`}` };
3179
+ };
3180
+ /** This function asks the Wayback Machine for its newest copy of a page, and fetches the raw copy. */
3181
+ const fromWayback = async (runtime, url, signalLines, options) => {
3182
+ const name = ARCHIVE_NAMES.wayback;
3183
+ const lookup = await runtime.crawler.fetch({
3184
+ url: `${WAYBACK_AVAILABLE}?url=${encodeURIComponent(url)}`,
3185
+ mode: "page",
3186
+ recipe: ARCHIVE_INDEX_RECIPE,
3187
+ accept: JSON_ACCEPT,
3188
+ ...common(options)
3189
+ });
3190
+ if (!got(lookup)) return { note: `${name}: the index did not answer (${said(lookup)})` };
3191
+ const closest = jsonOf(lookup)?.archived_snapshots?.closest;
3192
+ if (closest === void 0 || closest["available"] === false) return { note: `${name}: no copy` };
3193
+ const status = closest["status"];
3194
+ if (status !== void 0 && status !== "200") return { note: `${name}: the newest copy has the status ${JSON.stringify(status) ?? "unknown"}, not 200` };
3195
+ const digits = typeof closest["timestamp"] === "string" ? closest["timestamp"] : "";
3196
+ const shown = typeof closest["url"] === "string" ? closest["url"] : "";
3197
+ const original = httpUrlOf(/\/web\/\d{14}[a-z_]*\/(.+)$/s.exec(shown)?.[1] ?? "");
3198
+ if (waybackTime(digits) === void 0 || original === void 0) return { note: `${name}: the index gave no usable time or URL` };
3199
+ const copy = await fetchAndIndex(runtime, {
3200
+ url: `${WAYBACK}${digits}id_/${original}`,
3201
+ mode: "page",
3202
+ recipe: ARCHIVE_COPY_RECIPE,
3203
+ accept: HTML_ACCEPT,
3204
+ originalHeaderPrefix: "x-archive-orig-",
3205
+ signalLines,
3206
+ ...common(options)
3207
+ });
3208
+ const found = copyOf(copy, name);
3209
+ return "note" in found ? found : {
3210
+ ...found,
3211
+ fromCache: lookup.fromCache && copy.fetched.fromCache
3212
+ };
3213
+ };
3214
+ /** This function reads the JSON lines of a Common Crawl index answer, and gives the newest copy with the status 200. */
3215
+ const newestOf = (fetched) => {
3216
+ const text = new TextDecoder().decode(fetched.body);
3217
+ let best;
3218
+ for (const line of text.split("\n")) {
3219
+ if (line.trim() === "") continue;
3220
+ let row;
3221
+ try {
3222
+ row = JSON.parse(line);
3223
+ } catch {
3224
+ continue;
3225
+ }
3226
+ const { timestamp, filename, status } = row;
3227
+ const offset = Number(row["offset"]);
3228
+ const length = Number(row["length"]);
3229
+ if (status !== "200" || typeof timestamp !== "string" || !/^\d{14}$/.test(timestamp) || typeof filename !== "string" || !/^crawl-data\/[\w\-./]+\.warc\.gz$/.test(filename) || filename.includes("..") || !Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 1 || length > MAX_RECORD_BYTES) continue;
3230
+ if (best === void 0 || timestamp > best.timestamp) best = {
3231
+ timestamp,
3232
+ filename,
3233
+ offset,
3234
+ length
3235
+ };
3236
+ }
3237
+ return best;
3238
+ };
3239
+ /** This function asks the newest Common Crawl indexes for a page, and fetches its WARC record with a range request. */
3240
+ const fromCommonCrawl = async (runtime, url, signalLines, options) => {
3241
+ const name = ARCHIVE_NAMES.commoncrawl;
3242
+ const info = await runtime.crawler.fetch({
3243
+ url: `${CC_INDEX}collinfo.json`,
3244
+ mode: "page",
3245
+ recipe: ARCHIVE_INDEX_RECIPE,
3246
+ accept: JSON_ACCEPT,
3247
+ ...common(options)
3248
+ });
3249
+ if (!got(info)) return { note: `${name}: the list of indexes did not come (${said(info)})` };
3250
+ const listed = jsonOf(info);
3251
+ const apis = (Array.isArray(listed) ? listed : []).map((entry) => typeof entry === "object" && entry !== null ? entry["cdx-api"] : void 0).filter((api) => typeof api === "string" && api.startsWith(CC_INDEX)).slice(0, CC_INDEXES);
3252
+ if (apis.length === 0) return { note: `${name}: the list of indexes is empty` };
3253
+ let entry;
3254
+ let fromCache = info.fromCache;
3255
+ const misses = [];
3256
+ for (const api of apis) {
3257
+ const query = await runtime.crawler.fetch({
3258
+ url: `${api}?url=${encodeURIComponent(url)}&output=json`,
3259
+ mode: "page",
3260
+ recipe: ARCHIVE_INDEX_RECIPE,
3261
+ accept: JSON_ACCEPT,
3262
+ ...common(options)
3263
+ });
3264
+ fromCache &&= query.fromCache;
3265
+ if (got(query)) entry = newestOf(query);
3266
+ else if (query.outcome !== "not-found") misses.push(said(query));
3267
+ if (entry !== void 0) break;
3268
+ if (query.outcome === "skipped") break;
3269
+ }
3270
+ if (entry === void 0) return { note: `${name}: no copy in the ${String(apis.length)} newest indexes${misses.length > 0 ? ` (${misses.join("; ")})` : ""}` };
3271
+ const copy = await fetchAndIndex(runtime, {
3272
+ url: `${CC_DATA}${entry.filename}`,
3273
+ mode: "page",
3274
+ recipe: ARCHIVE_COPY_RECIPE,
3275
+ accept: "*/*",
3276
+ range: {
3277
+ offset: entry.offset,
3278
+ length: entry.length
3279
+ },
3280
+ signalLines,
3281
+ ...common(options)
3282
+ });
3283
+ if (got(copy.fetched) && copy.fetched.status !== 206) return { note: `${name}: the server sent the status ${String(copy.fetched.status)}, not the part of the file` };
3284
+ const found = copyOf(copy, name);
3285
+ return "note" in found ? found : {
3286
+ ...found,
3287
+ fromCache: false
3288
+ };
3289
+ };
3290
+ /**
3291
+ * This function gives an archived copy of a page after a refusal. It applies the rule for
3292
+ * robots.txt first: with no `archiveDespiteRobots`, a page that the current robots.txt of its site
3293
+ * disallows gets no copy. A stored copy younger than `maxAgeMs` answers with no request. Then it asks
3294
+ * the Wayback Machine, then Common Crawl, each one with polite fetches of the crawler.
3295
+ */
3296
+ const archivedCopy = async (runtime, url, reason, options) => {
3297
+ let target;
3298
+ try {
3299
+ target = new URL(url);
3300
+ } catch {
3301
+ return {
3302
+ fromCache: false,
3303
+ notes: []
3304
+ };
3305
+ }
3306
+ target.hash = "";
3307
+ if (redact(target) !== target.href) return {
3308
+ fromCache: false,
3309
+ notes: ["the URL holds a secret, so the crawler does not send it to a web archive"]
3310
+ };
3311
+ const despite = options.archiveDespiteRobots === true;
3312
+ const refused = "the current robots.txt of the site disallows the page for this agent, so the crawler gives no archived copy (archiveDespiteRobots permits one)";
3313
+ if (reason === "robots" && !despite) return {
3314
+ fromCache: false,
3315
+ notes: [refused]
3316
+ };
3317
+ let rules;
3318
+ try {
3319
+ rules = await runtime.crawler.robotsAllow(target.href, {
3320
+ onBehalfOf: options.onBehalfOf,
3321
+ scope: options.scope,
3322
+ labels: options.labels,
3323
+ signal: options.signal
3324
+ });
3325
+ } catch {
3326
+ return {
3327
+ fromCache: false,
3328
+ notes: ["the fallback stopped before it read robots.txt"]
3329
+ };
3330
+ }
3331
+ if (!rules.allowed && (rules.note ?? "").startsWith(ROBOTS_DISALLOW) && !despite) return {
3332
+ fromCache: false,
3333
+ notes: [refused]
3334
+ };
3335
+ const signalLines = rules.signals ?? [];
3336
+ const known = runtime.index.get(archivedDocId(target.href));
3337
+ const archivedKnown = known === void 0 ? void 0 : archivedOf(known.meta);
3338
+ if (known !== void 0 && archivedKnown !== void 0 && typeof options.maxAgeMs === "number" && Date.parse(runtime.now()) - Date.parse(known.fetched_at) < options.maxAgeMs) return {
3339
+ doc: known,
3340
+ archived: archivedKnown,
3341
+ fromCache: true,
3342
+ notes: []
3343
+ };
3344
+ const notes = [];
3345
+ for (const ask of [fromWayback, fromCommonCrawl]) {
3346
+ if (options.signal?.aborted === true) break;
3347
+ const answer = await ask(runtime, target.href, signalLines, options);
3348
+ if ("note" in answer) notes.push(answer.note);
3349
+ else return {
3350
+ doc: answer.doc,
3351
+ archived: answer.archived,
3352
+ fromCache: answer.fromCache,
3353
+ notes
3354
+ };
3355
+ }
3356
+ return {
3357
+ fromCache: false,
3358
+ notes: [`no archived copy: ${notes.join("; ")}`]
3359
+ };
3360
+ };
3361
+ /**
3362
+ * This function gives the result of a page after a refusal, with an archived copy when an archive
3363
+ * had one. The result keeps the outcome and the status of the live fetch. It gives undefined when
3364
+ * the refusal gets no copy, and `notes` tell why.
3365
+ */
3366
+ const archivedPage = async (runtime, url, live, options) => {
3367
+ const reason = fallbackReason(live);
3368
+ if (reason === void 0) return { notes: [] };
3369
+ const answer = await archivedCopy(runtime, url, reason, options);
3370
+ if (answer.doc === void 0 || answer.archived === void 0) return { notes: answer.notes };
3371
+ const doc = runtime.index.get(answer.doc.doc) ?? answer.doc;
3372
+ const archived = archivedOf(doc.meta) ?? answer.archived;
3373
+ const held = isHeldBack(doc);
3374
+ const types = doc.meta["jsonld_types"];
3375
+ const links = doc.meta["links"];
3376
+ const note = [
3377
+ live.fetched.note,
3378
+ live.error,
3379
+ ...answer.notes,
3380
+ archivedNote(archived),
3381
+ ...held ? [HELD_BACK] : []
3382
+ ].filter((part) => part !== void 0 && part.length > 0);
3383
+ return {
3384
+ page: {
3385
+ url,
3386
+ doc: doc.doc,
3387
+ outcome: live.fetched.outcome,
3388
+ status: live.fetched.status,
3389
+ title: held ? null : doc.title,
3390
+ chars: doc.text.length,
3391
+ rendered: false,
3392
+ structured: Array.isArray(types) ? types : [],
3393
+ links: Array.isArray(links) ? links.length : 0,
3394
+ captures: [...doc.captures],
3395
+ capture: doc.capture,
3396
+ capture_count: doc.capture_count,
3397
+ fetched_at: doc.fetched_at,
3398
+ from_cache: answer.fromCache,
3399
+ held_back: held,
3400
+ archived,
3401
+ note: note.join("; ")
3402
+ },
3403
+ notes: []
3404
+ };
3405
+ };
3406
+ //#endregion
3885
3407
  //#region src/runtime.ts
3886
3408
  /** This function gives the document id of a web page. Each page has one id. */
3887
3409
  const pageDocId = (url) => `url:${canonicalUrl(url)}`;
@@ -3983,6 +3505,7 @@ const pageExtractor = (body, capture) => {
3983
3505
  needs_render: page.needsRender,
3984
3506
  render_reason: page.renderReason,
3985
3507
  canonical: page.canonical,
3508
+ ...page.feeds !== void 0 && page.feeds.length > 0 ? { feeds: page.feeds } : {},
3986
3509
  redirect: page.redirect,
3987
3510
  blocked: page.blocked,
3988
3511
  ...page.notes.length > 0 ? { notes: page.notes } : {}
@@ -4042,9 +3565,10 @@ const pdfDocs = async (body, url) => {
4042
3565
  }];
4043
3566
  };
4044
3567
  var Extractors = class {
4045
- byRecipe = /* @__PURE__ */ new Map([["page", pageExtractor]]);
3568
+ byRecipe = /* @__PURE__ */ new Map([["page", pageExtractor], ["archive-copy", archiveExtractor]]);
3569
+ replaceable = /* @__PURE__ */ new Set(["archive-copy"]);
4046
3570
  register(recipe, extractor) {
4047
- if (this.byRecipe.has(recipe)) throw new Error(`recipe ${recipe} already has an extractor`);
3571
+ if (this.byRecipe.has(recipe) && !this.replaceable.delete(recipe)) throw new Error(`recipe ${recipe} already has an extractor`);
4048
3572
  this.byRecipe.set(recipe, extractor);
4049
3573
  }
4050
3574
  get(recipe) {
@@ -4070,7 +3594,9 @@ const STRUCTURAL_META = [
4070
3594
  "format",
4071
3595
  "pages",
4072
3596
  "ocr_needed",
4073
- "truncated"
3597
+ "truncated",
3598
+ "archived",
3599
+ "archived_kind"
4074
3600
  ];
4075
3601
  /** The note of a document that is held back. */
4076
3602
  const HELD_BACK = "This page asked not to be used as AI input, so its text is held back. Open the URL to read it, or read again with userProvided when a person gave this URL directly (the crawler logs that).";
@@ -4127,7 +3653,7 @@ const indexCapture = async (runtime, fetched, recipe, rendered) => {
4127
3653
  blob: fetched.blob ?? null,
4128
3654
  recipe,
4129
3655
  rendered,
4130
- ai_input: fetched.signals.ai_input,
3656
+ ai_input: doc.ai_input === "no" ? "no" : fetched.signals.ai_input,
4131
3657
  ...fetched.scope !== void 0 ? { scope: fetched.scope } : {},
4132
3658
  ...fetched.labels !== void 0 ? { labels: fetched.labels } : {}
4133
3659
  }, now)) };
@@ -4179,7 +3705,8 @@ const fetchPage = async (runtime, url, options) => {
4179
3705
  job: options.job,
4180
3706
  signal: options.signal,
4181
3707
  scope: options.scope,
4182
- labels: options.labels
3708
+ labels: options.labels,
3709
+ deadline: options.deadline
4183
3710
  });
4184
3711
  let plain = await fetchAndIndex(runtime, plainRequest(url));
4185
3712
  const notes = [];
@@ -4212,7 +3739,8 @@ const fetchPage = async (runtime, url, options) => {
4212
3739
  job: options.job,
4213
3740
  signal: options.signal,
4214
3741
  scope: options.scope,
4215
- labels: options.labels
3742
+ labels: options.labels,
3743
+ deadline: options.deadline
4216
3744
  });
4217
3745
  if (shown.docs.length > 0) {
4218
3746
  docs = shown.docs;
@@ -4221,6 +3749,11 @@ const fetchPage = async (runtime, url, options) => {
4221
3749
  } else notes.push(shown.error ?? shown.fetched.note ?? "the render gave no document");
4222
3750
  }
4223
3751
  }
3752
+ if (options.fallback === "archive" && docs.length === 0) {
3753
+ const fallback = await archivedPage(runtime, url, plain, options);
3754
+ if (fallback.page !== void 0) return fallback.page;
3755
+ notes.push(...fallback.notes);
3756
+ }
4224
3757
  const doc = docs[0] === void 0 ? void 0 : runtime.index.get(docs[0].doc) ?? docs[0];
4225
3758
  const held = doc !== void 0 && isHeldBack(doc);
4226
3759
  const types = doc?.meta["jsonld_types"];
@@ -4251,6 +3784,38 @@ const knownDoc = (runtime, id) => {
4251
3784
  if (doc === void 0) throw new Error(`no stored document ${id}: find one with find(), or fetch its page`);
4252
3785
  return doc;
4253
3786
  };
3787
+ /**
3788
+ * This function gives the ISO 8601 form of a time. A text that is not a time throws a `RangeError`.
3789
+ * A date with no time is the start of that day in UTC.
3790
+ */
3791
+ const isoTime = (name, value) => {
3792
+ const ms = value instanceof Date ? value.getTime() : typeof value === "string" ? Date.parse(value) : NaN;
3793
+ if (!Number.isFinite(ms)) throw new RangeError(`${name} must be a time, such as 2026-10-01T12:00:00Z, not ${JSON.stringify(String(value))}`);
3794
+ return new Date(ms).toISOString();
3795
+ };
3796
+ /**
3797
+ * This function gives the version of a document that was current at a time (ISO 8601). It uses
3798
+ * `ReadingIndex.versionAt` when the index has it. Else it takes the newest version at or before
3799
+ * the time.
3800
+ */
3801
+ const versionAtTime = (runtime, id, at) => {
3802
+ const index = runtime.index;
3803
+ if (index.versionAt !== void 0) return index.versionAt(id, at);
3804
+ const all = index.versions(id, { limit: 1e6 });
3805
+ const pick = all.find((version) => version.fetched_at <= at);
3806
+ return {
3807
+ version: pick === void 0 ? void 0 : index.versionText(id, pick.capture),
3808
+ first: all.at(-1)?.fetched_at
3809
+ };
3810
+ };
3811
+ /** This function gives the note of a time before the first version of a document. */
3812
+ const beforeFirstNote = (id, at, first) => `the document ${id} has no version at ${at} or before${first === void 0 ? "" : `: its first capture is from ${first}`}`;
3813
+ /** This function gives the time of `at`, or undefined. It refuses `capture` and `at` together. */
3814
+ const atOf = (options) => {
3815
+ if (options.at === void 0) return void 0;
3816
+ if (options.capture !== void 0) throw new RangeError("give capture or at, not the two: each one chooses a version");
3817
+ return isoTime("at", options.at);
3818
+ };
4254
3819
  const wholeNumber = (name, value, fallback) => {
4255
3820
  if (value === void 0) return fallback;
4256
3821
  if (!Number.isFinite(value) || value < 0) throw new RangeError(`${name} must be a number of 0 or more, not ${String(value)}`);
@@ -4294,9 +3859,37 @@ const recordOverride = async (runtime, doc, action, marks = {}) => {
4294
3859
  const readDocument = async (runtime, id, options = {}) => {
4295
3860
  const from = wholeNumber("from", options.from, 0);
4296
3861
  const length = wholeNumber("length", options.length, 4e3);
3862
+ const at = atOf(options);
4297
3863
  const doc = knownDoc(runtime, id);
4298
- const version = options.capture === void 0 ? void 0 : runtime.index.versionText(id, options.capture);
3864
+ let version = options.capture === void 0 ? void 0 : runtime.index.versionText(id, options.capture);
4299
3865
  if (options.capture !== void 0 && version === void 0) throw new Error(`the document ${id} has no version from the capture ${options.capture}`);
3866
+ const timed = at === void 0 ? {} : {
3867
+ at,
3868
+ before_first: false
3869
+ };
3870
+ if (at !== void 0) {
3871
+ const found = versionAtTime(runtime, id, at);
3872
+ if (found.version === void 0) return {
3873
+ doc: doc.doc,
3874
+ kind: doc.kind,
3875
+ title: "",
3876
+ url: doc.url,
3877
+ meta: {},
3878
+ from: 0,
3879
+ to: 0,
3880
+ total: 0,
3881
+ text: "",
3882
+ captures: doc.captures,
3883
+ capture: "",
3884
+ fetched_at: "",
3885
+ ai_input: doc.ai_input,
3886
+ held_back: false,
3887
+ note: beforeFirstNote(id, at, found.first),
3888
+ at,
3889
+ before_first: true
3890
+ };
3891
+ version = found.version;
3892
+ }
4300
3893
  const aiInput = doc.ai_input === "no" || version?.ai_input === "no" ? "no" : version?.ai_input ?? doc.ai_input;
4301
3894
  const userProvided = options.userProvided ?? false;
4302
3895
  const held = isHeldBack({ ai_input: aiInput }, userProvided);
@@ -4305,6 +3898,8 @@ const readDocument = async (runtime, id, options = {}) => {
4305
3898
  const start = onBoundary(text, Math.min(from, text.length));
4306
3899
  const end = held ? start : onBoundary(text, Math.min(text.length, start + length));
4307
3900
  const meta = version?.meta ?? doc.meta;
3901
+ const archived = archivedOf(meta);
3902
+ const notes = [...held ? [HELD_BACK] : [], ...archived ? [archivedNote(archived)] : []];
4308
3903
  return {
4309
3904
  doc: doc.doc,
4310
3905
  kind: version?.kind ?? doc.kind,
@@ -4320,7 +3915,24 @@ const readDocument = async (runtime, id, options = {}) => {
4320
3915
  fetched_at: version?.fetched_at ?? doc.fetched_at,
4321
3916
  ai_input: aiInput,
4322
3917
  held_back: held,
4323
- note: held ? HELD_BACK : null
3918
+ note: notes.length > 0 ? notes.join(" ") : null,
3919
+ ...timed,
3920
+ ...archived !== void 0 ? { archived } : {}
3921
+ };
3922
+ };
3923
+ /** This function gives the `archived` field of a result, for the metadata of an archived copy. */
3924
+ const archivedField = (meta) => {
3925
+ const archived = archivedOf(meta);
3926
+ return archived === void 0 ? {} : { archived };
3927
+ };
3928
+ /** This function gives the `archived` field and the note of a result: the note of an archived copy goes after the other note. */
3929
+ const withArchived = (meta, note) => {
3930
+ const archived = archivedOf(meta);
3931
+ if (archived === void 0) return { note };
3932
+ const said = archivedNote(archived);
3933
+ return {
3934
+ note: note === null ? said : `${note}; ${said}`,
3935
+ archived
4324
3936
  };
4325
3937
  };
4326
3938
  /**
@@ -4328,8 +3940,9 @@ const readDocument = async (runtime, id, options = {}) => {
4328
3940
  * if `userProvided` opened a held-back version, so that `verifyQuote` logs the override.
4329
3941
  */
4330
3942
  const examineQuote = (runtime, id, quote, options) => {
3943
+ const at = atOf(options);
4331
3944
  const doc = knownDoc(runtime, id);
4332
- const pinned = options.capture === void 0 ? void 0 : runtime.index.versionText(id, options.capture);
3945
+ let pinned = options.capture === void 0 ? void 0 : runtime.index.versionText(id, options.capture);
4333
3946
  if (options.capture !== void 0 && pinned === void 0) throw new Error(`the document ${id} has no version from the capture ${options.capture}`);
4334
3947
  const userProvided = options.userProvided ?? false;
4335
3948
  const base = {
@@ -4337,23 +3950,48 @@ const examineQuote = (runtime, id, quote, options) => {
4337
3950
  url: doc.url,
4338
3951
  captures: doc.captures
4339
3952
  };
3953
+ const timed = at === void 0 ? {} : {
3954
+ at,
3955
+ before_first: false
3956
+ };
3957
+ const nothing = {
3958
+ found: false,
3959
+ start: null,
3960
+ end: null,
3961
+ excerpt: null,
3962
+ capture: null,
3963
+ blob: null,
3964
+ fetched_at: null,
3965
+ stale: false,
3966
+ in_full_text: false,
3967
+ count: 0
3968
+ };
3969
+ if (at !== void 0) {
3970
+ const found = versionAtTime(runtime, id, at);
3971
+ if (found.version === void 0) return {
3972
+ result: {
3973
+ ...base,
3974
+ ...nothing,
3975
+ complete: true,
3976
+ held_back: false,
3977
+ note: beforeFirstNote(id, at, found.first),
3978
+ at,
3979
+ before_first: true
3980
+ },
3981
+ override: false
3982
+ };
3983
+ pinned = found.version;
3984
+ }
4340
3985
  const aiInput = doc.ai_input === "no" || pinned?.ai_input === "no" ? "no" : doc.ai_input;
4341
3986
  if (isHeldBack({ ai_input: aiInput }, userProvided)) return {
4342
3987
  result: {
4343
3988
  ...base,
4344
- found: false,
4345
- start: null,
4346
- end: null,
4347
- excerpt: null,
4348
- capture: null,
4349
- blob: null,
4350
- fetched_at: null,
4351
- stale: false,
4352
- in_full_text: false,
3989
+ ...nothing,
4353
3990
  complete: false,
4354
- count: 0,
4355
3991
  held_back: true,
4356
- note: HELD_BACK
3992
+ note: HELD_BACK,
3993
+ ...timed,
3994
+ ...archivedField(pinned?.meta ?? doc.meta)
4357
3995
  },
4358
3996
  override: false
4359
3997
  };
@@ -4386,7 +4024,8 @@ const examineQuote = (runtime, id, quote, options) => {
4386
4024
  complete: true,
4387
4025
  count: hit.count,
4388
4026
  held_back: false,
4389
- note: stale ? "only an earlier capture of the document holds the quote" : null
4027
+ ...withArchived(version.meta, stale ? "only an earlier capture of the document holds the quote" : null),
4028
+ ...timed
4390
4029
  },
4391
4030
  override
4392
4031
  };
@@ -4408,7 +4047,8 @@ const examineQuote = (runtime, id, quote, options) => {
4408
4047
  complete: !incomplete,
4409
4048
  count: 0,
4410
4049
  held_back: false,
4411
- note: notes.length > 0 ? notes.join("; ") : null
4050
+ ...withArchived(current?.meta ?? doc.meta, notes.length > 0 ? notes.join("; ") : null),
4051
+ ...timed
4412
4052
  },
4413
4053
  override
4414
4054
  };
@@ -4434,17 +4074,36 @@ const verifyQuote = async (runtime, id, quote, options = {}) => {
4434
4074
  };
4435
4075
  /**
4436
4076
  * This function searches the stored documents. The syntax of the query is the syntax of
4437
- * `parseQuery`. A held-back document is not in the results.
4077
+ * `parseQuery`. A held-back document is not in the results. The option `rank` chooses the
4078
+ * ranking (`RankMode`), and the default is `exact`.
4438
4079
  */
4439
4080
  const findDocuments = async (runtime, q, options = {}) => {
4440
4081
  await Promise.resolve();
4441
4082
  return runtime.index.find(q, {
4442
4083
  limit: options.limit ?? 10,
4443
- kind: options.kind ?? null
4084
+ kind: options.kind ?? null,
4085
+ ...options.rank !== void 0 ? { rank: options.rank } : {}
4086
+ }).map((hit) => {
4087
+ if (hit.kind !== "archived") return hit;
4088
+ const meta = runtime.index.get(hit.doc)?.meta;
4089
+ return meta === void 0 ? hit : {
4090
+ ...hit,
4091
+ ...archivedField(meta)
4092
+ };
4444
4093
  });
4445
4094
  };
4446
4095
  const RENDERED_MARK = "#rendered";
4447
4096
  const MAX_ERRORS = 20;
4097
+ /**
4098
+ * The crawler reads the captures of these recipes for itself. They are robots.txt, the sitemaps
4099
+ * and feeds of `discover`, and the indexes of the web archives. They give no document.
4100
+ */
4101
+ const CRAWLER_RECIPES = /* @__PURE__ */ new Set([
4102
+ "robots.txt",
4103
+ "sitemap",
4104
+ "feed",
4105
+ "archive-index"
4106
+ ]);
4448
4107
  /** This function gives the URL of a capture and its variant. A URL of version 0.1 can end in `#rendered`. */
4449
4108
  const captureTarget = (payload) => {
4450
4109
  if (payload.url.endsWith(RENDERED_MARK)) return {
@@ -4487,7 +4146,7 @@ const replayLatest = (build, envelope, payload) => {
4487
4146
  });
4488
4147
  return;
4489
4148
  }
4490
- if (payload.cut === true || payload.unasked === true) return;
4149
+ if (payload.cut === true || payload.unasked === true || payload.range !== void 0) return;
4491
4150
  const previous = build.latest(url, variant);
4492
4151
  const revisit = envelope.kind === "revisit" || payload.outcome === "not-modified";
4493
4152
  const truncated = payload.truncated === true;
@@ -4527,7 +4186,7 @@ const replayRecord = async (runtime, target, envelope, tally) => {
4527
4186
  replayLatest(target, envelope, payload);
4528
4187
  const extractor = runtime.extractors.get(payload.recipe);
4529
4188
  if (extractor === void 0) {
4530
- if (payload.recipe !== "robots.txt") tally.unknown.add(payload.recipe);
4189
+ if (!CRAWLER_RECIPES.has(payload.recipe)) tally.unknown.add(payload.recipe);
4531
4190
  return;
4532
4191
  }
4533
4192
  const body = await runtime.blobs.get(payload.blob);
@@ -4560,7 +4219,7 @@ const replayRecord = async (runtime, target, envelope, tally) => {
4560
4219
  blob: payload.blob,
4561
4220
  recipe: payload.recipe,
4562
4221
  rendered: variant === "rendered",
4563
- ai_input: payload.signals?.ai_input ?? "unset",
4222
+ ai_input: doc.ai_input === "no" ? "no" : payload.signals?.ai_input ?? "unset",
4564
4223
  ...envelope.scope !== void 0 ? { scope: envelope.scope } : {},
4565
4224
  ...envelope.labels !== void 0 ? { labels: envelope.labels } : {}
4566
4225
  }, envelope.received_at);
@@ -4615,6 +4274,7 @@ const missed = (runtime, envelope) => {
4615
4274
  const entry = runtime.index.latest(url, variant);
4616
4275
  if (payload.outcome === "redirect") return entry === void 0 || entry.fetched_at < fetchedAt;
4617
4276
  if (typeof payload.blob !== "string" || payload.outcome !== "ok" && payload.outcome !== "not-modified") return false;
4277
+ if (payload.range !== void 0) return runtime.extractors.get(payload.recipe) !== void 0 && !runtime.index.hasVersion(envelope.id);
4618
4278
  if (entry === void 0 || entry.fetched_at < fetchedAt) return true;
4619
4279
  return runtime.extractors.get(payload.recipe) !== void 0 && !runtime.index.hasVersion(envelope.id);
4620
4280
  };
@@ -4659,230 +4319,6 @@ const catchUpIndex = async (runtime) => {
4659
4319
  };
4660
4320
  };
4661
4321
  //#endregion
4662
- //#region src/open.ts
4663
- var open_exports = /* @__PURE__ */ __exportAll({
4664
- BOT_PAGE: () => BOT_PAGE,
4665
- DEFAULT_TOKEN: () => DEFAULT_TOKEN,
4666
- createRuntime: () => createRuntime,
4667
- defaultUserAgent: () => defaultUserAgent,
4668
- durationMs: () => durationMs,
4669
- isMailbox: () => isMailbox,
4670
- openCrawler: () => openCrawler
4671
- });
4672
- /**
4673
- * This function gives the default `User-Agent` header. The header names the crawler, its version
4674
- * and the page about it, so a site owner can find out what made each request.
4675
- */
4676
- const defaultUserAgent = (contact) => `${DEFAULT_TOKEN}/${VERSION} (+${BOT_PAGE}${contact === void 0 ? "" : `; ${contact}`})`;
4677
- /** A mailbox is `name@host`, or a name and `<name@host>`. A URL is not a mailbox, also when it holds an `@`. */
4678
- const MAILBOX = /^(?:[^\s<>@:/]+@[^\s<>@/]+|[^<>]*<[^\s<>@:/]+@[^\s<>@/]+>)$/;
4679
- /**
4680
- * This function tells if a contact is a mailbox. The `From` header holds only a mailbox (RFC 9110,
4681
- * section 10.1.2). Thus the crawler sends a mailbox in `From` and in the `User-Agent`, and a URL in
4682
- * the `User-Agent` only.
4683
- */
4684
- const isMailbox = (contact) => MAILBOX.test(contact.trim());
4685
- const UNITS = {
4686
- ms: 1,
4687
- s: 1e3,
4688
- m: 6e4,
4689
- h: 36e5,
4690
- d: 864e5,
4691
- w: 6048e5
4692
- };
4693
- /**
4694
- * This function changes a duration into milliseconds. The units are `ms`, `s`, `m` (minutes), `h`,
4695
- * `d` and `w`, in any case. The unit `M` is an error, because a month is not a unit. A bare `0`
4696
- * is zero. The function throws an error for a text that it cannot read.
4697
- */
4698
- const durationMs = (duration) => {
4699
- if (typeof duration === "number") {
4700
- if (!Number.isFinite(duration) || duration < 0) throw new Error(`not a duration: ${String(duration)}`);
4701
- return duration;
4702
- }
4703
- if (/^\s*0+(\.0+)?\s*$/.test(duration)) return 0;
4704
- const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)\s*$/i.exec(duration);
4705
- const unit = match?.[2];
4706
- const factor = unit === void 0 || unit === "M" ? void 0 : UNITS[unit.toLowerCase()];
4707
- if (match === null || factor === void 0) throw new Error(`not a duration: "${duration}" (write a number and ms, s, m, h, d or w; m is minutes)`);
4708
- return Number(match[1]) * factor;
4709
- };
4710
- /** The parts of a store that can be a symbolic link to a different place only by mistake or by attack. */
4711
- const STORE_PARTS = [
4712
- "captures",
4713
- "blobs",
4714
- "index.sqlite",
4715
- "hosts.sqlite"
4716
- ];
4717
- /**
4718
- * This function makes the folder of a store for the owner only, where the system has modes. It
4719
- * refuses a store folder of a different user, and a part of the store that is a symbolic link.
4720
- * The store folder itself can be a link, for example to a larger disk.
4721
- */
4722
- const guardStore = (dir) => {
4723
- mkdirSync(dir, {
4724
- recursive: true,
4725
- mode: 448
4726
- });
4727
- const real = lstatSync(dir).isSymbolicLink() ? realpathSync(dir) : dir;
4728
- const info = statSync(real);
4729
- if (!info.isDirectory()) throw new Error(`the store ${dir} is not a folder`);
4730
- 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`);
4731
- for (const part of STORE_PARTS) {
4732
- let link = false;
4733
- try {
4734
- link = lstatSync(join(dir, part)).isSymbolicLink();
4735
- } catch {}
4736
- if (link) throw new Error(`${part} in the store ${dir} is a symbolic link: the crawler does not follow it`);
4737
- }
4738
- };
4739
- /**
4740
- * This function makes a runtime. It is the one place that puts the log, the blob store, the index,
4741
- * the host state, the crawler and the extractors together. The function `openCrawler` uses it.
4742
- * An application that needs the parts uses it too. The runtime and the crawler share one clock.
4743
- */
4744
- const createRuntime = (options) => {
4745
- const dir = options.dir;
4746
- 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");
4747
- const contact = options.contact;
4748
- if (contact !== void 0 && !/^[\t -~]*$/.test(contact)) throw new Error(`contact "${contact}" has a character that an HTTP header cannot hold: use printable ASCII`);
4749
- if (dir !== void 0) guardStore(dir);
4750
- const owned = [];
4751
- const log = options.log ?? new CaptureLog(join(dir, "captures"));
4752
- const blobs = options.blobs ?? new FileBlobStore(join(dir, "blobs"));
4753
- let index = options.index;
4754
- if (index === void 0) {
4755
- const made = new DocIndex(join(dir, "index.sqlite"));
4756
- owned.push(() => made.close());
4757
- index = made;
4758
- }
4759
- let hostState = options.hostState;
4760
- if (hostState === void 0) {
4761
- if (dir === void 0) hostState = new MemoryHostStateStore();
4762
- else {
4763
- const made = new SqliteHostStateStore(join(dir, "hosts.sqlite"));
4764
- owned.push(() => made.close());
4765
- hostState = made;
4766
- }
4767
- }
4768
- const clock = options.clock ?? realClock;
4769
- const renderer = options.renderer === false ? void 0 : options.renderer ?? checkedRenderer(playwrightRenderer());
4770
- const crawler = new Crawler({
4771
- token: options.token ?? "PoliteCrawler",
4772
- userAgent: options.userAgent ?? defaultUserAgent(contact),
4773
- from: contact !== void 0 && isMailbox(contact) ? contact.trim() : void 0,
4774
- log,
4775
- blobs,
4776
- index,
4777
- renderer,
4778
- maxActive: options.maxActive,
4779
- maxRenders: options.maxRenders,
4780
- dispatcher: options.dispatcher,
4781
- egress: options.egress,
4782
- politeness: options.politeness,
4783
- hostState,
4784
- clock,
4785
- random: options.random,
4786
- scope: options.scope,
4787
- scopeRank: options.scopeRank,
4788
- obeyDefaultToken: options.obeyDefaultToken,
4789
- onEvent: options.onEvent
4790
- });
4791
- const extractors = new Extractors();
4792
- for (const [recipe, extractor] of Object.entries(options.extractors ?? {})) extractors.register(recipe, extractor);
4793
- let closing;
4794
- return {
4795
- crawler,
4796
- index,
4797
- blobs,
4798
- captures: log,
4799
- log,
4800
- extractors,
4801
- hostState,
4802
- now: () => new Date(clock.now()).toISOString(),
4803
- close() {
4804
- closing ??= (async () => {
4805
- await crawler.close();
4806
- for (const close of owned) await close();
4807
- })();
4808
- return closing;
4809
- }
4810
- };
4811
- };
4812
- /**
4813
- * This function opens a crawler on a folder. The folder keeps the capture log (`captures/`), the
4814
- * response bodies (`blobs/`), the search index (`index.sqlite`) and the state of each host
4815
- * (`hosts.sqlite`). The index is a projection: `reindex` makes it again from the log and the bodies.
4816
- * At open, `catchUpIndex` rebuilds an index of an earlier version, and gives an index that is
4817
- * behind the log the captures that it missed.
4818
- *
4819
- * Each method gives a promise, and none throws before its promise. After `close`, each method
4820
- * rejects.
4821
- */
4822
- const openCrawler = (options) => {
4823
- const runtime = createRuntime(options);
4824
- let closed = false;
4825
- let rebuilt;
4826
- const ready = existsSync(join(options.dir, "captures")) ? catchUpIndex(runtime).then((done) => {
4827
- if (done?.mode === "rebuild") {
4828
- const { mode, ...counts } = done;
4829
- rebuilt = mode === "rebuild" ? counts : void 0;
4830
- }
4831
- }, () => void 0) : Promise.resolve();
4832
- const live = async () => {
4833
- if (closed) throw new Error("the crawler is closed");
4834
- await ready;
4835
- if (closed) throw new Error("the crawler is closed");
4836
- };
4837
- return {
4838
- runtime,
4839
- async fetch(url, fetchOptions = {}) {
4840
- await live();
4841
- rebuilt = void 0;
4842
- const maxAge = fetchOptions.maxAge ?? "24h";
4843
- return fetchPage(runtime, url, {
4844
- render: fetchOptions.render ?? "auto",
4845
- maxAgeMs: maxAge === "auto" ? "auto" : durationMs(maxAge),
4846
- onBehalfOf: [...options.onBehalfOf ?? [], ...fetchOptions.onBehalfOf ?? []],
4847
- signal: fetchOptions.signal,
4848
- scope: fetchOptions.scope,
4849
- labels: fetchOptions.labels
4850
- });
4851
- },
4852
- async find(q, findOptions = {}) {
4853
- await live();
4854
- return findDocuments(runtime, q, findOptions);
4855
- },
4856
- async read(id, readOptions = {}) {
4857
- await live();
4858
- return readDocument(runtime, id, readOptions);
4859
- },
4860
- async verify(id, quote, verifyOptions = {}) {
4861
- await live();
4862
- return verifyQuote(runtime, id, quote, verifyOptions);
4863
- },
4864
- async versions(id, versionOptions = {}) {
4865
- await live();
4866
- const held = runtime.index.get(id)?.ai_input === "no";
4867
- return runtime.index.versions(id, versionOptions).map((version) => held || version.ai_input === "no" ? {
4868
- ...version,
4869
- title: ""
4870
- } : version);
4871
- },
4872
- async reindex() {
4873
- await live();
4874
- const done = rebuilt;
4875
- rebuilt = void 0;
4876
- return done ?? reindex(runtime);
4877
- },
4878
- async close() {
4879
- closed = true;
4880
- await ready;
4881
- await runtime.close();
4882
- }
4883
- };
4884
- };
4885
- //#endregion
4886
- export { DEFAULT_SECRET_PARAMS as A, readPdf as C, locate as D, DocIndex as E, FileBlobStore as F, makeRecord as M, EgressRefused as N, CAPTURE_SCHEMA as O, egressLookup as P, isPdf as S, SqliteHostStateStore as T, readDocument as _, openCrawler as a, renderAndIndex as b, HELD_BACK as c, fetchPage as d, findDocuments as f, pageExtractor as g, pageDocId as h, isMailbox as i, renderedKey as j, Crawler as k, catchUpIndex as l, isHeldBack as m, defaultUserAgent as n, open_exports as o, heldBackMeta as p, durationMs as r, Extractors as s, createRuntime as t, fetchAndIndex as u, recordOverride as v, CaptureLog as w, verifyQuote as x, reindex as y };
4322
+ export { Crawler as A, readPdf as C, parseQuery as D, locate as E, sha256 as F, stableStringify as I, EgressRefused as L, DEFAULT_SECRET_PARAMS as M, renderedKey as N, snippetOf as O, makeRecord as P, egressLookup as R, isPdf as S, foldText as T, versionAtTime as _, fetchPage as a, archivedNote as b, isHeldBack as c, pageExtractor as d, readDocument as f, verifyQuote as g, renderAndIndex as h, fetchAndIndex as i, DEADLINE_NOTE as j, CAPTURE_SCHEMA as k, isoTime as l, reindex as m, HELD_BACK as n, findDocuments as o, recordOverride as p, catchUpIndex as r, heldBackMeta as s, Extractors as t, pageDocId as u, archiveExtractor as v, fold as w, archivedOf as x, archivedDocId as y };
4887
4323
 
4888
- //# sourceMappingURL=open-CJR4b44v.mjs.map
4324
+ //# sourceMappingURL=runtime-k8mo1_7U.mjs.map