polite-crawler 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +1087 -15
  2. package/build/browser-CKRNbIDT.mjs +62 -0
  3. package/build/browser-CKRNbIDT.mjs.map +1 -0
  4. package/build/cli-daemon--cTmq0Z5.mjs +38 -0
  5. package/build/cli-daemon--cTmq0Z5.mjs.map +1 -0
  6. package/build/cli.mjs +1065 -123
  7. package/build/cli.mjs.map +1 -1
  8. package/build/daemon-DP4EqjFL.d.mts +3772 -0
  9. package/build/daemon-fZTE24P5.mjs +515 -0
  10. package/build/daemon-fZTE24P5.mjs.map +1 -0
  11. package/build/diff-NQcQqKe9.mjs +648 -0
  12. package/build/diff-NQcQqKe9.mjs.map +1 -0
  13. package/build/extract-ByFfc8Zk.mjs +1793 -0
  14. package/build/extract-ByFfc8Zk.mjs.map +1 -0
  15. package/build/hours.d.mts +26 -0
  16. package/build/hours.mjs +48 -1
  17. package/build/hours.mjs.map +1 -1
  18. package/build/{html-nWYFVsRU.d.mts → html-BACljlkf.d.mts} +8 -9
  19. package/build/{html-oNpGAHwi.mjs → html-DkQUAE_d.mjs} +18 -39
  20. package/build/html-DkQUAE_d.mjs.map +1 -0
  21. package/build/html.d.mts +2 -1
  22. package/build/html.mjs +3 -1
  23. package/build/index.d.mts +582 -12
  24. package/build/index.mjs +903 -6
  25. package/build/index.mjs.map +1 -0
  26. package/build/log-Dx-wXjJl.mjs +342 -0
  27. package/build/log-Dx-wXjJl.mjs.map +1 -0
  28. package/build/loopback-C_QYowSf.mjs +22 -0
  29. package/build/loopback-C_QYowSf.mjs.map +1 -0
  30. package/build/mcp.d.mts +164 -5
  31. package/build/mcp.mjs +1115 -42
  32. package/build/mcp.mjs.map +1 -1
  33. package/build/ollama-BJxkjny4.mjs +160 -0
  34. package/build/ollama-BJxkjny4.mjs.map +1 -0
  35. package/build/open-C4fhXJgD.mjs +4544 -0
  36. package/build/open-C4fhXJgD.mjs.map +1 -0
  37. package/build/{browser-gJ_h4AhB.mjs → render-C1CUraWF.mjs} +31 -74
  38. package/build/render-C1CUraWF.mjs.map +1 -0
  39. package/build/rolldown-runtime-D7D4PA-g.mjs +13 -0
  40. package/build/{open-DYZ7KL2A.mjs → runtime-JToRO63o.mjs} +986 -1657
  41. package/build/runtime-JToRO63o.mjs.map +1 -0
  42. package/build/transformers-BQMlaGj4.mjs +133 -0
  43. package/build/transformers-BQMlaGj4.mjs.map +1 -0
  44. package/build/urls-BHFeSyEK.mjs +62 -0
  45. package/build/urls-BHFeSyEK.mjs.map +1 -0
  46. package/build/urls-BTDSgDLC.d.mts +16 -0
  47. package/build/version-ntbY-6wh.mjs +38 -0
  48. package/build/version-ntbY-6wh.mjs.map +1 -0
  49. package/build/wacz-BFNG42Kt.mjs +275 -0
  50. package/build/wacz-BFNG42Kt.mjs.map +1 -0
  51. package/build/warc-CVS0t1fW.mjs +728 -0
  52. package/build/warc-CVS0t1fW.mjs.map +1 -0
  53. package/build/webbotauth-CjJdTAfE.mjs +568 -0
  54. package/build/webbotauth-CjJdTAfE.mjs.map +1 -0
  55. package/package.json +10 -2
  56. package/record.schema.json +15 -1
  57. package/build/browser-gJ_h4AhB.mjs.map +0 -1
  58. package/build/html-oNpGAHwi.mjs.map +0 -1
  59. package/build/open-DYZ7KL2A.mjs.map +0 -1
  60. package/build/open-eHcriTds.d.mts +0 -1499
  61. package/build/version-no12qgos.mjs +0 -11
  62. package/build/version-no12qgos.mjs.map +0 -1
@@ -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
+ import { n as DEFAULT_TOKEN } from "./version-ntbY-6wh.mjs";
2
3
  import { classify, isClientError, parseHttpDate } from "./classify.mjs";
3
- import { DEFAULT_POLICY, HostScheduler, HostStopped, MAX_STOP_MS, MemoryHostStateStore, checkPolicy, equalJitterMs, nextValidatorMemo, policyFor, realClock, restAfter, validatorsToSend } from "./politeness.mjs";
4
+ import { DEFAULT_POLICY, HostScheduler, HostStopped, MAX_STOP_MS, checkPolicy, equalJitterMs, policyFor, realClock, restAfter, validatorsToSend } from "./politeness.mjs";
4
5
  import { decide, isProductToken, robotsFromResponse, robotsFromStatus, signalsFor, unreachableRobots } from "./robots.mjs";
5
6
  import { NO_SIGNALS, metaSignals, readSignals } from "./signals.mjs";
6
- import { n as DEFAULT_TOKEN, r as VERSION, t as BOT_PAGE } from "./version-no12qgos.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";
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);
@@ -1223,11 +1159,41 @@ var Crawler = class {
1223
1159
  fetchedAt: entry.fetched_at
1224
1160
  } };
1225
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
+ }
1226
1190
  requestHeaders(request_) {
1227
1191
  const headers = {};
1228
1192
  for (const [name, value] of Object.entries(request_.headers ?? {})) {
1229
1193
  const lower = name.toLowerCase();
1230
- 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;
1231
1197
  }
1232
1198
  headers["accept"] = request_.accept ?? headers["accept"] ?? "*/*";
1233
1199
  headers["user-agent"] = this.options.userAgent;
@@ -1416,7 +1382,7 @@ var Crawler = class {
1416
1382
  return {
1417
1383
  ...closedResult(request_.url),
1418
1384
  outcome: "error",
1419
- note: `crawler error: ${messageOf$2(error)}`
1385
+ note: `crawler error: ${messageOf$1(error)}`
1420
1386
  };
1421
1387
  }
1422
1388
  }
@@ -1436,7 +1402,11 @@ var Crawler = class {
1436
1402
  request_.onBehalfOf ?? null,
1437
1403
  request_.job ?? null,
1438
1404
  request_.scope ?? null,
1439
- request_.labels ?? null
1405
+ request_.labels ?? null,
1406
+ request_.deadline ?? null,
1407
+ request_.range ?? null,
1408
+ request_.signalLines ?? null,
1409
+ request_.originalHeaderPrefix ?? null
1440
1410
  ]);
1441
1411
  const running = this.inFlight.get(key);
1442
1412
  if (running !== void 0) return running;
@@ -1479,7 +1449,7 @@ var Crawler = class {
1479
1449
  good: cached?.good
1480
1450
  };
1481
1451
  entry.verdict = this.fetchRobots(target, policy, waiting).catch((error) => ({
1482
- robots: unreachableRobots(`robots.txt failed: ${messageOf$2(error)}`),
1452
+ robots: unreachableRobots(`robots.txt failed: ${messageOf$1(error)}`),
1483
1453
  capture: void 0,
1484
1454
  retryAfterMs: void 0
1485
1455
  })).then(async (read) => {
@@ -1514,13 +1484,63 @@ var Crawler = class {
1514
1484
  * come from its memory, or from a fetch of `/robots.txt` with its redirects. A robots.txt that
1515
1485
  * the crawler cannot read gives rules that permit nothing, and `unreachable` gives the reason.
1516
1486
  */
1517
- async readRobots(url) {
1487
+ async readRobots(url, marks = {}) {
1518
1488
  const target = new URL(url);
1519
1489
  if (!WEB_PROTOCOLS.has(target.protocol)) return {
1520
1490
  robots: unreachableRobots("not an http(s) URL"),
1521
1491
  capture: void 0
1522
1492
  };
1523
- 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
+ };
1524
1544
  }
1525
1545
  /** This method reads the stored copy of a robots.txt, when it is younger than the limit of the policy. */
1526
1546
  async storedRobots(target, policy) {
@@ -1610,21 +1630,23 @@ var Crawler = class {
1610
1630
  return request_.labels;
1611
1631
  }
1612
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)}`;
1613
1635
  const admission = await this.admit({
1614
1636
  transport: "http",
1615
1637
  url: request_.url,
1616
1638
  mode: request_.mode,
1617
1639
  base,
1618
1640
  secretParams,
1619
- 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,
1620
1642
  onBehalfOf: request_.onBehalfOf,
1621
1643
  scope: request_.scope,
1622
1644
  labels: request_.labels,
1623
- 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)
1624
1646
  });
1625
1647
  if ("answer" in admission) return admission.answer;
1626
1648
  const { target, url, host, policy, check, robotsLines, crawlDelaySeconds, skip } = admission.admitted;
1627
- const maxBytes = request_.maxBytes ?? policy.maxBytes;
1649
+ const maxBytes = Math.min(request_.maxBytes ?? policy.maxBytes, range?.length ?? Infinity);
1628
1650
  const rest = (elapsedMs) => ({
1629
1651
  kind: request_.mode,
1630
1652
  elapsedMs,
@@ -1634,12 +1656,13 @@ var Crawler = class {
1634
1656
  const robotsFile = request_.recipe === "robots.txt";
1635
1657
  try {
1636
1658
  return await this.scheduler.run(host, async () => {
1637
- 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;
1638
1660
  if (again !== void 0) return {
1639
1661
  value: again,
1640
1662
  delayMs: 0
1641
1663
  };
1642
- const latest = this.lookup(url);
1664
+ this.beforeDeadline(request_.deadline);
1665
+ const latest = range === void 0 ? this.lookup(url) : void 0;
1643
1666
  const usable = latest !== void 0 && latest.truncated !== true && varyMatches(latest.vary_key, sent) ? latest : void 0;
1644
1667
  let stored;
1645
1668
  if (usable?.blob && (usable.etag || usable.last_modified)) stored = await this.options.blobs.get(usable.blob);
@@ -1654,11 +1677,20 @@ var Crawler = class {
1654
1677
  if (send.etag && usable?.etag) headers["if-none-match"] = usable.etag;
1655
1678
  if (send.lastModified && usable?.last_modified) headers["if-modified-since"] = usable.last_modified;
1656
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 ?? {};
1657
1689
  const started = this.clock.now();
1658
1690
  const timeout = AbortSignal.timeout(policy.requestTimeoutMs);
1659
1691
  const abort = AbortSignal.any([timeout, signal]);
1660
1692
  const cancelled = () => request_.signal?.aborted === true;
1661
- 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)}`;
1662
1694
  this.emit({
1663
1695
  type: "request",
1664
1696
  url,
@@ -1708,7 +1740,9 @@ var Crawler = class {
1708
1740
  outcome: "error",
1709
1741
  note,
1710
1742
  elapsed_ms: elapsedMs,
1711
- ...check ? { robots_check: check } : {}
1743
+ ...check ? { robots_check: check } : {},
1744
+ ...rangeText !== void 0 ? { range: rangeText } : {},
1745
+ ...signedFacts
1712
1746
  }, url, marks);
1713
1747
  return {
1714
1748
  value: { fetched: {
@@ -1762,7 +1796,7 @@ var Crawler = class {
1762
1796
  blob = (await this.options.blobs.put(body)).hash;
1763
1797
  } catch (error) {
1764
1798
  outcome = "error";
1765
- note = `blob store: ${messageOf$2(error)}`;
1799
+ note = `blob store: ${messageOf$1(error)}`;
1766
1800
  body = void 0;
1767
1801
  result = "neutral";
1768
1802
  }
@@ -1791,7 +1825,16 @@ var Crawler = class {
1791
1825
  }
1792
1826
  }
1793
1827
  const revisit = outcome === "not-modified" && usable !== void 0;
1794
- 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")));
1795
1838
  if (revisit) {
1796
1839
  const merged = signals.ai_input === "no" || usable.ai_input === "no" ? "no" : signals.ai_input !== "unset" ? signals.ai_input : usable.ai_input;
1797
1840
  signals = {
@@ -1852,10 +1895,12 @@ var Crawler = class {
1852
1895
  ...freshness !== null ? { fresh_until: freshness } : {},
1853
1896
  ...varyKey !== null ? { vary_key: varyKey } : {},
1854
1897
  ...location !== void 0 ? { location } : {},
1855
- ...note !== void 0 ? { note } : {}
1898
+ ...note !== void 0 ? { note } : {},
1899
+ ...rangeText !== void 0 ? { range: rangeText } : {},
1900
+ ...signedFacts
1856
1901
  }, url, marks);
1857
1902
  const fetchedIso = new Date(fetchedAt).toISOString();
1858
- 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({
1859
1904
  url,
1860
1905
  capture,
1861
1906
  blob,
@@ -1921,9 +1966,10 @@ var Crawler = class {
1921
1966
  },
1922
1967
  delayMs
1923
1968
  };
1924
- }, signal);
1969
+ }, this.paced(signal, request_.deadline));
1925
1970
  } catch (error) {
1926
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)) };
1927
1973
  if (this.closed) return { fetched: closedResult(url) };
