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