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