1928
1974
  if (request_.signal?.aborted === true) return { fetched: await skip("cancelled by the caller") };
1929
1975
  throw error;
@@ -2018,7 +2064,7 @@ var Crawler = class {
2018
2064
  return this.track(this.renderOnce(request_).catch((error) => this.closed ? closedResult(request_.url) : {
2019
2065
  ...closedResult(request_.url),
2020
2066
  outcome: "error",
2021
- note: `crawler error: ${messageOf$2(error)}`
2067
+ note: `crawler error: ${messageOf$1(error)}`
2022
2068
  }));
2023
2069
  }
2024
2070
  async renderOnce(request_) {
@@ -2068,6 +2114,20 @@ var Crawler = class {
2068
2114
  hosts: /* @__PURE__ */ new Set()
2069
2115
  };
2070
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
+ } };
2071
2131
  const rest = (elapsedMs) => ({
2072
2132
  kind: "render",
2073
2133
  elapsedMs,
@@ -2083,6 +2143,7 @@ var Crawler = class {
2083
2143
  let done;
2084
2144
  try {
2085
2145
  done = await this.scheduler.run(host, async () => {
2146
+ this.beforeDeadline(request_.deadline);
2086
2147
  const started = this.clock.now();
2087
2148
  this.emit({
2088
2149
  type: "request",
@@ -2097,7 +2158,8 @@ var Crawler = class {
2097
2158
  ...this.options.from !== void 0 ? { headers: { from: this.options.from } } : {},
2098
2159
  allowRequest,
2099
2160
  maxRedirects: policy.maxRedirects,
2100
- signal
2161
+ signal,
2162
+ ...sign === void 0 ? {} : { sign }
2101
2163
  }), signal);
2102
2164
  const header = (name) => rendered.headers[name.toLowerCase()];
2103
2165
  const classified = rendered.status === null ? {
@@ -2135,7 +2197,7 @@ var Crawler = class {
2135
2197
  },
2136
2198
  delayMs: restAfter(policy, rest(0)).ms
2137
2199
  };
2138
- 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;
2139
2201
  if (refused !== void 0) {
2140
2202
  if (refused.reason === "browser") this.renderOff = refused.message;
2141
2203
  const delayMs = this.settle(host, policy, {
@@ -2167,15 +2229,16 @@ var Crawler = class {
2167
2229
  return {
2168
2230
  value: {
2169
2231
  kind: "failed",
2170
- note: `render failed: ${messageOf$2(error)}`,
2232
+ note: `render failed: ${messageOf$1(error)}`,
2171
2233
  elapsedMs
2172
2234
  },
2173
2235
  delayMs
2174
2236
  };
2175
2237
  }
2176
- }, signal);
2238
+ }, this.paced(signal, request_.deadline));
2177
2239
  } catch (error) {
2178
2240
  if (error instanceof HostStopped) return skip(stopNote(error.reason, error.until));
2241
+ if (error instanceof DeadlineReached) return skip(deadlineNote(error, request_.deadline));
2179
2242
  if (this.closed) return closedResult(url);
2180
2243
  if (request_.signal?.aborted === true) return skip("cancelled by the caller");
2181
2244
  throw error;
@@ -2213,7 +2276,8 @@ var Crawler = class {
2213
2276
  note: done.note,
2214
2277
  elapsed_ms: done.elapsedMs,
2215
2278
  robots_check: check,
2216
- subrequests
2279
+ subrequests,
2280
+ ...signedFacts
2217
2281
  }, url, marks);
2218
2282
  return {
2219
2283
  ...closedResult(url),
@@ -2246,7 +2310,8 @@ var Crawler = class {
2246
2310
  note,
2247
2311
  elapsed_ms: done.elapsedMs,
2248
2312
  robots_check: check,
2249
- subrequests
2313
+ subrequests,
2314
+ ...signedFacts
2250
2315
  }, url, marks),
2251
2316
  blob: void 0,
2252
2317
  body: void 0,
@@ -2290,7 +2355,7 @@ var Crawler = class {
2290
2355
  blob = (await this.options.blobs.put(body)).hash;
2291
2356
  } catch (error) {
2292
2357
  outcome = "error";
2293
- note = `blob store: ${messageOf$2(error)}`;
2358
+ note = `blob store: ${messageOf$1(error)}`;
2294
2359
  body = void 0;
2295
2360
  }
2296
2361
  }
@@ -2321,7 +2386,8 @@ var Crawler = class {
2321
2386
  signals,
2322
2387
  subrequests,
2323
2388
  ...freshness !== null ? { fresh_until: freshness } : {},
2324
- ...note !== void 0 ? { note } : {}
2389
+ ...note !== void 0 ? { note } : {},
2390
+ ...signedFacts
2325
2391
  }, url, marks);
2326
2392
  const fetchedIso = new Date(fetchedAt).toISOString();
2327
2393
  if (capture !== void 0 && blob !== void 0) this.store({
@@ -2366,6 +2432,17 @@ var Crawler = class {
2366
2432
  }
2367
2433
  }
2368
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
+ /**
2369
2446
  * This method closes the crawler. Work in the queue ends as `skipped` with the note
2370
2447
  * `crawler closed`, and the crawler writes no capture after the close. Then it closes the
2371
2448
  * browser, the log and its own dispatcher. A second close waits for the first.
@@ -2661,1258 +2738,93 @@ const snippetOf = (text, terms, width = 160) => {
2661
2738
  const clean = (part) => part.replace(/\s+/g, " ");
2662
2739
  return `${from > 0 ? "…" : ""}${clean(text.slice(from, start))}[${clean(text.slice(start, end))}]${clean(text.slice(end, to))}${to < text.length ? "…" : ""}`;
2663
2740
  };
2664
- /** The search index reads this many characters of each text, at most. */
2665
- const MAX_INDEX_CHARS = 2e5;
2666
- /** In the mode `fast`, a part is common when more than this share of the documents hold it. */
2667
- const FAST_RANK_SHARE = .2;
2668
- /** This function gives the rank mode of an option. A value that is not a rank mode throws a `RangeError`. */
2669
- const rankModeOf = (value) => {
2670
- if (value === void 0 || value === "exact") return "exact";
2671
- if (value === "fast") return "fast";
2672
- throw new RangeError(`rank must be "exact" or "fast", not ${JSON.stringify(value) ?? "undefined"}`);
2673
- };
2674
- const namesOf = (generation) => ({
2675
- docs: `docs_${String(generation)}`,
2676
- fts: `fts_${String(generation)}`,
2677
- versions: `versions_${String(generation)}`,
2678
- texts: `texts_${String(generation)}`,
2679
- latest: `latest_${String(generation)}`
2680
- });
2681
- const GENERATION_TABLE = /^(docs|fts|versions|texts|latest)_(\d+)$/;
2682
- /** A build that started this long ago and did not finish belongs to a process that stopped. */
2683
- const ABANDONED_MS = 864e5;
2684
- /** The FTS5 tokenizer: folded text, English stems, and spacing marks kept in the words. */
2685
- const TOKENIZER = `tokenize = "porter unicode61 remove_diacritics 0 categories 'L* N* Co Mc'"`;
2686
- const ddlOf = (n) => `
2687
- CREATE TABLE IF NOT EXISTS ${n.texts} (
2688
- sha TEXT PRIMARY KEY,
2689
- body TEXT NOT NULL
2690
- );
2691
- CREATE TABLE IF NOT EXISTS ${n.versions} (
2692
- doc TEXT NOT NULL,
2693
- capture TEXT NOT NULL,
2694
- recipe TEXT NOT NULL,
2695
- rendered INTEGER NOT NULL,
2696
- fetched_at TEXT NOT NULL,
2697
- blob TEXT,
2698
- kind TEXT NOT NULL,
2699
- title TEXT NOT NULL,
2700
- url TEXT,
2701
- meta_sha TEXT NOT NULL,
2702
- text_sha TEXT NOT NULL,
2703
- text_chars INTEGER NOT NULL,
2704
- full_sha TEXT,
2705
- ai_input TEXT NOT NULL,
2706
- merge TEXT NOT NULL,
2707
- scope TEXT,
2708
- labels TEXT,
2709
- PRIMARY KEY (doc, capture)
2710
- );
2711
- CREATE INDEX IF NOT EXISTS ${n.versions}_time ON ${n.versions} (doc, recipe, rendered, fetched_at);
2712
- CREATE INDEX IF NOT EXISTS ${n.versions}_capture ON ${n.versions} (capture);
2713
- CREATE INDEX IF NOT EXISTS ${n.versions}_fetched ON ${n.versions} (fetched_at);
2714
- CREATE INDEX IF NOT EXISTS ${n.versions}_recipe ON ${n.versions} (recipe);
2715
- CREATE TABLE IF NOT EXISTS ${n.docs} (
2716
- doc TEXT PRIMARY KEY,
2717
- kind TEXT NOT NULL,
2718
- title TEXT NOT NULL,
2719
- url TEXT,
2720
- meta TEXT NOT NULL,
2721
- text_sha TEXT NOT NULL,
2722
- full_sha TEXT,
2723
- captures TEXT NOT NULL,
2724
- capture_count INTEGER NOT NULL,
2725
- capture TEXT NOT NULL,
2726
- blob TEXT,
2727
- fetched_at TEXT NOT NULL,
2728
- recipe TEXT NOT NULL,
2729
- rendered INTEGER NOT NULL,
2730
- ai_input TEXT NOT NULL,
2731
- scope TEXT,
2732
- labels TEXT,
2733
- updated_at TEXT NOT NULL
2734
- );
2735
- CREATE VIRTUAL TABLE IF NOT EXISTS ${n.fts} USING fts5(
2736
- title, text, content = '', contentless_delete = 1, ${TOKENIZER}
2737
- );
2738
- CREATE TABLE IF NOT EXISTS ${n.latest} (
2739
- url TEXT NOT NULL,
2740
- variant TEXT NOT NULL,
2741
- capture TEXT NOT NULL,
2742
- blob TEXT,
2743
- etag TEXT,
2744
- last_modified TEXT,
2745
- content_type TEXT,
2746
- fetched_at TEXT NOT NULL,
2747
- status INTEGER NOT NULL,
2748
- ai_input TEXT NOT NULL,
2749
- location TEXT,
2750
- truncated INTEGER,
2751
- fresh_until TEXT,
2752
- vary_key TEXT,
2753
- PRIMARY KEY (url, variant)
2754
- );
2755
- CREATE INDEX IF NOT EXISTS ${n.latest}_fetched ON ${n.latest} (fetched_at);
2756
- `;
2757
- const RENDERED_MARK$1 = "#rendered";
2758
- /** 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. */
2759
- const keyOf = (url, variant) => {
2760
- if (url.endsWith(RENDERED_MARK$1)) return {
2761
- url: cacheKeyOf(url.slice(0, -9)),
2762
- variant: "rendered"
2763
- };
2764
- return {
2765
- url: cacheKeyOf(url),
2766
- variant: variant ?? "plain"
2767
- };
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;
2768
2758
  };
2769
- const sha = (text) => createHash("sha256").update(text, "utf8").digest("hex");
2770
- const parseJson = (text, fallback) => {
2771
- if (text === null || text === void 0) return fallback;
2772
- try {
2773
- return JSON.parse(text);
2774
- } catch {
2775
- return fallback;
2776
- }
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;
2777
2767
  };
2778
- const toLatest = (row) => ({
2779
- url: row.url,
2780
- capture: row.capture,
2781
- blob: row.blob,
2782
- etag: row.etag,
2783
- last_modified: row.last_modified,
2784
- content_type: row.content_type,
2785
- fetched_at: row.fetched_at,
2786
- status: row.status,
2787
- ai_input: row.ai_input,
2788
- location: row.location,
2789
- truncated: row.truncated === 1 ? true : void 0,
2790
- fresh_until: row.fresh_until,
2791
- vary_key: row.vary_key,
2792
- variant: row.variant
2793
- });
2794
- const toVersion = (row) => ({
2795
- doc: row.doc,
2796
- capture: row.capture,
2797
- recipe: row.recipe,
2798
- rendered: row.rendered === 1,
2799
- fetched_at: row.fetched_at,
2800
- blob: row.blob,
2801
- kind: row.kind,
2802
- title: row.title,
2803
- url: row.url,
2804
- ai_input: row.ai_input,
2805
- chars: row.text_chars,
2806
- cut: row.full_sha !== null
2807
- });
2808
- const messageOf$1 = (error) => error instanceof Error ? error.message : String(error);
2809
- var DocIndex = class {
2810
- path;
2811
- db;
2812
- statements = /* @__PURE__ */ new Map();
2813
- depth = 0;
2814
- closed = false;
2815
- empty;
2816
- /**
2817
- * This value is true when the file had a different schema, or no tables, so the index opened
2818
- * empty. A store with captures then needs `reindex`. The commit of a rebuild makes it false.
2819
- */
2820
- get stale() {
2821
- return this.empty;
2822
- }
2823
- constructor(path, options = {}) {
2824
- this.path = path;
2825
- if (path !== ":memory:") mkdirSync(dirname(path), {
2826
- recursive: true,
2827
- mode: 448
2828
- });
2829
- this.db = new DatabaseSync(path);
2830
- const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
2831
- this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
2832
- try {
2833
- this.db.exec("CREATE VIRTUAL TABLE temp.polite_fts5_probe USING fts5(x, content = '', contentless_delete = 1); DROP TABLE temp.polite_fts5_probe;");
2834
- } catch (cause) {
2835
- this.db.close();
2836
- 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 });
2837
- }
2838
- if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
2839
- this.db.exec("PRAGMA synchronous = NORMAL;");
2840
- this.empty = this.write(() => this.prepareSchema());
2841
- }
2842
- /** This method makes the tables of the schema. It gives true when the index opened empty. */
2843
- prepareSchema() {
2844
- const version = this.db.prepare("PRAGMA user_version").get().user_version;
2845
- const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
2846
- if (version === 2 && tables.some((table) => table.name === "index_meta")) {
2847
- this.dropAbandoned();
2848
- return false;
2849
- }
2850
- const virtual = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'").all();
2851
- for (const { name } of virtual) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
2852
- const rest = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
2853
- for (const { name } of rest) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
2854
- this.db.exec("CREATE TABLE index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
2855
- this.db.exec(ddlOf(namesOf(1)));
2856
- this.statements.clear();
2857
- this.setMeta("generation", "1");
2858
- this.setMeta("next_generation", "2");
2859
- this.db.exec(`PRAGMA user_version = ${String(2)}`);
2860
- return true;
2861
- }
2862
- setMeta(key, value) {
2863
- this.stmt("INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
2864
- }
2865
- getMeta(key) {
2866
- return this.stmt("SELECT value FROM index_meta WHERE key = ?").get(key)?.value;
2867
- }
2868
- stmt(sql) {
2869
- let statement = this.statements.get(sql);
2870
- if (statement === void 0) {
2871
- statement = this.db.prepare(sql);
2872
- this.statements.set(sql, statement);
2873
- }
2874
- return statement;
2875
- }
2876
- /**
2877
- * This method does a write in one transaction. A transaction in progress takes the write in,
2878
- * also a transaction that the caller opened on the connection.
2879
- */
2880
- write(work) {
2881
- if (this.closed) throw new Error("the index is closed");
2882
- if (this.depth > 0 || this.db.isTransaction) {
2883
- this.depth += 1;
2884
- try {
2885
- return work();
2886
- } finally {
2887
- this.depth -= 1;
2888
- }
2889
- }
2890
- this.db.exec("BEGIN IMMEDIATE");
2891
- this.depth = 1;
2892
- try {
2893
- const result = work();
2894
- this.db.exec("COMMIT");
2895
- return result;
2896
- } catch (error) {
2897
- if (this.db.isTransaction) this.db.exec("ROLLBACK");
2898
- throw error;
2899
- } finally {
2900
- 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;
2901
2775
  }
2776
+ if (match) return index;
2902
2777
  }
2903
- generation() {
2904
- return Number(this.getMeta("generation") ?? "1");
2905
- }
2906
- /**
2907
- * This method does a step on the tables of the current generation. A rebuild of a different
2908
- * process can remove those tables in the meantime. Then the method reads the generation again
2909
- * and does the step once more.
2910
- */
2911
- live(step) {
2912
- 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;
2913
2804
  try {
2914
- 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;
2915
2809
  } catch (error) {
2916
- if (!/no such table/i.test(messageOf$1(error)) || this.depth > 0) throw error;
2917
- this.statements.clear();
2918
- return step(namesOf(this.generation()));
2919
- }
2920
- }
2921
- /** This method removes the tables of a generation. */
2922
- dropGeneration(generation) {
2923
- const n = namesOf(generation);
2924
- for (const table of [
2925
- n.fts,
2926
- n.docs,
2927
- n.versions,
2928
- n.texts,
2929
- n.latest
2930
- ]) this.db.exec(`DROP TABLE IF EXISTS ${table}`);
2931
- this.db.prepare("DELETE FROM index_meta WHERE key = ?").run(`building:${String(generation)}`);
2932
- this.statements.clear();
2933
- }
2934
- /** This method removes the generations of builds that a stopped process left. */
2935
- dropAbandoned() {
2936
- const current = this.generation();
2937
- const generations = /* @__PURE__ */ new Set();
2938
- for (const { name } of this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()) {
2939
- const match = GENERATION_TABLE.exec(name);
2940
- if (match !== null) generations.add(Number(match[2]));
2941
- }
2942
- for (const generation of generations) {
2943
- if (generation === current) continue;
2944
- const started = this.getMeta(`building:${String(generation)}`);
2945
- if (!((started === void 0 ? Number.POSITIVE_INFINITY : Date.now() - Date.parse(started)) < ABANDONED_MS)) this.dropGeneration(generation);
2946
- }
2947
- }
2948
- putText(n, body) {
2949
- const key = sha(body);
2950
- this.stmt(`INSERT OR IGNORE INTO ${n.texts} (sha, body) VALUES (?, ?)`).run(key, body);
2951
- return key;
2952
- }
2953
- textOf(n, key) {
2954
- if (key === null) return void 0;
2955
- return this.stmt(`SELECT body FROM ${n.texts} WHERE sha = ?`).get(key)?.body;
2956
- }
2957
- putVersion(n, input, now) {
2958
- const full = input.full_text !== void 0 && input.full_text !== input.text ? this.putText(n, input.full_text) : null;
2959
- this.stmt(`INSERT INTO ${n.versions} (doc, capture, recipe, rendered, fetched_at, blob, kind, title, url,
2960
- meta_sha, text_sha, text_chars, full_sha, ai_input, merge, scope, labels)
2961
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2962
- ON CONFLICT(doc, capture) DO UPDATE SET recipe = excluded.recipe, rendered = excluded.rendered,
2963
- fetched_at = excluded.fetched_at, blob = excluded.blob, kind = excluded.kind, title = excluded.title,
2964
- url = excluded.url, meta_sha = excluded.meta_sha, text_sha = excluded.text_sha,
2965
- text_chars = excluded.text_chars, full_sha = excluded.full_sha, ai_input = excluded.ai_input,
2966
- 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));
2967
- }
2968
- newest(n, doc, recipe, rendered) {
2969
- return this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = ?
2970
- ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered);
2971
- }
2972
- /** This method chooses the current version of a document from its versions, and writes it with its search entry. */
2973
- recompute(n, doc, now) {
2974
- const groups = this.stmt(`SELECT DISTINCT recipe, rendered FROM ${n.versions} WHERE doc = ?`).all(doc);
2975
- const before = this.stmt(`SELECT rowid, text_sha, title FROM ${n.docs} WHERE doc = ?`).get(doc);
2976
- if (groups.length === 0) {
2977
- if (before !== void 0) {
2978
- this.stmt(`DELETE FROM ${n.fts} WHERE rowid = ?`).run(before.rowid);
2979
- this.stmt(`DELETE FROM ${n.docs} WHERE doc = ?`).run(doc);
2980
- }
2981
- return;
2982
- }
2983
- const byRecipe = /* @__PURE__ */ new Map();
2984
- for (const group of groups) {
2985
- const row = this.newest(n, doc, group.recipe, group.rendered);
2986
- if (row === void 0) continue;
2987
- const entry = byRecipe.get(group.recipe) ?? {};
2988
- if (group.rendered === 1) entry.rendered = row;
2989
- else entry.plain = row;
2990
- byRecipe.set(group.recipe, entry);
2991
- }
2992
- const candidates = [];
2993
- for (const [recipe, { plain, rendered }] of byRecipe) {
2994
- let pick;
2995
- if (rendered === void 0) pick = plain;
2996
- else if (plain === void 0 || rendered.fetched_at >= plain.fetched_at) pick = rendered;
2997
- else {
2998
- const base = this.stmt(`SELECT blob FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = 0 AND fetched_at <= ?
2999
- ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered.fetched_at);
3000
- pick = base !== void 0 && base.blob !== null && base.blob === plain.blob ? rendered : plain;
3001
- }
3002
- const newest = plain === void 0 ? rendered : rendered === void 0 || plain.fetched_at > rendered.fetched_at ? plain : rendered;
3003
- const others = [plain, rendered].filter((row) => row !== void 0 && row !== pick);
3004
- candidates.push({
3005
- row: pick,
3006
- ai: newest.ai_input,
3007
- others
3008
- });
3009
- }
3010
- let winner = candidates[0];
3011
- if (candidates.length > 1) {
3012
- const latest = candidates.every((candidate) => candidate.row.merge === "latest");
3013
- for (const candidate of candidates.slice(1)) {
3014
- const a = candidate.row;
3015
- const b = winner.row;
3016
- 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;
3017
- }
3018
- }
3019
- const row = winner.row;
3020
- 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";
3021
- let meta = parseJson(this.textOf(n, row.meta_sha), {});
3022
- for (const candidate of candidates) if (candidate !== winner) meta = {
3023
- ...parseJson(this.textOf(n, candidate.row.meta_sha), {}),
3024
- ...meta
3025
- };
3026
- const same = this.stmt(`SELECT capture FROM ${n.versions} WHERE doc = ? AND text_sha = ?
3027
- ORDER BY fetched_at DESC, capture DESC LIMIT ?`).all(doc, row.text_sha, 10);
3028
- const captures = [.../* @__PURE__ */ new Set([
3029
- row.capture,
3030
- ...same.map((entry) => entry.capture),
3031
- ...winner.others.map((other) => other.capture),
3032
- ...candidates.filter((candidate) => candidate !== winner).map((candidate) => candidate.row.capture)
3033
- ])].slice(0, 10);
3034
- const count = this.stmt(`SELECT count(*) AS n FROM ${n.versions} WHERE doc = ?`).get(doc).n;
3035
- const url = row.url ?? candidates.map((candidate) => candidate.row.url).find((value) => value !== null) ?? null;
3036
- const saved = this.stmt(`INSERT INTO ${n.docs} (doc, kind, title, url, meta, text_sha, full_sha, captures, capture_count, capture,
3037
- blob, fetched_at, recipe, rendered, ai_input, scope, labels, updated_at)
3038
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3039
- ON CONFLICT(doc) DO UPDATE SET kind = excluded.kind, title = excluded.title, url = excluded.url,
3040
- meta = excluded.meta, text_sha = excluded.text_sha, full_sha = excluded.full_sha,
3041
- captures = excluded.captures, capture_count = excluded.capture_count, capture = excluded.capture,
3042
- blob = excluded.blob, fetched_at = excluded.fetched_at, recipe = excluded.recipe,
3043
- rendered = excluded.rendered, ai_input = excluded.ai_input, scope = excluded.scope,
3044
- labels = excluded.labels, updated_at = excluded.updated_at
3045
- 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);
3046
- const text = this.textOf(n, row.text_sha) ?? "";
3047
- if (before === void 0 || before.text_sha !== row.text_sha || before.title !== row.title) {
3048
- if (before !== void 0) this.stmt(`DELETE FROM ${n.fts} WHERE rowid = ?`).run(before.rowid);
3049
- 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;
3050
2811
  }
3051
- return this.docOf(n, doc, text);
3052
- }
3053
- docOf(n, doc, knownText) {
3054
- const row = this.stmt(`SELECT rowid, * FROM ${n.docs} WHERE doc = ?`).get(doc);
3055
- if (row === void 0) return void 0;
3056
- return this.toDoc(n, row, knownText);
3057
- }
3058
- toDoc(n, row, knownText) {
3059
- const labels = parseJson(row.labels, void 0);
3060
- return {
3061
- doc: row.doc,
3062
- kind: row.kind,
3063
- title: row.title,
3064
- text: knownText ?? this.textOf(n, row.text_sha) ?? "",
3065
- url: row.url,
3066
- meta: parseJson(row.meta, {}),
3067
- captures: parseJson(row.captures, []),
3068
- capture_count: row.capture_count,
3069
- capture: row.capture,
3070
- blob: row.blob,
3071
- fetched_at: row.fetched_at,
3072
- recipe: row.recipe,
3073
- rendered: row.rendered === 1,
3074
- ai_input: row.ai_input,
3075
- cut: row.full_sha !== null,
3076
- ...row.scope !== null ? { scope: row.scope } : {},
3077
- ...labels !== void 0 ? { labels } : {},
3078
- updated_at: row.updated_at
3079
- };
3080
- }
3081
- versionTextOf(n, row) {
3082
- return {
3083
- ...toVersion(row),
3084
- text: this.textOf(n, row.text_sha) ?? "",
3085
- full_text: this.textOf(n, row.full_sha),
3086
- meta: parseJson(this.textOf(n, row.meta_sha), {})
3087
- };
3088
- }
3089
- get(doc) {
3090
- return this.live((n) => this.docOf(n, doc));
3091
- }
3092
- /**
3093
- * This method adds one version of a document, and it gives the current document. The rules of
3094
- * the class choose the current version. An upsert of a known capture replaces that version.
3095
- */
3096
- upsert(input, now) {
3097
- return this.live((n) => this.write(() => {
3098
- this.putVersion(n, input, now);
3099
- return this.recompute(n, input.doc, now);
3100
- }));
2812
+ if (total > limit) return false;
2813
+ from = Math.max(from, end);
3101
2814
  }
3102
- /** This method gives the versions of a document, the newest first. */
3103
- versions(doc, options = {}) {
3104
- const limit = Math.max(1, Math.floor(options.limit ?? 100));
3105
- 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));
3106
- }
3107
- /**
3108
- * This method gives the text of one version of a document. With no capture, it gives the current
3109
- * version. It also gives the text of a held-back version: the caller applies `isHeldBack` to the
3110
- * `ai_input` of the version.
3111
- */
3112
- versionText(doc, capture) {
3113
- return this.live((n) => {
3114
- const id = capture ?? this.docOf(n, doc)?.capture;
3115
- if (id === void 0) return void 0;
3116
- const row = this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND capture = ?`).get(doc, id);
3117
- return row === void 0 ? void 0 : this.versionTextOf(n, row);
3118
- });
3119
- }
3120
- /**
3121
- * This method gives the texts of the earlier versions of a document, the newest first. Each
3122
- * text comes one time, with the newest version that has it. It skips the current text. It also
3123
- * gives held-back texts: the caller applies `isHeldBack` to the `ai_input` of each version.
3124
- */
3125
- earlierTexts(doc, options = {}) {
3126
- const limit = Math.max(1, Math.floor(options.limit ?? 50));
3127
- return this.live((n) => {
3128
- const head = this.stmt(`SELECT text_sha FROM ${n.docs} WHERE doc = ?`).get(doc);
3129
- if (head === void 0) return [];
3130
- 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);
3131
- const seen = /* @__PURE__ */ new Set();
3132
- const out = [];
3133
- for (const row of rows) {
3134
- if (seen.has(row.text_sha)) continue;
3135
- seen.add(row.text_sha);
3136
- out.push(this.versionTextOf(n, row));
3137
- if (out.length >= limit) break;
3138
- }
3139
- return out;
3140
- });
3141
- }
3142
- /** This method tells if a capture gave a version of a document. */
3143
- hasVersion(capture) {
3144
- return this.live((n) => this.stmt(`SELECT 1 AS found FROM ${n.versions} WHERE capture = ? LIMIT 1`).get(capture) !== void 0);
3145
- }
3146
- /** This method gives the newest fetch time of the cache entries, or undefined for an empty index. */
3147
- newestFetch() {
3148
- return this.live((n) => this.stmt(`SELECT max(fetched_at) AS newest FROM ${n.latest}`).get()?.newest ?? void 0);
3149
- }
3150
- /** This method gives the current documents that a capture gave. */
3151
- docsOfCapture(capture) {
3152
- 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));
3153
- }
3154
- /**
3155
- * This method searches the documents with BM25, the best first. The syntax of the query is the
3156
- * syntax of `parseQuery`. It first asks for each part. When that gives too few documents, it
3157
- * adds the documents that match one part. A document that asked not to be AI input is not in
3158
- * the results, so a search cannot show its text. The option `rank` chooses the ranking (`RankMode`).
3159
- */
3160
- find(q, options) {
3161
- const rank = rankModeOf(options.rank);
3162
- const query = parseQuery(q);
3163
- if (query === void 0) return [];
3164
- const limit = Math.max(1, Math.min(1e3, Math.floor(options.limit) || 10));
3165
- return this.live((n) => {
3166
- const kindClause = options.kind === void 0 || options.kind === null ? "" : "AND d.kind = ?";
3167
- const sql = `SELECT d.rowid AS rowid, d.*, bm25(${n.fts}, 3.0, 1.0) AS rank
3168
- FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
3169
- WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' ${kindClause}
3170
- ORDER BY rank LIMIT ?`;
3171
- const run = (match) => {
3172
- const params = [match];
3173
- if (kindClause.length > 0) params.push(options.kind);
3174
- params.push(limit);
3175
- return this.stmt(sql).all(...params);
3176
- };
3177
- const rows = run(query.all);
3178
- if (rows.length < limit && query.parts > 1) {
3179
- const seen = new Set(rows.map((row) => row.doc));
3180
- for (const row of run(rank === "fast" ? this.rareParts(n, query.anyParts) : query.any)) {
3181
- if (rows.length >= limit) break;
3182
- if (!seen.has(row.doc)) rows.push(row);
3183
- }
3184
- }
3185
- return rows.map((row) => ({
3186
- doc: row.doc,
3187
- kind: row.kind,
3188
- title: row.title,
3189
- url: row.url,
3190
- snippet: snippetOf(this.textOf(n, row.text_sha) ?? "", query.terms),
3191
- score: Math.round(-row.rank * 1e3) / 1e3,
3192
- ai_input: row.ai_input,
3193
- fetched_at: row.fetched_at,
3194
- capture: row.capture
3195
- }));
3196
- });
3197
- }
3198
- /**
3199
- * This method scores a set of documents against a query, with the best first. It leaves out a
3200
- * document that matches no term, and a document that asked not to be AI input. It ranks the
3201
- * results of a source again, because the order of the source is only as good as its search engine.
3202
- * In the mode `fast`, it also leaves out a document that holds only common parts (`RankMode`).
3203
- */
3204
- rankWithin(q, docs, options = {}) {
3205
- const rank = rankModeOf(options.rank);
3206
- const query = parseQuery(q);
3207
- if (query === void 0 || docs.length === 0) return [];
3208
- return this.live((n) => this.stmt(`SELECT d.doc AS doc, bm25(${n.fts}, 3.0, 1.0) AS rank
3209
- FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
3210
- WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' AND d.doc IN (SELECT value FROM json_each(?))
3211
- ORDER BY rank`).all(rank === "fast" ? this.rareParts(n, query.anyParts) : query.any, JSON.stringify(docs)).map((row) => ({
3212
- doc: row.doc,
3213
- score: Math.round(-row.rank * 1e3) / 1e3
3214
- })));
3215
- }
3216
- /**
3217
- * This method gives the form of a query that needs one part, without the common parts (the mode
3218
- * `fast` of `RankMode`). It counts the documents that hold each part. When each part is common,
3219
- * it keeps the rarest part.
3220
- */
3221
- rareParts(n, parts) {
3222
- if (parts.length < 2) return parts.join(" OR ");
3223
- const total = this.stmt(`SELECT count(*) AS n FROM ${n.docs}`).get().n;
3224
- const counted = parts.map((part) => ({
3225
- part,
3226
- count: this.stmt(`SELECT count(*) AS n FROM ${n.fts} WHERE ${n.fts} MATCH ?`).get(part).n
3227
- }));
3228
- const rare = counted.filter((one) => one.count <= FAST_RANK_SHARE * total);
3229
- if (rare.length > 0) return rare.map((one) => one.part).join(" OR ");
3230
- return counted.reduce((best, one) => one.count < best.count ? one : best).part;
3231
- }
3232
- /** This method gives the cache entry of a URL. The key is canonical, so two spellings of one URL give one entry. */
3233
- latest(url, variant) {
3234
- const key = keyOf(url, variant);
3235
- return this.live((n) => {
3236
- const row = this.stmt(`SELECT * FROM ${n.latest} WHERE url = ? AND variant = ?`).get(key.url, key.variant);
3237
- return row === void 0 ? void 0 : toLatest(row);
3238
- });
3239
- }
3240
- putLatest(n, entry) {
3241
- const key = keyOf(entry.url, entry.variant);
3242
- this.stmt(`INSERT INTO ${n.latest} (url, variant, capture, blob, etag, last_modified, content_type, fetched_at,
3243
- status, ai_input, location, truncated, fresh_until, vary_key)
3244
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3245
- ON CONFLICT(url, variant) DO UPDATE SET capture = excluded.capture, blob = excluded.blob,
3246
- etag = excluded.etag, last_modified = excluded.last_modified, content_type = excluded.content_type,
3247
- fetched_at = excluded.fetched_at, status = excluded.status, ai_input = excluded.ai_input,
3248
- location = excluded.location, truncated = excluded.truncated, fresh_until = excluded.fresh_until,
3249
- vary_key = excluded.vary_key
3250
- 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);
3251
- }
3252
- /** This method keeps the last answer for a URL. An entry that is older than the stored entry changes nothing. */
3253
- setLatest(entry) {
3254
- this.live((n) => this.write(() => this.putLatest(n, entry)));
3255
- }
3256
- counts() {
3257
- return this.live((n) => ({
3258
- docs: this.stmt(`SELECT count(*) AS n FROM ${n.docs}`).get().n,
3259
- urls: this.stmt(`SELECT count(*) AS n FROM ${n.latest}`).get().n
3260
- }));
3261
- }
3262
- /** This method empties the current generation. A rebuild uses `beginRebuild`, which keeps the index until its commit. */
3263
- reset() {
3264
- this.live((n) => this.write(() => {
3265
- for (const table of [
3266
- n.fts,
3267
- n.docs,
3268
- n.versions,
3269
- n.texts,
3270
- n.latest
3271
- ]) this.db.exec(`DELETE FROM ${table}`);
3272
- }));
3273
- }
3274
- /**
3275
- * This method starts a rebuild into a new generation of the tables. The current generation
3276
- * answers each read and takes each write until the commit. A failed rebuild thus leaves the
3277
- * index as it was.
3278
- */
3279
- beginRebuild(options = {}) {
3280
- const batchSize = Math.max(1, Math.floor(options.batch ?? 500));
3281
- const generation = this.write(() => {
3282
- const next = Number(this.getMeta("next_generation") ?? "2");
3283
- this.setMeta("next_generation", String(next + 1));
3284
- this.setMeta(`building:${String(next)}`, (/* @__PURE__ */ new Date()).toISOString());
3285
- this.db.exec(ddlOf(namesOf(next)));
3286
- return next;
3287
- });
3288
- const n = namesOf(generation);
3289
- const versions = [];
3290
- const latest = /* @__PURE__ */ new Map();
3291
- let open = true;
3292
- const keyText = (url, variant) => {
3293
- const key = keyOf(url, variant);
3294
- return `${key.variant} ${key.url}`;
3295
- };
3296
- const flush = () => {
3297
- if (!open) throw new Error("the rebuild is over");
3298
- if (versions.length === 0 && latest.size === 0) return;
3299
- this.write(() => {
3300
- const touched = /* @__PURE__ */ new Map();
3301
- for (const { input, now } of versions) {
3302
- this.putVersion(n, input, now);
3303
- touched.set(input.doc, now);
3304
- }
3305
- for (const entry of latest.values()) this.putLatest(n, entry);
3306
- for (const [doc, now] of touched) this.recompute(n, doc, now);
3307
- });
3308
- versions.length = 0;
3309
- latest.clear();
3310
- };
3311
- /** This function copies the texts of the versions of the current generation that agree with `where`. */
3312
- const copyTexts = (current, where, params) => {
3313
- for (const column of [
3314
- "text_sha",
3315
- "meta_sha",
3316
- "full_sha"
3317
- ]) this.db.prepare(`INSERT OR IGNORE INTO ${n.texts} (sha, body)
3318
- SELECT t.sha, t.body FROM ${current.texts} t
3319
- WHERE t.sha IN (SELECT ${column} FROM ${current.versions} WHERE ${where})`).run(...params);
3320
- };
3321
- return {
3322
- upsert: (input, now) => {
3323
- if (!open) throw new Error("the rebuild is over");
3324
- versions.push({
3325
- input,
3326
- now
3327
- });
3328
- if (versions.length >= batchSize) flush();
3329
- },
3330
- setLatest: (entry) => {
3331
- if (!open) throw new Error("the rebuild is over");
3332
- const key = keyText(entry.url, entry.variant);
3333
- const before = latest.get(key);
3334
- if (before === void 0 || entry.fetched_at >= before.fetched_at) latest.set(key, entry);
3335
- if (latest.size >= batchSize) flush();
3336
- },
3337
- latest: (url, variant) => {
3338
- const waiting = latest.get(keyText(url, variant));
3339
- if (waiting !== void 0) return waiting;
3340
- const key = keyOf(url, variant);
3341
- const row = this.stmt(`SELECT * FROM ${n.latest} WHERE url = ? AND variant = ?`).get(key.url, key.variant);
3342
- return row === void 0 ? void 0 : toLatest(row);
3343
- },
3344
- flush,
3345
- carry: (recipes) => {
3346
- flush();
3347
- const known = JSON.stringify([...recipes]);
3348
- const current = namesOf(this.generation());
3349
- const docs = this.db.prepare(`SELECT DISTINCT doc FROM ${current.versions} WHERE recipe NOT IN (SELECT value FROM json_each(?))`).all(known);
3350
- const where = "recipe NOT IN (SELECT value FROM json_each(?)) AND doc IN (SELECT value FROM json_each(?))";
3351
- for (let at = 0; at < docs.length; at += batchSize) {
3352
- const part = JSON.stringify(docs.slice(at, at + batchSize).map((row) => row.doc));
3353
- this.write(() => {
3354
- copyTexts(current, where, [known, part]);
3355
- this.db.prepare(`INSERT OR IGNORE INTO ${n.versions} SELECT * FROM ${current.versions} WHERE ${where}`).run(known, part);
3356
- const now = (/* @__PURE__ */ new Date()).toISOString();
3357
- for (const doc of JSON.parse(part)) this.recompute(n, doc, now);
3358
- });
3359
- }
3360
- return docs.length;
3361
- },
3362
- commit: (since) => {
3363
- flush();
3364
- open = false;
3365
- return this.write(() => {
3366
- const current = this.generation();
3367
- const old = namesOf(current);
3368
- copyTexts(old, "fetched_at >= ?", [since]);
3369
- const added = this.db.prepare(`SELECT DISTINCT doc FROM ${old.versions} WHERE fetched_at >= ?`).all(since);
3370
- this.db.prepare(`INSERT OR IGNORE INTO ${n.versions} SELECT * FROM ${old.versions} WHERE fetched_at >= ?`).run(since);
3371
- const now = (/* @__PURE__ */ new Date()).toISOString();
3372
- for (const { doc } of added) this.recompute(n, doc, now);
3373
- for (const row of this.db.prepare(`SELECT * FROM ${old.latest} WHERE fetched_at >= ?`).all(since)) this.putLatest(n, toLatest(row));
3374
- this.setMeta("generation", String(generation));
3375
- this.db.prepare("DELETE FROM index_meta WHERE key = ?").run(`building:${String(generation)}`);
3376
- this.dropGeneration(current);
3377
- this.empty = false;
3378
- return {
3379
- docs: this.db.prepare(`SELECT count(*) AS n FROM ${n.docs}`).get().n,
3380
- urls: this.db.prepare(`SELECT count(*) AS n FROM ${n.latest}`).get().n
3381
- };
3382
- });
3383
- },
3384
- abort: () => {
3385
- open = false;
3386
- versions.length = 0;
3387
- latest.clear();
3388
- if (this.closed) return;
3389
- this.write(() => this.dropGeneration(generation));
3390
- }
3391
- };
3392
- }
3393
- /** This method closes the file. A second close does nothing. */
3394
- close() {
3395
- if (this.closed) return;
3396
- this.closed = true;
3397
- this.statements.clear();
3398
- this.db.close();
3399
- }
3400
- };
3401
- //#endregion
3402
- //#region src/hoststate.ts
3403
- /** A stop with no end goes into the table as this value, which is past each date that JavaScript can hold. */
3404
- const NO_END = 9e15;
3405
- /** A process that shares the store asks again after this wait, at most, during the lease of a different process. */
3406
- const SHARED_WAIT_MS = 1e3;
3407
- /** The store removes the rows of idle hosts after this many reservations. */
3408
- const SWEEP_EVERY = 1e3;
3409
- const verdictOf = (value) => value === "works" || value === "fails" ? value : "unknown";
3410
- const memoOf = (row) => ({
3411
- skipEtag: row.skip_etag === 1,
3412
- etag: verdictOf(row.etag),
3413
- lastModified: verdictOf(row.last_modified),
3414
- etagMisses: row.etag_misses,
3415
- dateMisses: row.date_misses,
3416
- sinceProbe: row.since_probe,
3417
- changedAt: row.changed_at
3418
- });
3419
- /**
3420
- * This store keeps the state of each host in a SQLite file. Two or more processes on one store
3421
- * share it, so they share the pace and the stops of each host. Thus a host gets one request at a
3422
- * time from all of them. Each method is one transaction (`BEGIN IMMEDIATE`), and it waits for the lock
3423
- * of a different process (`busy_timeout`).
3424
- *
3425
- * The lease of a request belongs to the store that gave it. A different process that asks during
3426
- * the lease gets a wait of one second or less, and asks again. A process that stops during a
3427
- * request holds its lease until the lease ends (5 minutes by default).
3428
- *
3429
- * The table `validators` keeps the validator memo of each host (`ValidatorMemo`), so the processes
3430
- * share it and it stays after a restart. A file of version 0.1.0 gets the table when it opens.
3431
- *
3432
- * The state is not a projection of the capture log. A deletion of the file loses only the pace,
3433
- * the counts, the stops and the memos, and the crawler starts them again.
3434
- */
3435
- var SqliteHostStateStore = class {
3436
- path;
3437
- db;
3438
- statements = /* @__PURE__ */ new Map();
3439
- /** This id names the leases of this store. */
3440
- owner = randomUUID();
3441
- reservations = 0;
3442
- closed = false;
3443
- pollMs;
3444
- constructor(path, options = {}) {
3445
- this.path = path;
3446
- if (path !== ":memory:") mkdirSync(dirname(path), {
3447
- recursive: true,
3448
- mode: 448
3449
- });
3450
- this.pollMs = Math.max(1, Math.min(SHARED_WAIT_MS, options.pollMs ?? SHARED_WAIT_MS));
3451
- this.db = new DatabaseSync(path);
3452
- const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
3453
- this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
3454
- if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
3455
- this.db.exec("PRAGMA synchronous = NORMAL;");
3456
- this.migrate();
3457
- }
3458
- /**
3459
- * This method makes the tables, or adds the tables of a later version to an older file. It is one
3460
- * transaction, so two processes that open one old file at the same time do not collide.
3461
- */
3462
- migrate() {
3463
- this.step(() => {
3464
- this.db.exec(`CREATE TABLE IF NOT EXISTS hosts (
3465
- host TEXT PRIMARY KEY,
3466
- next_at REAL NOT NULL DEFAULT 0,
3467
- leased_until REAL NOT NULL DEFAULT 0,
3468
- owner TEXT,
3469
- failures INTEGER NOT NULL DEFAULT 0,
3470
- waits INTEGER NOT NULL DEFAULT 0,
3471
- stop_until REAL,
3472
- stop_reason TEXT
3473
- )`);
3474
- this.db.exec(`CREATE TABLE IF NOT EXISTS validators (
3475
- host TEXT PRIMARY KEY,
3476
- skip_etag INTEGER NOT NULL DEFAULT 0,
3477
- etag TEXT NOT NULL DEFAULT 'unknown',
3478
- last_modified TEXT NOT NULL DEFAULT 'unknown',
3479
- etag_misses INTEGER NOT NULL DEFAULT 0,
3480
- date_misses INTEGER NOT NULL DEFAULT 0,
3481
- since_probe INTEGER NOT NULL DEFAULT 0,
3482
- changed_at REAL NOT NULL DEFAULT 0
3483
- )`);
3484
- const { user_version: version } = this.db.prepare("PRAGMA user_version").get();
3485
- if (version < 1) this.db.exec(`PRAGMA user_version = ${String(1)}`);
3486
- });
3487
- }
3488
- stmt(sql) {
3489
- let statement = this.statements.get(sql);
3490
- if (statement === void 0) {
3491
- statement = this.db.prepare(sql);
3492
- this.statements.set(sql, statement);
3493
- }
3494
- return statement;
3495
- }
3496
- /** This method does one step in one transaction. */
3497
- step(work) {
3498
- if (this.closed) throw new Error("the host state store is closed");
3499
- this.db.exec("BEGIN IMMEDIATE");
3500
- try {
3501
- const result = work();
3502
- this.db.exec("COMMIT");
3503
- return result;
3504
- } catch (error) {
3505
- if (this.db.isTransaction) this.db.exec("ROLLBACK");
3506
- throw error;
3507
- }
3508
- }
3509
- row(host) {
3510
- this.stmt("INSERT OR IGNORE INTO hosts (host) VALUES (?)").run(host);
3511
- return this.stmt("SELECT next_at, leased_until, owner, failures, waits, stop_until, stop_reason FROM hosts WHERE host = ?").get(host);
3512
- }
3513
- /** This method removes the hosts that have nothing to remember: no rest, no lease, no counts and no stop. */
3514
- sweep(now) {
3515
- this.stmt(`DELETE FROM hosts WHERE next_at <= ? AND leased_until <= ? AND failures = 0 AND waits = 0
3516
- AND (stop_until IS NULL OR stop_until <= ?)`).run(now, now, now);
3517
- }
3518
- reserve(host, now, leaseMs) {
3519
- return this.step(() => {
3520
- this.reservations += 1;
3521
- if (this.reservations % SWEEP_EVERY === 0) this.sweep(now);
3522
- const row = this.row(host);
3523
- if (row.leased_until > now) return row.owner === this.owner ? row.leased_until - now : Math.min(this.pollMs, row.leased_until - now);
3524
- if (row.next_at > now) return row.next_at - now;
3525
- this.stmt("UPDATE hosts SET leased_until = ?, owner = ? WHERE host = ?").run(now + leaseMs, this.owner, host);
3526
- return 0;
3527
- });
3528
- }
3529
- record(host, result) {
3530
- return this.step(() => {
3531
- const row = this.row(host);
3532
- let { failures, waits } = row;
3533
- if (result === "success") {
3534
- failures = 0;
3535
- waits = 0;
3536
- } else if (result === "failure") failures += 1;
3537
- else if (result === "wait") waits += 1;
3538
- if (failures !== row.failures || waits !== row.waits) this.stmt("UPDATE hosts SET failures = ?, waits = ? WHERE host = ?").run(failures, waits, host);
3539
- return {
3540
- failures,
3541
- waits
3542
- };
3543
- });
3544
- }
3545
- release(host, nextAt) {
3546
- this.step(() => {
3547
- this.row(host);
3548
- this.stmt(`UPDATE hosts SET next_at = max(next_at, ?),
3549
- leased_until = CASE WHEN owner = ? THEN 0 ELSE leased_until END,
3550
- owner = CASE WHEN owner = ? THEN NULL ELSE owner END
3551
- WHERE host = ?`).run(nextAt, this.owner, this.owner, host);
3552
- });
3553
- }
3554
- defer(host, until) {
3555
- this.step(() => {
3556
- this.row(host);
3557
- this.stmt("UPDATE hosts SET next_at = max(next_at, ?) WHERE host = ?").run(until, host);
3558
- });
3559
- }
3560
- stop(host, until, reason) {
3561
- const end = Number.isFinite(until) ? until : NO_END;
3562
- this.step(() => {
3563
- this.row(host);
3564
- this.stmt(`UPDATE hosts SET stop_until = ?, stop_reason = ?
3565
- WHERE host = ? AND (stop_until IS NULL OR stop_until < ?)`).run(end, reason, host, end);
3566
- });
3567
- }
3568
- stopped(host, now) {
3569
- if (this.closed) throw new Error("the host state store is closed");
3570
- const row = this.stmt("SELECT stop_until, stop_reason FROM hosts WHERE host = ?").get(host);
3571
- if (row === void 0 || row.stop_until === null || row.stop_until <= now) return void 0;
3572
- return {
3573
- until: row.stop_until >= NO_END ? Number.POSITIVE_INFINITY : row.stop_until,
3574
- reason: row.stop_reason ?? "stopped"
3575
- };
3576
- }
3577
- reset(host) {
3578
- this.step(() => {
3579
- if (host === void 0) this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL").run();
3580
- else this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL WHERE host = ?").run(host);
3581
- });
3582
- }
3583
- validators(host) {
3584
- if (this.closed) throw new Error("the host state store is closed");
3585
- const row = this.stmt(`SELECT skip_etag, etag, last_modified, etag_misses, date_misses, since_probe, changed_at
3586
- FROM validators WHERE host = ?`).get(host);
3587
- return row === void 0 ? void 0 : memoOf(row);
3588
- }
3589
- noteValidators(host, report, now) {
3590
- return this.step(() => {
3591
- const memo = nextValidatorMemo(this.validators(host), report, now);
3592
- this.stmt(`INSERT INTO validators (host, skip_etag, etag, last_modified, etag_misses, date_misses, since_probe, changed_at)
3593
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3594
- ON CONFLICT(host) DO UPDATE SET skip_etag = excluded.skip_etag, etag = excluded.etag,
3595
- last_modified = excluded.last_modified, etag_misses = excluded.etag_misses,
3596
- date_misses = excluded.date_misses, since_probe = excluded.since_probe,
3597
- changed_at = excluded.changed_at`).run(host, memo.skipEtag ? 1 : 0, memo.etag, memo.lastModified, memo.etagMisses, memo.dateMisses, memo.sinceProbe, memo.changedAt);
3598
- return memo;
3599
- });
3600
- }
3601
- /** This value is the number of hosts that the store holds. */
3602
- get size() {
3603
- return this.stmt("SELECT count(*) AS n FROM hosts").get().n;
3604
- }
3605
- /** This method closes the file. A second close does nothing. */
3606
- close() {
3607
- if (this.closed) return;
3608
- this.closed = true;
3609
- this.statements.clear();
3610
- this.db.close();
3611
- }
3612
- };
3613
- //#endregion
3614
- //#region src/log.ts
3615
- const MONTH_FILE = /^(\d{2})(?:\.[^.]+)?\.ndjson$/;
3616
- const YEAR_DIR = /^\d{4}$/;
3617
- const NEWLINE = 10;
3618
- /** This function writes the whole buffer. A write to a file can write less than the buffer, and the loop writes the rest. */
3619
- const writeAll = async (handle, bytes) => {
3620
- let at = 0;
3621
- while (at < bytes.length) {
3622
- const { bytesWritten } = await handle.write(bytes, at, bytes.length - at);
3623
- at += bytesWritten;
3624
- }
3625
- };
3626
- /**
3627
- * This function reads a file line by line, as bytes, so a long file does not become one string. It
3628
- * gives each line and tells if a line break ends it.
3629
- */
3630
- async function* linesOf(path) {
3631
- const decoder = new TextDecoder("utf-8");
3632
- let rest;
3633
- for await (const chunk of createReadStream(path)) {
3634
- const data = rest === void 0 ? chunk : Buffer.concat([rest, chunk]);
3635
- rest = void 0;
3636
- let start = 0;
3637
- for (;;) {
3638
- const end = data.indexOf(NEWLINE, start);
3639
- if (end === -1) break;
3640
- yield {
3641
- text: decoder.decode(data.subarray(start, end)),
3642
- complete: true
3643
- };
3644
- start = end + 1;
3645
- }
3646
- if (start < data.length) rest = Buffer.from(data.subarray(start));
3647
- }
3648
- if (rest !== void 0 && rest.length > 0) yield {
3649
- text: decoder.decode(rest),
3650
- complete: false
3651
- };
3652
- }
3653
- const monthKey = (year, file) => `${year}-${MONTH_FILE.exec(file)?.[1] ?? "00"}`;
3654
- /**
3655
- * The capture log keeps each fetch decision as one line of JSON in
3656
- * `<dir>/<source>/<yyyy>/<mm>.ndjson`. It is the record of each access that the crawler makes.
3657
- *
3658
- * These are the rules of the log:
3659
- *
3660
- * - The log appends a batch of records with one write, and it writes the batch to the disk before
3661
- * the appends finish.
3662
- * - An append always starts on a new line. After a crash leaves a partial line, the next append
3663
- * starts after a line break. The partial line stays as a damaged line, and the record is good.
3664
- * - A scan reads each file as a stream. It skips a damaged line, counts it, and continues.
3665
- * - The log does not remove or change a line.
3666
- *
3667
- * Two processes can append to one log, because each batch is one write to a file in append mode.
3668
- */
3669
- var CaptureLog = class CaptureLog {
3670
- dir;
3671
- handles = /* @__PURE__ */ new Map();
3672
- pending = [];
3673
- draining;
3674
- sync;
3675
- constructor(dir, options = {}) {
3676
- this.dir = dir;
3677
- this.sync = options.sync ?? true;
3678
- }
3679
- pathOf(record) {
3680
- return join(this.dir, record.source, record.received_at.slice(0, 4), `${record.received_at.slice(5, 7)}.ndjson`);
3681
- }
3682
- async handleFor(path) {
3683
- const known = this.handles.get(path);
3684
- if (known !== void 0) return known;
3685
- await mkdir(dirname(path), {
3686
- recursive: true,
3687
- mode: 448
3688
- });
3689
- const handle = await open(path, "a+", 384);
3690
- this.handles.set(path, handle);
3691
- return handle;
3692
- }
3693
- /** This method tells if the last byte of a file is not a line break: a partial line that a crash left. */
3694
- static async needsBreak(handle) {
3695
- const { size } = await handle.stat();
3696
- if (size === 0) return false;
3697
- const last = Buffer.alloc(1);
3698
- await handle.read(last, 0, 1, size - 1);
3699
- return last[0] !== NEWLINE;
3700
- }
3701
- append(record) {
3702
- let line;
3703
- try {
3704
- line = `${JSON.stringify(record)}\n`;
3705
- } catch (error) {
3706
- return Promise.reject(error instanceof Error ? error : new Error(String(error)));
3707
- }
3708
- return new Promise((resolve, reject) => {
3709
- this.pending.push({
3710
- path: this.pathOf(record),
3711
- line,
3712
- resolve,
3713
- reject
3714
- });
3715
- this.kick();
3716
- });
3717
- }
3718
- /** This method starts the writer when it is not in progress. A record that comes as the writer stops starts it again. */
3719
- kick() {
3720
- if (this.draining !== void 0) return;
3721
- this.draining = this.drain().finally(() => {
3722
- this.draining = void 0;
3723
- if (this.pending.length > 0) this.kick();
3724
- });
3725
- }
3726
- /** This method writes the waiting records in batches: one write and one sync for each file. */
3727
- async drain() {
3728
- while (this.pending.length > 0) {
3729
- const batch = this.pending.splice(0);
3730
- const byPath = /* @__PURE__ */ new Map();
3731
- for (const item of batch) {
3732
- const list = byPath.get(item.path);
3733
- if (list === void 0) byPath.set(item.path, [item]);
3734
- else list.push(item);
3735
- }
3736
- for (const [path, items] of byPath) try {
3737
- const handle = await this.handleFor(path);
3738
- const lead = await CaptureLog.needsBreak(handle) ? "\n" : "";
3739
- await writeAll(handle, Buffer.from(`${lead}${items.map((item) => item.line).join("")}`, "utf8"));
3740
- if (this.sync) await handle.datasync();
3741
- for (const item of items) item.resolve();
3742
- } catch (error) {
3743
- const handle = this.handles.get(path);
3744
- this.handles.delete(path);
3745
- await handle?.close().catch(() => void 0);
3746
- for (const item of items) item.reject(error);
3747
- }
3748
- }
3749
- }
3750
- /** This method waits until each append in progress is on the disk. */
3751
- async flush() {
3752
- while (this.draining !== void 0) await this.draining;
3753
- }
3754
- /** This method gives the record files of one source, in the sequence of time. It skips the months before `since`. */
3755
- async filesOf(source, since) {
3756
- const sourceDir = join(this.dir, source);
3757
- if (!existsSync(sourceDir)) return [];
3758
- const from = since?.slice(0, 7);
3759
- const years = (await readdir(sourceDir, { withFileTypes: true })).filter((entry) => entry.isDirectory() && YEAR_DIR.test(entry.name)).map((entry) => entry.name).sort();
3760
- const files = [];
3761
- for (const year of years) {
3762
- if (from !== void 0 && year < from.slice(0, 4)) continue;
3763
- const months = (await readdir(join(sourceDir, year))).filter((name) => MONTH_FILE.test(name)).sort();
3764
- for (const month of months) {
3765
- if (from !== void 0 && monthKey(year, month) < from) continue;
3766
- files.push(join(sourceDir, year, month));
3767
- }
3768
- }
3769
- return files;
3770
- }
3771
- /** This method reads the records of one source in the sequence of the files. */
3772
- async *readSource(source, filter, options) {
3773
- for (const path of await this.filesOf(source, filter?.since)) {
3774
- let number = 0;
3775
- for await (const { text, complete } of linesOf(path)) {
3776
- number += 1;
3777
- const trimmed = text.trim();
3778
- if (trimmed.length === 0) continue;
3779
- let row;
3780
- try {
3781
- row = JSON.parse(trimmed);
3782
- if (typeof row !== "object" || row === null || typeof row.received_at !== "string") throw new Error("not a record");
3783
- } catch {
3784
- if (complete) options?.onDamaged?.({
3785
- path,
3786
- line: number
3787
- });
3788
- continue;
3789
- }
3790
- if (filter?.kind !== void 0 && row.kind !== filter.kind) continue;
3791
- if (filter?.since !== void 0 && row.received_at < filter.since) continue;
3792
- yield row;
3793
- }
3794
- }
3795
- }
3796
- /**
3797
- * This method reads the records that agree with the filter. It first waits for the appends that
3798
- * are in progress. It reads each file as a stream, so its memory does not grow with the log.
3799
- *
3800
- * The records of one source come in the sequence of the files. The records of two or more sources
3801
- * merge in the sequence of `received_at`.
3802
- */
3803
- async *scan(filter, options) {
3804
- await this.flush();
3805
- 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() : [];
3806
- if (sources.length === 1) {
3807
- yield* this.readSource(sources[0], filter, options);
3808
- return;
3809
- }
3810
- const streams = sources.map((source) => this.readSource(source, filter, options)[Symbol.asyncIterator]());
3811
- const heads = [];
3812
- for (const stream of streams) heads.push((await stream.next()).value);
3813
- for (;;) {
3814
- let pick = -1;
3815
- for (const [index, head] of heads.entries()) if (head !== void 0 && (pick === -1 || head.received_at < heads[pick].received_at)) pick = index;
3816
- if (pick === -1) return;
3817
- yield heads[pick];
3818
- heads[pick] = (await streams[pick].next()).value;
3819
- }
3820
- }
3821
- /** This method writes the appends in progress, then closes the files. A later append opens them again. */
3822
- async close() {
3823
- await this.flush();
3824
- const handles = [...this.handles.values()];
3825
- this.handles.clear();
3826
- for (const handle of handles) await handle.close();
3827
- }
3828
- };
3829
- //#endregion
3830
- //#region src/pdf.ts
3831
- const MAX_PAGES = 500;
3832
- const MAX_CHARS = 2e5;
3833
- const TIME_BUDGET_MS = 2e4;
3834
- /**
3835
- * The text streams of 35 stored menus and papers inflate to 0.6 MB or less. The parser of pdf.js
3836
- * reads a page with no pause. Thus a page of 8 MB of text operators stops the process for
3837
- * approximately one second.
3838
- */
3839
- const MAX_INFLATED_BYTES = 8388608;
3840
- /** This function finds a PDF from its first bytes or its content type. Some servers put bytes before `%PDF-`. */
3841
- const isPdf = (body, contentType) => {
3842
- if (/\bpdf\b/i.test(contentType ?? "")) return true;
3843
- const end = Math.min(body.length - 4, 1024);
3844
- 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;
3845
- return false;
3846
- };
3847
- /** This function gives one line of text for each line of the PDF. It removes the empty lines. */
3848
- const tidy = (page) => page.split(/\r?\n/).map((line) => line.replace(/\s+/g, " ").trim()).filter((line) => line.length > 0).join("\n");
3849
- /** A word processor writes the name of its file as the title ("Microsoft Word - menu-final-v3.docx"). */
3850
- 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;
3851
- const latin1 = (bytes, start, end) => {
3852
- let out = "";
3853
- for (let index = Math.max(0, start); index < end && index < bytes.length; index += 1) out += String.fromCharCode(bytes[index]);
3854
- return out;
3855
- };
3856
- const indexOf = (bytes, needle, from) => {
3857
- const first = needle.charCodeAt(0);
3858
- for (let index = bytes.indexOf(first, from); index !== -1; index = bytes.indexOf(first, index + 1)) {
3859
- let match = true;
3860
- for (let at = 1; at < needle.length; at += 1) if (bytes[index + at] !== needle.charCodeAt(at)) {
3861
- match = false;
3862
- break;
3863
- }
3864
- if (match) return index;
3865
- }
3866
- return -1;
3867
- };
3868
- /**
3869
- * This function adds up the bytes that the compressed streams of a file inflate to, and stops at
3870
- * the limit. It tries each stream: a stream in another format fails at its first bytes. An image
3871
- * stream does not count, because the text reader does not decode it. The function gives true when
3872
- * the file stays inside the limit.
3873
- */
3874
- const inflatesWithin = (bytes, limit) => {
3875
- let total = 0;
3876
- let from = 0;
3877
- for (;;) {
3878
- const keyword = indexOf(bytes, "stream", from);
3879
- if (keyword === -1) return true;
3880
- from = keyword + 6;
3881
- if (latin1(bytes, keyword - 3, keyword) === "end") continue;
3882
- const next = bytes[keyword + 6];
3883
- if (next !== 10 && next !== 13) continue;
3884
- const dictionary = latin1(bytes, keyword - 1024, keyword);
3885
- const head = dictionary.slice(Math.max(0, dictionary.lastIndexOf("<<")));
3886
- if (/\/Subtype\s*\/Image\b/.test(head)) continue;
3887
- let start = keyword + 7;
3888
- if (next === 13 && bytes[start] === 10) start += 1;
3889
- const declared = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(head);
3890
- const stop = indexOf(bytes, "endstream", start);
3891
- const end = declared !== null && start + Number(declared[1]) <= bytes.length ? start + Number(declared[1]) : stop === -1 ? bytes.length : stop;
3892
- try {
3893
- total += inflateSync(bytes.subarray(start, end), {
3894
- maxOutputLength: Math.max(1, limit - total + 1),
3895
- finishFlush: constants.Z_SYNC_FLUSH
3896
- }).length;
3897
- } catch (error) {
3898
- if (error instanceof RangeError) return false;
3899
- }
3900
- if (total > limit) return false;
3901
- from = Math.max(from, end);
3902
- }
3903
- };
3904
- /** This function lets the other work of the process run, for example another request. */
3905
- const pause = () => new Promise((resolve) => setImmediate(resolve));
3906
- /** This function gives the value of a promise, or undefined when the time ends first. */
3907
- const within = async (promise, ms) => {
3908
- let timer;
3909
- const late = new Promise((resolve) => {
3910
- timer = setTimeout(() => resolve(void 0), Math.max(0, ms));
3911
- });
3912
- try {
3913
- return await Promise.race([promise, late]);
3914
- } finally {
3915
- 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);
3916
2828
  }
3917
2829
  };
3918
2830
  /** This function keeps the first characters of a text, and does not cut a surrogate pair. */
@@ -3926,7 +2838,7 @@ const readPdf = async (bytes, options = {}) => {
3926
2838
  const maxPages = options.maxPages ?? MAX_PAGES;
3927
2839
  const maxChars = options.maxChars ?? MAX_CHARS;
3928
2840
  const budgetMs = options.timeBudgetMs ?? TIME_BUDGET_MS;
3929
- const maxInflated = options.maxInflatedBytes ?? MAX_INFLATED_BYTES;
2841
+ const maxInflated = options.maxInflatedBytes ?? MAX_INFLATED_BYTES$1;
3930
2842
  if (!inflatesWithin(bytes, maxInflated)) return {
3931
2843
  title: null,
3932
2844
  text: "",
@@ -3987,6 +2899,511 @@ const readPdf = async (bytes, options = {}) => {
3987
2899
  }
3988
2900
  };
3989
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
3990
3407
  //#region src/runtime.ts
3991
3408
  /** This function gives the document id of a web page. Each page has one id. */
3992
3409
  const pageDocId = (url) => `url:${canonicalUrl(url)}`;
@@ -4088,6 +3505,7 @@ const pageExtractor = (body, capture) => {
4088
3505
  needs_render: page.needsRender,
4089
3506
  render_reason: page.renderReason,
4090
3507
  canonical: page.canonical,
3508
+ ...page.feeds !== void 0 && page.feeds.length > 0 ? { feeds: page.feeds } : {},
4091
3509
  redirect: page.redirect,
4092
3510
  blocked: page.blocked,
4093
3511
  ...page.notes.length > 0 ? { notes: page.notes } : {}
@@ -4147,9 +3565,10 @@ const pdfDocs = async (body, url) => {
4147
3565
  }];
4148
3566
  };
4149
3567
  var Extractors = class {
4150
- byRecipe = /* @__PURE__ */ new Map([["page", pageExtractor]]);
3568
+ byRecipe = /* @__PURE__ */ new Map([["page", pageExtractor], ["archive-copy", archiveExtractor]]);
3569
+ replaceable = /* @__PURE__ */ new Set(["archive-copy"]);
4151
3570
  register(recipe, extractor) {
4152
- 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`);
4153
3572
  this.byRecipe.set(recipe, extractor);
4154
3573
  }
4155
3574
  get(recipe) {
@@ -4175,7 +3594,9 @@ const STRUCTURAL_META = [
4175
3594
  "format",
4176
3595
  "pages",
4177
3596
  "ocr_needed",
4178
- "truncated"
3597
+ "truncated",
3598
+ "archived",
3599
+ "archived_kind"
4179
3600
  ];
4180
3601
  /** The note of a document that is held back. */
4181
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).";
@@ -4232,7 +3653,7 @@ const indexCapture = async (runtime, fetched, recipe, rendered) => {
4232
3653
  blob: fetched.blob ?? null,
4233
3654
  recipe,
4234
3655
  rendered,
4235
- ai_input: fetched.signals.ai_input,
3656
+ ai_input: doc.ai_input === "no" ? "no" : fetched.signals.ai_input,
4236
3657
  ...fetched.scope !== void 0 ? { scope: fetched.scope } : {},
4237
3658
  ...fetched.labels !== void 0 ? { labels: fetched.labels } : {}
4238
3659
  }, now)) };
@@ -4284,7 +3705,8 @@ const fetchPage = async (runtime, url, options) => {
4284
3705
  job: options.job,
4285
3706
  signal: options.signal,
4286
3707
  scope: options.scope,
4287
- labels: options.labels
3708
+ labels: options.labels,
3709
+ deadline: options.deadline
4288
3710
  });
4289
3711
  let plain = await fetchAndIndex(runtime, plainRequest(url));
4290
3712
  const notes = [];
@@ -4317,7 +3739,8 @@ const fetchPage = async (runtime, url, options) => {
4317
3739
  job: options.job,
4318
3740
  signal: options.signal,
4319
3741
  scope: options.scope,
4320
- labels: options.labels
3742
+ labels: options.labels,
3743
+ deadline: options.deadline
4321
3744
  });
4322
3745
  if (shown.docs.length > 0) {
4323
3746
  docs = shown.docs;
@@ -4326,6 +3749,11 @@ const fetchPage = async (runtime, url, options) => {
4326
3749
  } else notes.push(shown.error ?? shown.fetched.note ?? "the render gave no document");
4327
3750
  }
4328
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
+ }
4329
3757
  const doc = docs[0] === void 0 ? void 0 : runtime.index.get(docs[0].doc) ?? docs[0];
4330
3758
  const held = doc !== void 0 && isHeldBack(doc);
4331
3759
  const types = doc?.meta["jsonld_types"];
@@ -4356,6 +3784,38 @@ const knownDoc = (runtime, id) => {
4356
3784
  if (doc === void 0) throw new Error(`no stored document ${id}: find one with find(), or fetch its page`);
4357
3785
  return doc;
4358
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
+ };
4359
3819
  const wholeNumber = (name, value, fallback) => {
4360
3820
  if (value === void 0) return fallback;
4361
3821
  if (!Number.isFinite(value) || value < 0) throw new RangeError(`${name} must be a number of 0 or more, not ${String(value)}`);
@@ -4399,9 +3859,37 @@ const recordOverride = async (runtime, doc, action, marks = {}) => {
4399
3859
  const readDocument = async (runtime, id, options = {}) => {
4400
3860
  const from = wholeNumber("from", options.from, 0);
4401
3861
  const length = wholeNumber("length", options.length, 4e3);
3862
+ const at = atOf(options);
4402
3863
  const doc = knownDoc(runtime, id);
4403
- 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);
4404
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
+ }
4405
3893
  const aiInput = doc.ai_input === "no" || version?.ai_input === "no" ? "no" : version?.ai_input ?? doc.ai_input;
4406
3894
  const userProvided = options.userProvided ?? false;
4407
3895
  const held = isHeldBack({ ai_input: aiInput }, userProvided);
@@ -4410,6 +3898,8 @@ const readDocument = async (runtime, id, options = {}) => {
4410
3898
  const start = onBoundary(text, Math.min(from, text.length));
4411
3899
  const end = held ? start : onBoundary(text, Math.min(text.length, start + length));
4412
3900
  const meta = version?.meta ?? doc.meta;
3901
+ const archived = archivedOf(meta);
3902
+ const notes = [...held ? [HELD_BACK] : [], ...archived ? [archivedNote(archived)] : []];
4413
3903
  return {
4414
3904
  doc: doc.doc,
4415
3905
  kind: version?.kind ?? doc.kind,
@@ -4425,7 +3915,24 @@ const readDocument = async (runtime, id, options = {}) => {
4425
3915
  fetched_at: version?.fetched_at ?? doc.fetched_at,
4426
3916
  ai_input: aiInput,
4427
3917
  held_back: held,
4428
- 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
4429
3936
  };
4430
3937
  };
4431
3938
  /**
@@ -4433,8 +3940,9 @@ const readDocument = async (runtime, id, options = {}) => {
4433
3940
  * if `userProvided` opened a held-back version, so that `verifyQuote` logs the override.
4434
3941
  */
4435
3942
  const examineQuote = (runtime, id, quote, options) => {
3943
+ const at = atOf(options);
4436
3944
  const doc = knownDoc(runtime, id);
4437
- 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);
4438
3946
  if (options.capture !== void 0 && pinned === void 0) throw new Error(`the document ${id} has no version from the capture ${options.capture}`);
4439
3947
  const userProvided = options.userProvided ?? false;
4440
3948
  const base = {
@@ -4442,23 +3950,48 @@ const examineQuote = (runtime, id, quote, options) => {
4442
3950
  url: doc.url,
4443
3951
  captures: doc.captures
4444
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
+ }
4445
3985
  const aiInput = doc.ai_input === "no" || pinned?.ai_input === "no" ? "no" : doc.ai_input;
4446
3986
  if (isHeldBack({ ai_input: aiInput }, userProvided)) return {
4447
3987
  result: {
4448
3988
  ...base,
4449
- found: false,
4450
- start: null,
4451
- end: null,
4452
- excerpt: null,
4453
- capture: null,
4454
- blob: null,
4455
- fetched_at: null,
4456
- stale: false,
4457
- in_full_text: false,
3989
+ ...nothing,
4458
3990
  complete: false,
4459
- count: 0,
4460
3991
  held_back: true,
4461
- note: HELD_BACK
3992
+ note: HELD_BACK,
3993
+ ...timed,
3994
+ ...archivedField(pinned?.meta ?? doc.meta)
4462
3995
  },
4463
3996
  override: false
4464
3997
  };
@@ -4491,7 +4024,8 @@ const examineQuote = (runtime, id, quote, options) => {
4491
4024
  complete: true,
4492
4025
  count: hit.count,
4493
4026
  held_back: false,
4494
- 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
4495
4029
  },
4496
4030
  override
4497
4031
  };
@@ -4513,7 +4047,8 @@ const examineQuote = (runtime, id, quote, options) => {
4513
4047
  complete: !incomplete,
4514
4048
  count: 0,
4515
4049
  held_back: false,
4516
- note: notes.length > 0 ? notes.join("; ") : null
4050
+ ...withArchived(current?.meta ?? doc.meta, notes.length > 0 ? notes.join("; ") : null),
4051
+ ...timed
4517
4052
  },
4518
4053
  override
4519
4054
  };
@@ -4548,10 +4083,27 @@ const findDocuments = async (runtime, q, options = {}) => {
4548
4083
  limit: options.limit ?? 10,
4549
4084
  kind: options.kind ?? null,
4550
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
+ };
4551
4093
  });
4552
4094
  };
4553
4095
  const RENDERED_MARK = "#rendered";
4554
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
+ ]);
4555
4107
  /** This function gives the URL of a capture and its variant. A URL of version 0.1 can end in `#rendered`. */
4556
4108
  const captureTarget = (payload) => {
4557
4109
  if (payload.url.endsWith(RENDERED_MARK)) return {
@@ -4594,7 +4146,7 @@ const replayLatest = (build, envelope, payload) => {
4594
4146
  });
4595
4147
  return;
4596
4148
  }
4597
- if (payload.cut === true || payload.unasked === true) return;
4149
+ if (payload.cut === true || payload.unasked === true || payload.range !== void 0) return;
4598
4150
  const previous = build.latest(url, variant);
4599
4151
  const revisit = envelope.kind === "revisit" || payload.outcome === "not-modified";
4600
4152
  const truncated = payload.truncated === true;
@@ -4634,7 +4186,7 @@ const replayRecord = async (runtime, target, envelope, tally) => {
4634
4186
  replayLatest(target, envelope, payload);
4635
4187
  const extractor = runtime.extractors.get(payload.recipe);
4636
4188
  if (extractor === void 0) {
4637
- if (payload.recipe !== "robots.txt") tally.unknown.add(payload.recipe);
4189
+ if (!CRAWLER_RECIPES.has(payload.recipe)) tally.unknown.add(payload.recipe);
4638
4190
  return;
4639
4191
  }
4640
4192
  const body = await runtime.blobs.get(payload.blob);
@@ -4667,7 +4219,7 @@ const replayRecord = async (runtime, target, envelope, tally) => {
4667
4219
  blob: payload.blob,
4668
4220
  recipe: payload.recipe,
4669
4221
  rendered: variant === "rendered",
4670
- ai_input: payload.signals?.ai_input ?? "unset",
4222
+ ai_input: doc.ai_input === "no" ? "no" : payload.signals?.ai_input ?? "unset",
4671
4223
  ...envelope.scope !== void 0 ? { scope: envelope.scope } : {},
4672
4224
  ...envelope.labels !== void 0 ? { labels: envelope.labels } : {}
4673
4225
  }, envelope.received_at);
@@ -4722,6 +4274,7 @@ const missed = (runtime, envelope) => {
4722
4274
  const entry = runtime.index.latest(url, variant);
4723
4275
  if (payload.outcome === "redirect") return entry === void 0 || entry.fetched_at < fetchedAt;
4724
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);
4725
4278
  if (entry === void 0 || entry.fetched_at < fetchedAt) return true;
4726
4279
  return runtime.extractors.get(payload.recipe) !== void 0 && !runtime.index.hasVersion(envelope.id);
4727
4280
  };
@@ -4766,230 +4319,6 @@ const catchUpIndex = async (runtime) => {
4766
4319
  };
4767
4320
  };
4768
4321
  //#endregion
4769
- //#region src/open.ts
4770
- var open_exports = /* @__PURE__ */ __exportAll({
4771
- BOT_PAGE: () => BOT_PAGE,
4772
- DEFAULT_TOKEN: () => DEFAULT_TOKEN,
4773
- createRuntime: () => createRuntime,
4774
- defaultUserAgent: () => defaultUserAgent,
4775
- durationMs: () => durationMs,
4776
- isMailbox: () => isMailbox,
4777
- openCrawler: () => openCrawler
4778
- });
4779
- /**
4780
- * This function gives the default `User-Agent` header. The header names the crawler, its version
4781
- * and the page about it, so a site owner can find out what made each request.
4782
- */
4783
- const defaultUserAgent = (contact) => `${DEFAULT_TOKEN}/${VERSION} (+${BOT_PAGE}${contact === void 0 ? "" : `; ${contact}`})`;
4784
- /** A mailbox is `name@host`, or a name and `<name@host>`. A URL is not a mailbox, also when it holds an `@`. */
4785
- const MAILBOX = /^(?:[^\s<>@:/]+@[^\s<>@/]+|[^<>]*<[^\s<>@:/]+@[^\s<>@/]+>)$/;
4786
- /**
4787
- * This function tells if a contact is a mailbox. The `From` header holds only a mailbox (RFC 9110,
4788
- * section 10.1.2). Thus the crawler sends a mailbox in `From` and in the `User-Agent`, and a URL in
4789
- * the `User-Agent` only.
4790
- */
4791
- const isMailbox = (contact) => MAILBOX.test(contact.trim());
4792
- const UNITS = {
4793
- ms: 1,
4794
- s: 1e3,
4795
- m: 6e4,
4796
- h: 36e5,
4797
- d: 864e5,
4798
- w: 6048e5
4799
- };
4800
- /**
4801
- * This function changes a duration into milliseconds. The units are `ms`, `s`, `m` (minutes), `h`,
4802
- * `d` and `w`, in any case. The unit `M` is an error, because a month is not a unit. A bare `0`
4803
- * is zero. The function throws an error for a text that it cannot read.
4804
- */
4805
- const durationMs = (duration) => {
4806
- if (typeof duration === "number") {
4807
- if (!Number.isFinite(duration) || duration < 0) throw new Error(`not a duration: ${String(duration)}`);
4808
- return duration;
4809
- }
4810
- if (/^\s*0+(\.0+)?\s*$/.test(duration)) return 0;
4811
- const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)\s*$/i.exec(duration);
4812
- const unit = match?.[2];
4813
- const factor = unit === void 0 || unit === "M" ? void 0 : UNITS[unit.toLowerCase()];
4814
- 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)`);
4815
- return Number(match[1]) * factor;
4816
- };
4817
- /** The parts of a store that can be a symbolic link to a different place only by mistake or by attack. */
4818
- const STORE_PARTS = [
4819
- "captures",
4820
- "blobs",
4821
- "index.sqlite",
4822
- "hosts.sqlite"
4823
- ];
4824
- /**
4825
- * This function makes the folder of a store for the owner only, where the system has modes. It
4826
- * refuses a store folder of a different user, and a part of the store that is a symbolic link.
4827
- * The store folder itself can be a link, for example to a larger disk.
4828
- */
4829
- const guardStore = (dir) => {
4830
- mkdirSync(dir, {
4831
- recursive: true,
4832
- mode: 448
4833
- });
4834
- const real = lstatSync(dir).isSymbolicLink() ? realpathSync(dir) : dir;
4835
- const info = statSync(real);
4836
- if (!info.isDirectory()) throw new Error(`the store ${dir} is not a folder`);
4837
- 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`);
4838
- for (const part of STORE_PARTS) {
4839
- let link = false;
4840
- try {
4841
- link = lstatSync(join(dir, part)).isSymbolicLink();
4842
- } catch {}
4843
- if (link) throw new Error(`${part} in the store ${dir} is a symbolic link: the crawler does not follow it`);
4844
- }
4845
- };
4846
- /**
4847
- * This function makes a runtime. It is the one place that puts the log, the blob store, the index,
4848
- * the host state, the crawler and the extractors together. The function `openCrawler` uses it.
4849
- * An application that needs the parts uses it too. The runtime and the crawler share one clock.
4850
- */
4851
- const createRuntime = (options) => {
4852
- const dir = options.dir;
4853
- 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");
4854
- const contact = options.contact;
4855
- if (contact !== void 0 && !/^[\t -~]*$/.test(contact)) throw new Error(`contact "${contact}" has a character that an HTTP header cannot hold: use printable ASCII`);
4856
- if (dir !== void 0) guardStore(dir);
4857
- const owned = [];
4858
- const log = options.log ?? new CaptureLog(join(dir, "captures"));
4859
- const blobs = options.blobs ?? new FileBlobStore(join(dir, "blobs"));
4860
- let index = options.index;
4861
- if (index === void 0) {
4862
- const made = new DocIndex(join(dir, "index.sqlite"));
4863
- owned.push(() => made.close());
4864
- index = made;
4865
- }
4866
- let hostState = options.hostState;
4867
- if (hostState === void 0) {
4868
- if (dir === void 0) hostState = new MemoryHostStateStore();
4869
- else {
4870
- const made = new SqliteHostStateStore(join(dir, "hosts.sqlite"));
4871
- owned.push(() => made.close());
4872
- hostState = made;
4873
- }
4874
- }
4875
- const clock = options.clock ?? realClock;
4876
- const renderer = options.renderer === false ? void 0 : options.renderer ?? checkedRenderer(playwrightRenderer());
4877
- const crawler = new Crawler({
4878
- token: options.token ?? "PoliteCrawler",
4879
- userAgent: options.userAgent ?? defaultUserAgent(contact),
4880
- from: contact !== void 0 && isMailbox(contact) ? contact.trim() : void 0,
4881
- log,
4882
- blobs,
4883
- index,
4884
- renderer,
4885
- maxActive: options.maxActive,
4886
- maxRenders: options.maxRenders,
4887
- dispatcher: options.dispatcher,
4888
- egress: options.egress,
4889
- politeness: options.politeness,
4890
- hostState,
4891
- clock,
4892
- random: options.random,
4893
- scope: options.scope,
4894
- scopeRank: options.scopeRank,
4895
- obeyDefaultToken: options.obeyDefaultToken,
4896
- onEvent: options.onEvent
4897
- });
4898
- const extractors = new Extractors();
4899
- for (const [recipe, extractor] of Object.entries(options.extractors ?? {})) extractors.register(recipe, extractor);
4900
- let closing;
4901
- return {
4902
- crawler,
4903
- index,
4904
- blobs,
4905
- captures: log,
4906
- log,
4907
- extractors,
4908
- hostState,
4909
- now: () => new Date(clock.now()).toISOString(),
4910
- close() {
4911
- closing ??= (async () => {
4912
- await crawler.close();
4913
- for (const close of owned) await close();
4914
- })();
4915
- return closing;
4916
- }
4917
- };
4918
- };
4919
- /**
4920
- * This function opens a crawler on a folder. The folder keeps the capture log (`captures/`), the
4921
- * response bodies (`blobs/`), the search index (`index.sqlite`) and the state of each host
4922
- * (`hosts.sqlite`). The index is a projection: `reindex` makes it again from the log and the bodies.
4923
- * At open, `catchUpIndex` rebuilds an index of an earlier version, and gives an index that is
4924
- * behind the log the captures that it missed.
4925
- *
4926
- * Each method gives a promise, and none throws before its promise. After `close`, each method
4927
- * rejects.
4928
- */
4929
- const openCrawler = (options) => {
4930
- const runtime = createRuntime(options);
4931
- let closed = false;
4932
- let rebuilt;
4933
- const ready = existsSync(join(options.dir, "captures")) ? catchUpIndex(runtime).then((done) => {
4934
- if (done?.mode === "rebuild") {
4935
- const { mode, ...counts } = done;
4936
- rebuilt = mode === "rebuild" ? counts : void 0;
4937
- }
4938
- }, () => void 0) : Promise.resolve();
4939
- const live = async () => {
4940
- if (closed) throw new Error("the crawler is closed");
4941
- await ready;
4942
- if (closed) throw new Error("the crawler is closed");
4943
- };
4944
- return {
4945
- runtime,
4946
- async fetch(url, fetchOptions = {}) {
4947
- await live();
4948
- rebuilt = void 0;
4949
- const maxAge = fetchOptions.maxAge ?? "24h";
4950
- return fetchPage(runtime, url, {
4951
- render: fetchOptions.render ?? "auto",
4952
- maxAgeMs: maxAge === "auto" ? "auto" : durationMs(maxAge),
4953
- onBehalfOf: [...options.onBehalfOf ?? [], ...fetchOptions.onBehalfOf ?? []],
4954
- signal: fetchOptions.signal,
4955
- scope: fetchOptions.scope,
4956
- labels: fetchOptions.labels
4957
- });
4958
- },
4959
- async find(q, findOptions = {}) {
4960
- await live();
4961
- return findDocuments(runtime, q, findOptions);
4962
- },
4963
- async read(id, readOptions = {}) {
4964
- await live();
4965
- return readDocument(runtime, id, readOptions);
4966
- },
4967
- async verify(id, quote, verifyOptions = {}) {
4968
- await live();
4969
- return verifyQuote(runtime, id, quote, verifyOptions);
4970
- },
4971
- async versions(id, versionOptions = {}) {
4972
- await live();
4973
- const held = runtime.index.get(id)?.ai_input === "no";
4974
- return runtime.index.versions(id, versionOptions).map((version) => held || version.ai_input === "no" ? {
4975
- ...version,
4976
- title: ""
4977
- } : version);
4978
- },
4979
- async reindex() {
4980
- await live();
4981
- const done = rebuilt;
4982
- rebuilt = void 0;
4983
- return done ?? reindex(runtime);
4984
- },
4985
- async close() {
4986
- closed = true;
4987
- await ready;
4988
- await runtime.close();
4989
- }
4990
- };
4991
- };
4992
- //#endregion
4993
- export { Crawler as A, readPdf as C, FAST_RANK_SHARE as D, DocIndex as E, egressLookup as F, FileBlobStore as I, renderedKey as M, makeRecord as N, locate as O, EgressRefused 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, DEFAULT_SECRET_PARAMS as j, CAPTURE_SCHEMA 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 { snippetOf as A, egressLookup as B, fallbackReason as C, foldText as D, fold as E, renderedKey as F, makeRecord as I, sha256 as L, Crawler as M, DEADLINE_NOTE as N, locate as O, DEFAULT_SECRET_PARAMS as P, stableStringify as R, archivedOf as S, readPdf as T, versionAtTime as _, fetchPage as a, archivedDocId as b, isHeldBack as c, pageExtractor as d, readDocument as f, verifyQuote as g, renderAndIndex as h, fetchAndIndex as i, CAPTURE_SCHEMA as j, parseQuery 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, isPdf as w, archivedNote as x, archivedCopy as y, EgressRefused as z };
4994
4323
 
4995
- //# sourceMappingURL=open-DYZ7KL2A.mjs.map
4324
+ //# sourceMappingURL=runtime-JToRO63o.mjs.map