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
|
@@ -0,0 +1,3161 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { n as FileBlobStore, t as CaptureLog } from "./log-Dx-wXjJl.mjs";
|
|
3
|
+
import { r as playwrightRenderer } from "./render-C1CUraWF.mjs";
|
|
4
|
+
import { r as checkedRenderer } from "./browser-CKRNbIDT.mjs";
|
|
5
|
+
import { A as Crawler, D as parseQuery, F as sha256, I as stableStringify, O as snippetOf, a as fetchPage, c as isHeldBack, f as readDocument, g as verifyQuote, i as fetchAndIndex, j as DEADLINE_NOTE, m as reindex, o as findDocuments, r as catchUpIndex, t as Extractors, w as fold } from "./runtime-k8mo1_7U.mjs";
|
|
6
|
+
import { MemoryHostStateStore, nextValidatorMemo, realClock } from "./politeness.mjs";
|
|
7
|
+
import { matchesPattern } from "./robots.mjs";
|
|
8
|
+
import { i as VERSION, n as BOT_PAGE, r as DEFAULT_TOKEN, t as durationMs } from "./duration-iIuMMofi.mjs";
|
|
9
|
+
import { n as canonicalUrl, r as scopeTest, t as cacheKeyOf } from "./urls-BHFeSyEK.mjs";
|
|
10
|
+
import { o as decodeBody, t as extractPage } from "./html-DkQUAE_d.mjs";
|
|
11
|
+
import { n as diffDocument, r as diffTexts } from "./diff-Dk2eSvi-.mjs";
|
|
12
|
+
import { r as extractDocument } from "./extract-DcwNrTK7.mjs";
|
|
13
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
|
16
|
+
import { createGunzip } from "node:zlib";
|
|
17
|
+
import { DatabaseSync } from "node:sqlite";
|
|
18
|
+
/** The search index reads this many characters of each text, at most. */
|
|
19
|
+
const MAX_INDEX_CHARS = 2e5;
|
|
20
|
+
/** In the mode `fast`, a part is common when more than this share of the documents hold it. */
|
|
21
|
+
const FAST_RANK_SHARE = .2;
|
|
22
|
+
/** This function gives the rank mode of an option. A value that is not a rank mode throws a `RangeError`. */
|
|
23
|
+
const rankModeOf = (value) => {
|
|
24
|
+
if (value === void 0 || value === "exact") return "exact";
|
|
25
|
+
if (value === "fast") return "fast";
|
|
26
|
+
throw new RangeError(`rank must be "exact" or "fast", not ${JSON.stringify(value) ?? "undefined"}`);
|
|
27
|
+
};
|
|
28
|
+
const namesOf = (generation) => ({
|
|
29
|
+
docs: `docs_${String(generation)}`,
|
|
30
|
+
fts: `fts_${String(generation)}`,
|
|
31
|
+
versions: `versions_${String(generation)}`,
|
|
32
|
+
texts: `texts_${String(generation)}`,
|
|
33
|
+
latest: `latest_${String(generation)}`
|
|
34
|
+
});
|
|
35
|
+
const GENERATION_TABLE = /^(docs|fts|versions|texts|latest)_(\d+)$/;
|
|
36
|
+
/** A build that started this long ago and did not finish belongs to a process that stopped. */
|
|
37
|
+
const ABANDONED_MS = 864e5;
|
|
38
|
+
/** The FTS5 tokenizer: folded text, English stems, and spacing marks kept in the words. */
|
|
39
|
+
const TOKENIZER = `tokenize = "porter unicode61 remove_diacritics 0 categories 'L* N* Co Mc'"`;
|
|
40
|
+
const ddlOf = (n) => `
|
|
41
|
+
CREATE TABLE IF NOT EXISTS ${n.texts} (
|
|
42
|
+
sha TEXT PRIMARY KEY,
|
|
43
|
+
body TEXT NOT NULL
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS ${n.versions} (
|
|
46
|
+
doc TEXT NOT NULL,
|
|
47
|
+
capture TEXT NOT NULL,
|
|
48
|
+
recipe TEXT NOT NULL,
|
|
49
|
+
rendered INTEGER NOT NULL,
|
|
50
|
+
fetched_at TEXT NOT NULL,
|
|
51
|
+
blob TEXT,
|
|
52
|
+
kind TEXT NOT NULL,
|
|
53
|
+
title TEXT NOT NULL,
|
|
54
|
+
url TEXT,
|
|
55
|
+
meta_sha TEXT NOT NULL,
|
|
56
|
+
text_sha TEXT NOT NULL,
|
|
57
|
+
text_chars INTEGER NOT NULL,
|
|
58
|
+
full_sha TEXT,
|
|
59
|
+
ai_input TEXT NOT NULL,
|
|
60
|
+
merge TEXT NOT NULL,
|
|
61
|
+
scope TEXT,
|
|
62
|
+
labels TEXT,
|
|
63
|
+
PRIMARY KEY (doc, capture)
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS ${n.versions}_time ON ${n.versions} (doc, recipe, rendered, fetched_at);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS ${n.versions}_capture ON ${n.versions} (capture);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS ${n.versions}_fetched ON ${n.versions} (fetched_at);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS ${n.versions}_recipe ON ${n.versions} (recipe);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS ${n.docs} (
|
|
70
|
+
doc TEXT PRIMARY KEY,
|
|
71
|
+
kind TEXT NOT NULL,
|
|
72
|
+
title TEXT NOT NULL,
|
|
73
|
+
url TEXT,
|
|
74
|
+
meta TEXT NOT NULL,
|
|
75
|
+
text_sha TEXT NOT NULL,
|
|
76
|
+
full_sha TEXT,
|
|
77
|
+
captures TEXT NOT NULL,
|
|
78
|
+
capture_count INTEGER NOT NULL,
|
|
79
|
+
capture TEXT NOT NULL,
|
|
80
|
+
blob TEXT,
|
|
81
|
+
fetched_at TEXT NOT NULL,
|
|
82
|
+
recipe TEXT NOT NULL,
|
|
83
|
+
rendered INTEGER NOT NULL,
|
|
84
|
+
ai_input TEXT NOT NULL,
|
|
85
|
+
scope TEXT,
|
|
86
|
+
labels TEXT,
|
|
87
|
+
updated_at TEXT NOT NULL
|
|
88
|
+
);
|
|
89
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS ${n.fts} USING fts5(
|
|
90
|
+
title, text, content = '', contentless_delete = 1, ${TOKENIZER}
|
|
91
|
+
);
|
|
92
|
+
CREATE TABLE IF NOT EXISTS ${n.latest} (
|
|
93
|
+
url TEXT NOT NULL,
|
|
94
|
+
variant TEXT NOT NULL,
|
|
95
|
+
capture TEXT NOT NULL,
|
|
96
|
+
blob TEXT,
|
|
97
|
+
etag TEXT,
|
|
98
|
+
last_modified TEXT,
|
|
99
|
+
content_type TEXT,
|
|
100
|
+
fetched_at TEXT NOT NULL,
|
|
101
|
+
status INTEGER NOT NULL,
|
|
102
|
+
ai_input TEXT NOT NULL,
|
|
103
|
+
location TEXT,
|
|
104
|
+
truncated INTEGER,
|
|
105
|
+
fresh_until TEXT,
|
|
106
|
+
vary_key TEXT,
|
|
107
|
+
PRIMARY KEY (url, variant)
|
|
108
|
+
);
|
|
109
|
+
CREATE INDEX IF NOT EXISTS ${n.latest}_fetched ON ${n.latest} (fetched_at);
|
|
110
|
+
`;
|
|
111
|
+
const RENDERED_MARK = "#rendered";
|
|
112
|
+
/** 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. */
|
|
113
|
+
const keyOf = (url, variant) => {
|
|
114
|
+
if (url.endsWith(RENDERED_MARK)) return {
|
|
115
|
+
url: cacheKeyOf(url.slice(0, -9)),
|
|
116
|
+
variant: "rendered"
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
url: cacheKeyOf(url),
|
|
120
|
+
variant: variant ?? "plain"
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
const sha = (text) => createHash("sha256").update(text, "utf8").digest("hex");
|
|
124
|
+
const parseJson$1 = (text, fallback) => {
|
|
125
|
+
if (text === null || text === void 0) return fallback;
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(text);
|
|
128
|
+
} catch {
|
|
129
|
+
return fallback;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const toLatest = (row) => ({
|
|
133
|
+
url: row.url,
|
|
134
|
+
capture: row.capture,
|
|
135
|
+
blob: row.blob,
|
|
136
|
+
etag: row.etag,
|
|
137
|
+
last_modified: row.last_modified,
|
|
138
|
+
content_type: row.content_type,
|
|
139
|
+
fetched_at: row.fetched_at,
|
|
140
|
+
status: row.status,
|
|
141
|
+
ai_input: row.ai_input,
|
|
142
|
+
location: row.location,
|
|
143
|
+
truncated: row.truncated === 1 ? true : void 0,
|
|
144
|
+
fresh_until: row.fresh_until,
|
|
145
|
+
vary_key: row.vary_key,
|
|
146
|
+
variant: row.variant
|
|
147
|
+
});
|
|
148
|
+
const toVersion = (row) => ({
|
|
149
|
+
doc: row.doc,
|
|
150
|
+
capture: row.capture,
|
|
151
|
+
recipe: row.recipe,
|
|
152
|
+
rendered: row.rendered === 1,
|
|
153
|
+
fetched_at: row.fetched_at,
|
|
154
|
+
blob: row.blob,
|
|
155
|
+
kind: row.kind,
|
|
156
|
+
title: row.title,
|
|
157
|
+
url: row.url,
|
|
158
|
+
ai_input: row.ai_input,
|
|
159
|
+
chars: row.text_chars,
|
|
160
|
+
cut: row.full_sha !== null
|
|
161
|
+
});
|
|
162
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
163
|
+
var DocIndex = class {
|
|
164
|
+
path;
|
|
165
|
+
db;
|
|
166
|
+
statements = /* @__PURE__ */ new Map();
|
|
167
|
+
depth = 0;
|
|
168
|
+
closed = false;
|
|
169
|
+
empty;
|
|
170
|
+
/**
|
|
171
|
+
* This value is true when the file had a different schema, or no tables, so the index opened
|
|
172
|
+
* empty. A store with captures then needs `reindex`. The commit of a rebuild makes it false.
|
|
173
|
+
*/
|
|
174
|
+
get stale() {
|
|
175
|
+
return this.empty;
|
|
176
|
+
}
|
|
177
|
+
constructor(path, options = {}) {
|
|
178
|
+
this.path = path;
|
|
179
|
+
if (path !== ":memory:") mkdirSync(dirname(path), {
|
|
180
|
+
recursive: true,
|
|
181
|
+
mode: 448
|
|
182
|
+
});
|
|
183
|
+
this.db = new DatabaseSync(path);
|
|
184
|
+
const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
|
|
185
|
+
this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
|
|
186
|
+
try {
|
|
187
|
+
this.db.exec("CREATE VIRTUAL TABLE temp.polite_fts5_probe USING fts5(x, content = '', contentless_delete = 1); DROP TABLE temp.polite_fts5_probe;");
|
|
188
|
+
} catch (cause) {
|
|
189
|
+
this.db.close();
|
|
190
|
+
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 });
|
|
191
|
+
}
|
|
192
|
+
if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
|
|
193
|
+
this.db.exec("PRAGMA synchronous = NORMAL;");
|
|
194
|
+
this.empty = this.write(() => this.prepareSchema());
|
|
195
|
+
}
|
|
196
|
+
/** This method makes the tables of the schema. It gives true when the index opened empty. */
|
|
197
|
+
prepareSchema() {
|
|
198
|
+
const version = this.db.prepare("PRAGMA user_version").get().user_version;
|
|
199
|
+
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
200
|
+
if (version === 2 && tables.some((table) => table.name === "index_meta")) {
|
|
201
|
+
this.dropAbandoned();
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
const virtual = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'").all();
|
|
205
|
+
for (const { name } of virtual) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
206
|
+
const rest = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
207
|
+
for (const { name } of rest) this.db.exec(`DROP TABLE IF EXISTS "${name}"`);
|
|
208
|
+
this.db.exec("CREATE TABLE index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
|
|
209
|
+
this.db.exec(ddlOf(namesOf(1)));
|
|
210
|
+
this.statements.clear();
|
|
211
|
+
this.setMeta("generation", "1");
|
|
212
|
+
this.setMeta("next_generation", "2");
|
|
213
|
+
this.db.exec(`PRAGMA user_version = ${String(2)}`);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
setMeta(key, value) {
|
|
217
|
+
this.stmt("INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
|
|
218
|
+
}
|
|
219
|
+
getMeta(key) {
|
|
220
|
+
return this.stmt("SELECT value FROM index_meta WHERE key = ?").get(key)?.value;
|
|
221
|
+
}
|
|
222
|
+
stmt(sql) {
|
|
223
|
+
let statement = this.statements.get(sql);
|
|
224
|
+
if (statement === void 0) {
|
|
225
|
+
statement = this.db.prepare(sql);
|
|
226
|
+
this.statements.set(sql, statement);
|
|
227
|
+
}
|
|
228
|
+
return statement;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* This method does a write in one transaction. A transaction in progress takes the write in,
|
|
232
|
+
* also a transaction that the caller opened on the connection.
|
|
233
|
+
*/
|
|
234
|
+
write(work) {
|
|
235
|
+
if (this.closed) throw new Error("the index is closed");
|
|
236
|
+
if (this.depth > 0 || this.db.isTransaction) {
|
|
237
|
+
this.depth += 1;
|
|
238
|
+
try {
|
|
239
|
+
return work();
|
|
240
|
+
} finally {
|
|
241
|
+
this.depth -= 1;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
245
|
+
this.depth = 1;
|
|
246
|
+
try {
|
|
247
|
+
const result = work();
|
|
248
|
+
this.db.exec("COMMIT");
|
|
249
|
+
return result;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (this.db.isTransaction) this.db.exec("ROLLBACK");
|
|
252
|
+
throw error;
|
|
253
|
+
} finally {
|
|
254
|
+
this.depth = 0;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
generation() {
|
|
258
|
+
return Number(this.getMeta("generation") ?? "1");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* This method does a step on the tables of the current generation. A rebuild of a different
|
|
262
|
+
* process can remove those tables in the meantime. Then the method reads the generation again
|
|
263
|
+
* and does the step once more.
|
|
264
|
+
*/
|
|
265
|
+
live(step) {
|
|
266
|
+
if (this.closed) throw new Error("the index is closed");
|
|
267
|
+
try {
|
|
268
|
+
return step(namesOf(this.generation()));
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (!/no such table/i.test(messageOf(error)) || this.depth > 0) throw error;
|
|
271
|
+
this.statements.clear();
|
|
272
|
+
return step(namesOf(this.generation()));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** This method removes the tables of a generation. */
|
|
276
|
+
dropGeneration(generation) {
|
|
277
|
+
const n = namesOf(generation);
|
|
278
|
+
for (const table of [
|
|
279
|
+
n.fts,
|
|
280
|
+
n.docs,
|
|
281
|
+
n.versions,
|
|
282
|
+
n.texts,
|
|
283
|
+
n.latest
|
|
284
|
+
]) this.db.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
285
|
+
this.db.prepare("DELETE FROM index_meta WHERE key = ?").run(`building:${String(generation)}`);
|
|
286
|
+
this.statements.clear();
|
|
287
|
+
}
|
|
288
|
+
/** This method removes the generations of builds that a stopped process left. */
|
|
289
|
+
dropAbandoned() {
|
|
290
|
+
const current = this.generation();
|
|
291
|
+
const generations = /* @__PURE__ */ new Set();
|
|
292
|
+
for (const { name } of this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()) {
|
|
293
|
+
const match = GENERATION_TABLE.exec(name);
|
|
294
|
+
if (match !== null) generations.add(Number(match[2]));
|
|
295
|
+
}
|
|
296
|
+
for (const generation of generations) {
|
|
297
|
+
if (generation === current) continue;
|
|
298
|
+
const started = this.getMeta(`building:${String(generation)}`);
|
|
299
|
+
if (!((started === void 0 ? Number.POSITIVE_INFINITY : Date.now() - Date.parse(started)) < ABANDONED_MS)) this.dropGeneration(generation);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
putText(n, body) {
|
|
303
|
+
const key = sha(body);
|
|
304
|
+
this.stmt(`INSERT OR IGNORE INTO ${n.texts} (sha, body) VALUES (?, ?)`).run(key, body);
|
|
305
|
+
return key;
|
|
306
|
+
}
|
|
307
|
+
textOf(n, key) {
|
|
308
|
+
if (key === null) return void 0;
|
|
309
|
+
return this.stmt(`SELECT body FROM ${n.texts} WHERE sha = ?`).get(key)?.body;
|
|
310
|
+
}
|
|
311
|
+
putVersion(n, input, now) {
|
|
312
|
+
const full = input.full_text !== void 0 && input.full_text !== input.text ? this.putText(n, input.full_text) : null;
|
|
313
|
+
this.stmt(`INSERT INTO ${n.versions} (doc, capture, recipe, rendered, fetched_at, blob, kind, title, url,
|
|
314
|
+
meta_sha, text_sha, text_chars, full_sha, ai_input, merge, scope, labels)
|
|
315
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
316
|
+
ON CONFLICT(doc, capture) DO UPDATE SET recipe = excluded.recipe, rendered = excluded.rendered,
|
|
317
|
+
fetched_at = excluded.fetched_at, blob = excluded.blob, kind = excluded.kind, title = excluded.title,
|
|
318
|
+
url = excluded.url, meta_sha = excluded.meta_sha, text_sha = excluded.text_sha,
|
|
319
|
+
text_chars = excluded.text_chars, full_sha = excluded.full_sha, ai_input = excluded.ai_input,
|
|
320
|
+
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));
|
|
321
|
+
}
|
|
322
|
+
/** This method gives the newest version of a document for one recipe and one variant, at `until` or before when it is given. */
|
|
323
|
+
newest(n, doc, recipe, rendered, until) {
|
|
324
|
+
if (until === void 0) return this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = ?
|
|
325
|
+
ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered);
|
|
326
|
+
return this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = ? AND fetched_at <= ?
|
|
327
|
+
ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered, until);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* This method chooses the current version of a document with the rules of the class. With
|
|
331
|
+
* `until`, it uses only the versions that the crawler fetched at that time or before. Thus it
|
|
332
|
+
* gives the version that was current at that time. It gives undefined when no version agrees.
|
|
333
|
+
*/
|
|
334
|
+
choose(n, doc, until) {
|
|
335
|
+
const groups = until === void 0 ? this.stmt(`SELECT DISTINCT recipe, rendered FROM ${n.versions} WHERE doc = ?`).all(doc) : this.stmt(`SELECT DISTINCT recipe, rendered FROM ${n.versions} WHERE doc = ? AND fetched_at <= ?`).all(doc, until);
|
|
336
|
+
if (groups.length === 0) return void 0;
|
|
337
|
+
const byRecipe = /* @__PURE__ */ new Map();
|
|
338
|
+
for (const group of groups) {
|
|
339
|
+
const row = this.newest(n, doc, group.recipe, group.rendered, until);
|
|
340
|
+
if (row === void 0) continue;
|
|
341
|
+
const entry = byRecipe.get(group.recipe) ?? {};
|
|
342
|
+
if (group.rendered === 1) entry.rendered = row;
|
|
343
|
+
else entry.plain = row;
|
|
344
|
+
byRecipe.set(group.recipe, entry);
|
|
345
|
+
}
|
|
346
|
+
const candidates = [];
|
|
347
|
+
for (const [recipe, { plain, rendered }] of byRecipe) {
|
|
348
|
+
let pick;
|
|
349
|
+
if (rendered === void 0) pick = plain;
|
|
350
|
+
else if (plain === void 0 || rendered.fetched_at >= plain.fetched_at) pick = rendered;
|
|
351
|
+
else {
|
|
352
|
+
const base = this.stmt(`SELECT blob FROM ${n.versions} WHERE doc = ? AND recipe = ? AND rendered = 0 AND fetched_at <= ?
|
|
353
|
+
ORDER BY fetched_at DESC, capture DESC LIMIT 1`).get(doc, recipe, rendered.fetched_at);
|
|
354
|
+
pick = base !== void 0 && base.blob !== null && base.blob === plain.blob ? rendered : plain;
|
|
355
|
+
}
|
|
356
|
+
const newest = plain === void 0 ? rendered : rendered === void 0 || plain.fetched_at > rendered.fetched_at ? plain : rendered;
|
|
357
|
+
const others = [plain, rendered].filter((row) => row !== void 0 && row !== pick);
|
|
358
|
+
candidates.push({
|
|
359
|
+
row: pick,
|
|
360
|
+
ai: newest.ai_input,
|
|
361
|
+
others
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
let winner = candidates[0];
|
|
365
|
+
if (candidates.length > 1) {
|
|
366
|
+
const latest = candidates.every((candidate) => candidate.row.merge === "latest");
|
|
367
|
+
for (const candidate of candidates.slice(1)) {
|
|
368
|
+
const a = candidate.row;
|
|
369
|
+
const b = winner.row;
|
|
370
|
+
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;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const aiInput = winner.row.ai_input === "no" || candidates.some((candidate) => candidate.ai === "no") ? "no" : winner.ai !== "unset" ? winner.ai : candidates.some((candidate) => candidate.ai === "yes") ? "yes" : "unset";
|
|
374
|
+
return {
|
|
375
|
+
winner,
|
|
376
|
+
candidates,
|
|
377
|
+
aiInput
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/** This method chooses the current version of a document from its versions, and writes it with its search entry. */
|
|
381
|
+
recompute(n, doc, now) {
|
|
382
|
+
const before = this.stmt(`SELECT rowid, text_sha, title FROM ${n.docs} WHERE doc = ?`).get(doc);
|
|
383
|
+
const choice = this.choose(n, doc);
|
|
384
|
+
if (choice === void 0) {
|
|
385
|
+
if (before !== void 0) {
|
|
386
|
+
this.stmt(`DELETE FROM ${n.fts} WHERE rowid = ?`).run(before.rowid);
|
|
387
|
+
this.stmt(`DELETE FROM ${n.docs} WHERE doc = ?`).run(doc);
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const { winner, candidates, aiInput } = choice;
|
|
392
|
+
const row = winner.row;
|
|
393
|
+
let meta = parseJson$1(this.textOf(n, row.meta_sha), {});
|
|
394
|
+
for (const candidate of candidates) if (candidate !== winner) meta = {
|
|
395
|
+
...parseJson$1(this.textOf(n, candidate.row.meta_sha), {}),
|
|
396
|
+
...meta
|
|
397
|
+
};
|
|
398
|
+
const same = this.stmt(`SELECT capture FROM ${n.versions} WHERE doc = ? AND text_sha = ?
|
|
399
|
+
ORDER BY fetched_at DESC, capture DESC LIMIT ?`).all(doc, row.text_sha, 10);
|
|
400
|
+
const captures = [.../* @__PURE__ */ new Set([
|
|
401
|
+
row.capture,
|
|
402
|
+
...same.map((entry) => entry.capture),
|
|
403
|
+
...winner.others.map((other) => other.capture),
|
|
404
|
+
...candidates.filter((candidate) => candidate !== winner).map((candidate) => candidate.row.capture)
|
|
405
|
+
])].slice(0, 10);
|
|
406
|
+
const count = this.stmt(`SELECT count(*) AS n FROM ${n.versions} WHERE doc = ?`).get(doc).n;
|
|
407
|
+
const url = row.url ?? candidates.map((candidate) => candidate.row.url).find((value) => value !== null) ?? null;
|
|
408
|
+
const saved = this.stmt(`INSERT INTO ${n.docs} (doc, kind, title, url, meta, text_sha, full_sha, captures, capture_count, capture,
|
|
409
|
+
blob, fetched_at, recipe, rendered, ai_input, scope, labels, updated_at)
|
|
410
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
411
|
+
ON CONFLICT(doc) DO UPDATE SET kind = excluded.kind, title = excluded.title, url = excluded.url,
|
|
412
|
+
meta = excluded.meta, text_sha = excluded.text_sha, full_sha = excluded.full_sha,
|
|
413
|
+
captures = excluded.captures, capture_count = excluded.capture_count, capture = excluded.capture,
|
|
414
|
+
blob = excluded.blob, fetched_at = excluded.fetched_at, recipe = excluded.recipe,
|
|
415
|
+
rendered = excluded.rendered, ai_input = excluded.ai_input, scope = excluded.scope,
|
|
416
|
+
labels = excluded.labels, updated_at = excluded.updated_at
|
|
417
|
+
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);
|
|
418
|
+
const text = this.textOf(n, row.text_sha) ?? "";
|
|
419
|
+
if (before === void 0 || before.text_sha !== row.text_sha || before.title !== row.title) {
|
|
420
|
+
if (before !== void 0) this.stmt(`DELETE FROM ${n.fts} WHERE rowid = ?`).run(before.rowid);
|
|
421
|
+
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"));
|
|
422
|
+
}
|
|
423
|
+
return this.docOf(n, doc, text);
|
|
424
|
+
}
|
|
425
|
+
docOf(n, doc, knownText) {
|
|
426
|
+
const row = this.stmt(`SELECT rowid, * FROM ${n.docs} WHERE doc = ?`).get(doc);
|
|
427
|
+
if (row === void 0) return void 0;
|
|
428
|
+
return this.toDoc(n, row, knownText);
|
|
429
|
+
}
|
|
430
|
+
toDoc(n, row, knownText) {
|
|
431
|
+
const labels = parseJson$1(row.labels, void 0);
|
|
432
|
+
return {
|
|
433
|
+
doc: row.doc,
|
|
434
|
+
kind: row.kind,
|
|
435
|
+
title: row.title,
|
|
436
|
+
text: knownText ?? this.textOf(n, row.text_sha) ?? "",
|
|
437
|
+
url: row.url,
|
|
438
|
+
meta: parseJson$1(row.meta, {}),
|
|
439
|
+
captures: parseJson$1(row.captures, []),
|
|
440
|
+
capture_count: row.capture_count,
|
|
441
|
+
capture: row.capture,
|
|
442
|
+
blob: row.blob,
|
|
443
|
+
fetched_at: row.fetched_at,
|
|
444
|
+
recipe: row.recipe,
|
|
445
|
+
rendered: row.rendered === 1,
|
|
446
|
+
ai_input: row.ai_input,
|
|
447
|
+
cut: row.full_sha !== null,
|
|
448
|
+
...row.scope !== null ? { scope: row.scope } : {},
|
|
449
|
+
...labels !== void 0 ? { labels } : {},
|
|
450
|
+
updated_at: row.updated_at
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
versionTextOf(n, row) {
|
|
454
|
+
return {
|
|
455
|
+
...toVersion(row),
|
|
456
|
+
text: this.textOf(n, row.text_sha) ?? "",
|
|
457
|
+
full_text: this.textOf(n, row.full_sha),
|
|
458
|
+
meta: parseJson$1(this.textOf(n, row.meta_sha), {})
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
get(doc) {
|
|
462
|
+
return this.live((n) => this.docOf(n, doc));
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* This method adds one version of a document, and it gives the current document. The rules of
|
|
466
|
+
* the class choose the current version. An upsert of a known capture replaces that version.
|
|
467
|
+
*/
|
|
468
|
+
upsert(input, now) {
|
|
469
|
+
return this.live((n) => this.write(() => {
|
|
470
|
+
this.putVersion(n, input, now);
|
|
471
|
+
return this.recompute(n, input.doc, now);
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
/** This method gives the versions of a document, the newest first. */
|
|
475
|
+
versions(doc, options = {}) {
|
|
476
|
+
const limit = Math.max(1, Math.floor(options.limit ?? 100));
|
|
477
|
+
return this.live((n) => this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? ORDER BY fetched_at DESC, capture DESC LIMIT ?`).all(doc, limit).map(toVersion));
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* This method gives the text of one version of a document. With no capture, it gives the current
|
|
481
|
+
* version. It also gives the text of a held-back version: the caller applies `isHeldBack` to the
|
|
482
|
+
* `ai_input` of the version.
|
|
483
|
+
*/
|
|
484
|
+
versionText(doc, capture) {
|
|
485
|
+
return this.live((n) => {
|
|
486
|
+
const id = capture ?? this.docOf(n, doc)?.capture;
|
|
487
|
+
if (id === void 0) return void 0;
|
|
488
|
+
const row = this.stmt(`SELECT * FROM ${n.versions} WHERE doc = ? AND capture = ?`).get(doc, id);
|
|
489
|
+
return row === void 0 ? void 0 : this.versionTextOf(n, row);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* This method gives the version of a document that was current at a time (ISO 8601). It applies
|
|
494
|
+
* the rules of the class to the versions that the crawler fetched at that time or before. Thus
|
|
495
|
+
* a later capture does not change the answer. It also gives a held-back version: its `ai_input`
|
|
496
|
+
* is `no` when the document was held back at that time, and the caller applies `isHeldBack`.
|
|
497
|
+
*/
|
|
498
|
+
versionAt(doc, at) {
|
|
499
|
+
return this.live((n) => {
|
|
500
|
+
const first = this.stmt(`SELECT min(fetched_at) AS first FROM ${n.versions} WHERE doc = ?`).get(doc)?.first ?? void 0;
|
|
501
|
+
const choice = this.choose(n, doc, at);
|
|
502
|
+
if (choice === void 0) return {
|
|
503
|
+
version: void 0,
|
|
504
|
+
first
|
|
505
|
+
};
|
|
506
|
+
const version = this.versionTextOf(n, choice.winner.row);
|
|
507
|
+
return {
|
|
508
|
+
version: choice.aiInput === "no" ? {
|
|
509
|
+
...version,
|
|
510
|
+
ai_input: "no"
|
|
511
|
+
} : version,
|
|
512
|
+
first
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* This method gives the texts of the earlier versions of a document, the newest first. Each
|
|
518
|
+
* text comes one time, with the newest version that has it. It skips the current text. It also
|
|
519
|
+
* gives held-back texts: the caller applies `isHeldBack` to the `ai_input` of each version.
|
|
520
|
+
*/
|
|
521
|
+
earlierTexts(doc, options = {}) {
|
|
522
|
+
const limit = Math.max(1, Math.floor(options.limit ?? 50));
|
|
523
|
+
return this.live((n) => {
|
|
524
|
+
const head = this.stmt(`SELECT text_sha FROM ${n.docs} WHERE doc = ?`).get(doc);
|
|
525
|
+
if (head === void 0) return [];
|
|
526
|
+
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);
|
|
527
|
+
const seen = /* @__PURE__ */ new Set();
|
|
528
|
+
const out = [];
|
|
529
|
+
for (const row of rows) {
|
|
530
|
+
if (seen.has(row.text_sha)) continue;
|
|
531
|
+
seen.add(row.text_sha);
|
|
532
|
+
out.push(this.versionTextOf(n, row));
|
|
533
|
+
if (out.length >= limit) break;
|
|
534
|
+
}
|
|
535
|
+
return out;
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/** This method tells if a capture gave a version of a document. */
|
|
539
|
+
hasVersion(capture) {
|
|
540
|
+
return this.live((n) => this.stmt(`SELECT 1 AS found FROM ${n.versions} WHERE capture = ? LIMIT 1`).get(capture) !== void 0);
|
|
541
|
+
}
|
|
542
|
+
/** This method gives the newest fetch time of the cache entries, or undefined for an empty index. */
|
|
543
|
+
newestFetch() {
|
|
544
|
+
return this.live((n) => this.stmt(`SELECT max(fetched_at) AS newest FROM ${n.latest}`).get()?.newest ?? void 0);
|
|
545
|
+
}
|
|
546
|
+
/** This method gives the current documents that a capture gave. */
|
|
547
|
+
docsOfCapture(capture) {
|
|
548
|
+
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));
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* This method searches the documents with BM25, the best first. The syntax of the query is the
|
|
552
|
+
* syntax of `parseQuery`. It first asks for each part. When that gives too few documents, it
|
|
553
|
+
* adds the documents that match one part. A document that asked not to be AI input is not in
|
|
554
|
+
* the results, so a search cannot show its text. The option `rank` chooses the ranking (`RankMode`).
|
|
555
|
+
*/
|
|
556
|
+
find(q, options) {
|
|
557
|
+
const rank = rankModeOf(options.rank);
|
|
558
|
+
const query = parseQuery(q);
|
|
559
|
+
if (query === void 0) return [];
|
|
560
|
+
const limit = Math.max(1, Math.min(1e3, Math.floor(options.limit) || 10));
|
|
561
|
+
return this.live((n) => {
|
|
562
|
+
const kindClause = options.kind === void 0 || options.kind === null ? "" : "AND d.kind = ?";
|
|
563
|
+
const sql = `SELECT d.rowid AS rowid, d.*, bm25(${n.fts}, 3.0, 1.0) AS rank
|
|
564
|
+
FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
|
|
565
|
+
WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' ${kindClause}
|
|
566
|
+
ORDER BY rank LIMIT ?`;
|
|
567
|
+
const run = (match) => {
|
|
568
|
+
const params = [match];
|
|
569
|
+
if (kindClause.length > 0) params.push(options.kind);
|
|
570
|
+
params.push(limit);
|
|
571
|
+
return this.stmt(sql).all(...params);
|
|
572
|
+
};
|
|
573
|
+
const rows = run(query.all);
|
|
574
|
+
if (rows.length < limit && query.parts > 1) {
|
|
575
|
+
const seen = new Set(rows.map((row) => row.doc));
|
|
576
|
+
for (const row of run(rank === "fast" ? this.rareParts(n, query.anyParts) : query.any)) {
|
|
577
|
+
if (rows.length >= limit) break;
|
|
578
|
+
if (!seen.has(row.doc)) rows.push(row);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return rows.map((row) => ({
|
|
582
|
+
doc: row.doc,
|
|
583
|
+
kind: row.kind,
|
|
584
|
+
title: row.title,
|
|
585
|
+
url: row.url,
|
|
586
|
+
snippet: snippetOf(this.textOf(n, row.text_sha) ?? "", query.terms),
|
|
587
|
+
score: Math.round(-row.rank * 1e3) / 1e3,
|
|
588
|
+
ai_input: row.ai_input,
|
|
589
|
+
fetched_at: row.fetched_at,
|
|
590
|
+
capture: row.capture
|
|
591
|
+
}));
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* This method scores a set of documents against a query, with the best first. It leaves out a
|
|
596
|
+
* document that matches no term, and a document that asked not to be AI input. It ranks the
|
|
597
|
+
* results of a source again, because the order of the source is only as good as its search engine.
|
|
598
|
+
* In the mode `fast`, it also leaves out a document that holds only common parts (`RankMode`).
|
|
599
|
+
*/
|
|
600
|
+
rankWithin(q, docs, options = {}) {
|
|
601
|
+
const rank = rankModeOf(options.rank);
|
|
602
|
+
const query = parseQuery(q);
|
|
603
|
+
if (query === void 0 || docs.length === 0) return [];
|
|
604
|
+
return this.live((n) => this.stmt(`SELECT d.doc AS doc, bm25(${n.fts}, 3.0, 1.0) AS rank
|
|
605
|
+
FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
|
|
606
|
+
WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' AND d.doc IN (SELECT value FROM json_each(?))
|
|
607
|
+
ORDER BY rank`).all(rank === "fast" ? this.rareParts(n, query.anyParts) : query.any, JSON.stringify(docs)).map((row) => ({
|
|
608
|
+
doc: row.doc,
|
|
609
|
+
score: Math.round(-row.rank * 1e3) / 1e3
|
|
610
|
+
})));
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* This method gives the form of a query that needs one part, without the common parts (the mode
|
|
614
|
+
* `fast` of `RankMode`). It counts the documents that hold each part. When each part is common,
|
|
615
|
+
* it keeps the rarest part.
|
|
616
|
+
*/
|
|
617
|
+
rareParts(n, parts) {
|
|
618
|
+
if (parts.length < 2) return parts.join(" OR ");
|
|
619
|
+
const total = this.stmt(`SELECT count(*) AS n FROM ${n.docs}`).get().n;
|
|
620
|
+
const counted = parts.map((part) => ({
|
|
621
|
+
part,
|
|
622
|
+
count: this.stmt(`SELECT count(*) AS n FROM ${n.fts} WHERE ${n.fts} MATCH ?`).get(part).n
|
|
623
|
+
}));
|
|
624
|
+
const rare = counted.filter((one) => one.count <= FAST_RANK_SHARE * total);
|
|
625
|
+
if (rare.length > 0) return rare.map((one) => one.part).join(" OR ");
|
|
626
|
+
return counted.reduce((best, one) => one.count < best.count ? one : best).part;
|
|
627
|
+
}
|
|
628
|
+
/** This method gives the cache entry of a URL. The key is canonical, so two spellings of one URL give one entry. */
|
|
629
|
+
latest(url, variant) {
|
|
630
|
+
const key = keyOf(url, variant);
|
|
631
|
+
return this.live((n) => {
|
|
632
|
+
const row = this.stmt(`SELECT * FROM ${n.latest} WHERE url = ? AND variant = ?`).get(key.url, key.variant);
|
|
633
|
+
return row === void 0 ? void 0 : toLatest(row);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
putLatest(n, entry) {
|
|
637
|
+
const key = keyOf(entry.url, entry.variant);
|
|
638
|
+
this.stmt(`INSERT INTO ${n.latest} (url, variant, capture, blob, etag, last_modified, content_type, fetched_at,
|
|
639
|
+
status, ai_input, location, truncated, fresh_until, vary_key)
|
|
640
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
641
|
+
ON CONFLICT(url, variant) DO UPDATE SET capture = excluded.capture, blob = excluded.blob,
|
|
642
|
+
etag = excluded.etag, last_modified = excluded.last_modified, content_type = excluded.content_type,
|
|
643
|
+
fetched_at = excluded.fetched_at, status = excluded.status, ai_input = excluded.ai_input,
|
|
644
|
+
location = excluded.location, truncated = excluded.truncated, fresh_until = excluded.fresh_until,
|
|
645
|
+
vary_key = excluded.vary_key
|
|
646
|
+
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);
|
|
647
|
+
}
|
|
648
|
+
/** This method keeps the last answer for a URL. An entry that is older than the stored entry changes nothing. */
|
|
649
|
+
setLatest(entry) {
|
|
650
|
+
this.live((n) => this.write(() => this.putLatest(n, entry)));
|
|
651
|
+
}
|
|
652
|
+
counts() {
|
|
653
|
+
return this.live((n) => ({
|
|
654
|
+
docs: this.stmt(`SELECT count(*) AS n FROM ${n.docs}`).get().n,
|
|
655
|
+
urls: this.stmt(`SELECT count(*) AS n FROM ${n.latest}`).get().n
|
|
656
|
+
}));
|
|
657
|
+
}
|
|
658
|
+
/** This method empties the current generation. A rebuild uses `beginRebuild`, which keeps the index until its commit. */
|
|
659
|
+
reset() {
|
|
660
|
+
this.live((n) => this.write(() => {
|
|
661
|
+
for (const table of [
|
|
662
|
+
n.fts,
|
|
663
|
+
n.docs,
|
|
664
|
+
n.versions,
|
|
665
|
+
n.texts,
|
|
666
|
+
n.latest
|
|
667
|
+
]) this.db.exec(`DELETE FROM ${table}`);
|
|
668
|
+
}));
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* This method starts a rebuild into a new generation of the tables. The current generation
|
|
672
|
+
* answers each read and takes each write until the commit. A failed rebuild thus leaves the
|
|
673
|
+
* index as it was.
|
|
674
|
+
*/
|
|
675
|
+
beginRebuild(options = {}) {
|
|
676
|
+
const batchSize = Math.max(1, Math.floor(options.batch ?? 500));
|
|
677
|
+
const generation = this.write(() => {
|
|
678
|
+
const next = Number(this.getMeta("next_generation") ?? "2");
|
|
679
|
+
this.setMeta("next_generation", String(next + 1));
|
|
680
|
+
this.setMeta(`building:${String(next)}`, (/* @__PURE__ */ new Date()).toISOString());
|
|
681
|
+
this.db.exec(ddlOf(namesOf(next)));
|
|
682
|
+
return next;
|
|
683
|
+
});
|
|
684
|
+
const n = namesOf(generation);
|
|
685
|
+
const versions = [];
|
|
686
|
+
const latest = /* @__PURE__ */ new Map();
|
|
687
|
+
let open = true;
|
|
688
|
+
const keyText = (url, variant) => {
|
|
689
|
+
const key = keyOf(url, variant);
|
|
690
|
+
return `${key.variant} ${key.url}`;
|
|
691
|
+
};
|
|
692
|
+
const flush = () => {
|
|
693
|
+
if (!open) throw new Error("the rebuild is over");
|
|
694
|
+
if (versions.length === 0 && latest.size === 0) return;
|
|
695
|
+
this.write(() => {
|
|
696
|
+
const touched = /* @__PURE__ */ new Map();
|
|
697
|
+
for (const { input, now } of versions) {
|
|
698
|
+
this.putVersion(n, input, now);
|
|
699
|
+
touched.set(input.doc, now);
|
|
700
|
+
}
|
|
701
|
+
for (const entry of latest.values()) this.putLatest(n, entry);
|
|
702
|
+
for (const [doc, now] of touched) this.recompute(n, doc, now);
|
|
703
|
+
});
|
|
704
|
+
versions.length = 0;
|
|
705
|
+
latest.clear();
|
|
706
|
+
};
|
|
707
|
+
/** This function copies the texts of the versions of the current generation that agree with `where`. */
|
|
708
|
+
const copyTexts = (current, where, params) => {
|
|
709
|
+
for (const column of [
|
|
710
|
+
"text_sha",
|
|
711
|
+
"meta_sha",
|
|
712
|
+
"full_sha"
|
|
713
|
+
]) this.db.prepare(`INSERT OR IGNORE INTO ${n.texts} (sha, body)
|
|
714
|
+
SELECT t.sha, t.body FROM ${current.texts} t
|
|
715
|
+
WHERE t.sha IN (SELECT ${column} FROM ${current.versions} WHERE ${where})`).run(...params);
|
|
716
|
+
};
|
|
717
|
+
return {
|
|
718
|
+
upsert: (input, now) => {
|
|
719
|
+
if (!open) throw new Error("the rebuild is over");
|
|
720
|
+
versions.push({
|
|
721
|
+
input,
|
|
722
|
+
now
|
|
723
|
+
});
|
|
724
|
+
if (versions.length >= batchSize) flush();
|
|
725
|
+
},
|
|
726
|
+
setLatest: (entry) => {
|
|
727
|
+
if (!open) throw new Error("the rebuild is over");
|
|
728
|
+
const key = keyText(entry.url, entry.variant);
|
|
729
|
+
const before = latest.get(key);
|
|
730
|
+
if (before === void 0 || entry.fetched_at >= before.fetched_at) latest.set(key, entry);
|
|
731
|
+
if (latest.size >= batchSize) flush();
|
|
732
|
+
},
|
|
733
|
+
latest: (url, variant) => {
|
|
734
|
+
const waiting = latest.get(keyText(url, variant));
|
|
735
|
+
if (waiting !== void 0) return waiting;
|
|
736
|
+
const key = keyOf(url, variant);
|
|
737
|
+
const row = this.stmt(`SELECT * FROM ${n.latest} WHERE url = ? AND variant = ?`).get(key.url, key.variant);
|
|
738
|
+
return row === void 0 ? void 0 : toLatest(row);
|
|
739
|
+
},
|
|
740
|
+
flush,
|
|
741
|
+
carry: (recipes) => {
|
|
742
|
+
flush();
|
|
743
|
+
const known = JSON.stringify([...recipes]);
|
|
744
|
+
const current = namesOf(this.generation());
|
|
745
|
+
const docs = this.db.prepare(`SELECT DISTINCT doc FROM ${current.versions} WHERE recipe NOT IN (SELECT value FROM json_each(?))`).all(known);
|
|
746
|
+
const where = "recipe NOT IN (SELECT value FROM json_each(?)) AND doc IN (SELECT value FROM json_each(?))";
|
|
747
|
+
for (let at = 0; at < docs.length; at += batchSize) {
|
|
748
|
+
const part = JSON.stringify(docs.slice(at, at + batchSize).map((row) => row.doc));
|
|
749
|
+
this.write(() => {
|
|
750
|
+
copyTexts(current, where, [known, part]);
|
|
751
|
+
this.db.prepare(`INSERT OR IGNORE INTO ${n.versions} SELECT * FROM ${current.versions} WHERE ${where}`).run(known, part);
|
|
752
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
753
|
+
for (const doc of JSON.parse(part)) this.recompute(n, doc, now);
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
return docs.length;
|
|
757
|
+
},
|
|
758
|
+
commit: (since) => {
|
|
759
|
+
flush();
|
|
760
|
+
open = false;
|
|
761
|
+
return this.write(() => {
|
|
762
|
+
const current = this.generation();
|
|
763
|
+
const old = namesOf(current);
|
|
764
|
+
copyTexts(old, "fetched_at >= ?", [since]);
|
|
765
|
+
const added = this.db.prepare(`SELECT DISTINCT doc FROM ${old.versions} WHERE fetched_at >= ?`).all(since);
|
|
766
|
+
this.db.prepare(`INSERT OR IGNORE INTO ${n.versions} SELECT * FROM ${old.versions} WHERE fetched_at >= ?`).run(since);
|
|
767
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
768
|
+
for (const { doc } of added) this.recompute(n, doc, now);
|
|
769
|
+
for (const row of this.db.prepare(`SELECT * FROM ${old.latest} WHERE fetched_at >= ?`).all(since)) this.putLatest(n, toLatest(row));
|
|
770
|
+
this.setMeta("generation", String(generation));
|
|
771
|
+
this.db.prepare("DELETE FROM index_meta WHERE key = ?").run(`building:${String(generation)}`);
|
|
772
|
+
this.dropGeneration(current);
|
|
773
|
+
this.empty = false;
|
|
774
|
+
return {
|
|
775
|
+
docs: this.db.prepare(`SELECT count(*) AS n FROM ${n.docs}`).get().n,
|
|
776
|
+
urls: this.db.prepare(`SELECT count(*) AS n FROM ${n.latest}`).get().n
|
|
777
|
+
};
|
|
778
|
+
});
|
|
779
|
+
},
|
|
780
|
+
abort: () => {
|
|
781
|
+
open = false;
|
|
782
|
+
versions.length = 0;
|
|
783
|
+
latest.clear();
|
|
784
|
+
if (this.closed) return;
|
|
785
|
+
this.write(() => this.dropGeneration(generation));
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/** This method closes the file. A second close does nothing. */
|
|
790
|
+
close() {
|
|
791
|
+
if (this.closed) return;
|
|
792
|
+
this.closed = true;
|
|
793
|
+
this.statements.clear();
|
|
794
|
+
this.db.close();
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region src/sitemaps.ts
|
|
799
|
+
/** These are the default limits of the reader, and the maximum length of one value. */
|
|
800
|
+
const SITEMAP_LIMITS = {
|
|
801
|
+
maxEntries: 5e4,
|
|
802
|
+
maxDepth: 32,
|
|
803
|
+
/** A value (a URL or a date) longer than this number of characters is not valid. */
|
|
804
|
+
maxValue: 4096
|
|
805
|
+
};
|
|
806
|
+
const CHANGEFREQ = /* @__PURE__ */ new Set([
|
|
807
|
+
"always",
|
|
808
|
+
"hourly",
|
|
809
|
+
"daily",
|
|
810
|
+
"weekly",
|
|
811
|
+
"monthly",
|
|
812
|
+
"yearly",
|
|
813
|
+
"never"
|
|
814
|
+
]);
|
|
815
|
+
const SITEMAP_NS = [
|
|
816
|
+
"http://www.sitemaps.org/schemas/sitemap/0.9",
|
|
817
|
+
"http://www.google.com/schemas/sitemap/0.84",
|
|
818
|
+
"http://www.google.com/schemas/sitemap/0.9"
|
|
819
|
+
];
|
|
820
|
+
const ATOM_NS = ["http://www.w3.org/2005/Atom"];
|
|
821
|
+
const RSS1_NS = ["http://purl.org/rss/1.0/"];
|
|
822
|
+
const DC_NS = ["http://purl.org/dc/elements/1.1/"];
|
|
823
|
+
const RDF_NS = ["http://www.w3.org/1999/02/22-rdf-syntax-ns#"];
|
|
824
|
+
const XML_ENTITIES = {
|
|
825
|
+
amp: "&",
|
|
826
|
+
lt: "<",
|
|
827
|
+
gt: ">",
|
|
828
|
+
quot: "\"",
|
|
829
|
+
apos: "'"
|
|
830
|
+
};
|
|
831
|
+
/** A tag keeps this number of attributes at most. The reader reads the others and keeps nothing of them. */
|
|
832
|
+
const MAX_ATTRIBUTES = 64;
|
|
833
|
+
const NAME = /^[A-Za-z_][\w.:-]*$/;
|
|
834
|
+
const W3C_DATE = /^\d{4}(?:-\d{2}(?:-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?)?)?$/;
|
|
835
|
+
const MAIL_DATE = /^(?:[A-Za-z]{3},?\s+)?\d{1,2}\s+[A-Za-z]{3,9}\s+\d{2,4}\s+\d{1,2}:\d{2}(?::\d{2})?(?:\s*(?:[A-Za-z]{1,5}|[+-]\d{4}))?$/;
|
|
836
|
+
const short = (name) => name === void 0 ? "nothing" : name.length > 40 ? `${name.slice(0, 40)}...` : name;
|
|
837
|
+
/**
|
|
838
|
+
* This function changes a date of a sitemap or a feed into ISO 8601. It reads the W3C form of the
|
|
839
|
+
* sitemap protocol and Atom, and the mail form of RSS. A time with no zone is UTC. It gives null
|
|
840
|
+
* for a text that it cannot read.
|
|
841
|
+
*/
|
|
842
|
+
const feedDate = (value) => {
|
|
843
|
+
const text = value.trim();
|
|
844
|
+
if (text.length === 0 || text.length > 64) return null;
|
|
845
|
+
let parse;
|
|
846
|
+
const w3c = W3C_DATE.exec(text);
|
|
847
|
+
if (w3c !== null) {
|
|
848
|
+
const iso = text.replace(" ", "T");
|
|
849
|
+
parse = iso.includes("T") && w3c[1] === void 0 ? `${iso}Z` : iso;
|
|
850
|
+
} else if (MAIL_DATE.test(text)) parse = text;
|
|
851
|
+
if (parse === void 0) return null;
|
|
852
|
+
const ms = Date.parse(parse);
|
|
853
|
+
return Number.isFinite(ms) && Math.abs(ms) < 864e13 ? new Date(ms).toISOString() : null;
|
|
854
|
+
};
|
|
855
|
+
/** This function gives the canonical form of an http(s) URL, or undefined. A relative URL needs a base. */
|
|
856
|
+
const webUrl = (value, base) => {
|
|
857
|
+
const text = value.trim();
|
|
858
|
+
if (text.length === 0 || text.length > SITEMAP_LIMITS.maxValue) return void 0;
|
|
859
|
+
try {
|
|
860
|
+
const url = base === void 0 ? new URL(text) : new URL(text, base);
|
|
861
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
862
|
+
return canonicalUrl(url.href);
|
|
863
|
+
} catch {
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
/** This function decodes the five entities of XML and the character references. It gives undefined for each other entity. */
|
|
868
|
+
const decodeText = (raw, meter) => {
|
|
869
|
+
meter.steps += raw.length;
|
|
870
|
+
if (!raw.includes("&")) return raw;
|
|
871
|
+
let out = "";
|
|
872
|
+
let at = 0;
|
|
873
|
+
for (;;) {
|
|
874
|
+
const amp = raw.indexOf("&", at);
|
|
875
|
+
if (amp === -1) return out + raw.slice(at);
|
|
876
|
+
out += raw.slice(at, amp);
|
|
877
|
+
const semi = raw.indexOf(";", amp + 1);
|
|
878
|
+
if (semi === -1 || semi - amp > 10) return void 0;
|
|
879
|
+
const name = raw.slice(amp + 1, semi);
|
|
880
|
+
let char = XML_ENTITIES[name];
|
|
881
|
+
if (char === void 0 && /^#(?:\d{1,7}|x[0-9a-fA-F]{1,6})$/.test(name)) {
|
|
882
|
+
const code = name[1] === "x" ? Number.parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
883
|
+
if (code > 0 && code <= 1114111 && (code < 55296 || code > 57343)) char = String.fromCodePoint(code);
|
|
884
|
+
}
|
|
885
|
+
if (char === void 0) return void 0;
|
|
886
|
+
out += char;
|
|
887
|
+
at = semi + 1;
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
const isSpace = (code) => code === 32 || code === 9 || code === 10 || code === 13;
|
|
891
|
+
/** This function reads a start tag from the position after its `<`. It gives undefined for a tag that is not well formed. */
|
|
892
|
+
const readTag = (text, from, meter) => {
|
|
893
|
+
const tag = tagAt(text, from);
|
|
894
|
+
meter.steps += (tag?.end ?? text.length) - from;
|
|
895
|
+
return tag;
|
|
896
|
+
};
|
|
897
|
+
const tagAt = (text, from) => {
|
|
898
|
+
const n = text.length;
|
|
899
|
+
let at = from;
|
|
900
|
+
while (at < n) {
|
|
901
|
+
const code = text.charCodeAt(at);
|
|
902
|
+
if (isSpace(code) || code === 47 || code === 62) break;
|
|
903
|
+
at += 1;
|
|
904
|
+
}
|
|
905
|
+
const name = text.slice(from, Math.min(at, from + 256));
|
|
906
|
+
if (at - from > 256 || !NAME.test(name)) return void 0;
|
|
907
|
+
const attributes = [];
|
|
908
|
+
for (;;) {
|
|
909
|
+
while (at < n && isSpace(text.charCodeAt(at))) at += 1;
|
|
910
|
+
if (at >= n) return void 0;
|
|
911
|
+
const code = text.charCodeAt(at);
|
|
912
|
+
if (code === 62) return {
|
|
913
|
+
name,
|
|
914
|
+
attributes,
|
|
915
|
+
selfClosing: false,
|
|
916
|
+
end: at + 1
|
|
917
|
+
};
|
|
918
|
+
if (code === 47) return text.charCodeAt(at + 1) === 62 ? {
|
|
919
|
+
name,
|
|
920
|
+
attributes,
|
|
921
|
+
selfClosing: true,
|
|
922
|
+
end: at + 2
|
|
923
|
+
} : void 0;
|
|
924
|
+
const start = at;
|
|
925
|
+
while (at < n) {
|
|
926
|
+
const next = text.charCodeAt(at);
|
|
927
|
+
if (isSpace(next) || next === 61 || next === 62 || next === 47) break;
|
|
928
|
+
at += 1;
|
|
929
|
+
}
|
|
930
|
+
const key = text.slice(start, Math.min(at, start + 256));
|
|
931
|
+
if (at - start > 256 || !NAME.test(key)) return void 0;
|
|
932
|
+
while (at < n && isSpace(text.charCodeAt(at))) at += 1;
|
|
933
|
+
if (text.charCodeAt(at) !== 61) return void 0;
|
|
934
|
+
at += 1;
|
|
935
|
+
while (at < n && isSpace(text.charCodeAt(at))) at += 1;
|
|
936
|
+
const quote = text[at];
|
|
937
|
+
if (quote !== "\"" && quote !== "'") return void 0;
|
|
938
|
+
const close = text.indexOf(quote, at + 1);
|
|
939
|
+
if (close === -1) return void 0;
|
|
940
|
+
if (attributes.length < MAX_ATTRIBUTES && close - at <= SITEMAP_LIMITS.maxValue) attributes.push([key, text.slice(at + 1, close)]);
|
|
941
|
+
at = close + 1;
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
/**
|
|
945
|
+
* This function finds the end of a declaration (`<!DOCTYPE ...>`) with its internal subset. It
|
|
946
|
+
* gives the position after the `>`, or -1, and it tells if the declaration has an internal subset.
|
|
947
|
+
*/
|
|
948
|
+
const declarationEnd = (text, from, meter) => {
|
|
949
|
+
let depth = 0;
|
|
950
|
+
let quote = 0;
|
|
951
|
+
let subset = false;
|
|
952
|
+
for (let at = from; at < text.length; at += 1) {
|
|
953
|
+
meter.steps += 1;
|
|
954
|
+
const code = text.charCodeAt(at);
|
|
955
|
+
if (quote !== 0) {
|
|
956
|
+
if (code === quote) quote = 0;
|
|
957
|
+
} else if (code === 34 || code === 39) quote = code;
|
|
958
|
+
else if (code === 91) {
|
|
959
|
+
depth += 1;
|
|
960
|
+
subset = true;
|
|
961
|
+
} else if (code === 93) depth = Math.max(0, depth - 1);
|
|
962
|
+
else if (code === 62 && depth === 0) return {
|
|
963
|
+
end: at + 1,
|
|
964
|
+
subset
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
end: -1,
|
|
969
|
+
subset
|
|
970
|
+
};
|
|
971
|
+
};
|
|
972
|
+
/** This function reads a text sitemap: each line that is an absolute http(s) URL is an entry. */
|
|
973
|
+
const readLines = (text, maxEntries, meter) => {
|
|
974
|
+
const entries = [];
|
|
975
|
+
let invalid = 0;
|
|
976
|
+
let at = 0;
|
|
977
|
+
while (at < text.length) {
|
|
978
|
+
let end = text.indexOf("\n", at);
|
|
979
|
+
if (end === -1) end = text.length;
|
|
980
|
+
meter.steps += end - at + 1;
|
|
981
|
+
const line = end - at > SITEMAP_LIMITS.maxValue + 2 ? "-" : text.slice(at, end).trim();
|
|
982
|
+
at = end + 1;
|
|
983
|
+
if (line.length === 0) continue;
|
|
984
|
+
const url = webUrl(line, void 0);
|
|
985
|
+
if (url === void 0) invalid += 1;
|
|
986
|
+
else entries.push({
|
|
987
|
+
url,
|
|
988
|
+
lastmod: null,
|
|
989
|
+
changefreq: null
|
|
990
|
+
});
|
|
991
|
+
if (entries.length >= maxEntries) {
|
|
992
|
+
const complete = at >= text.length;
|
|
993
|
+
return {
|
|
994
|
+
kind: "text",
|
|
995
|
+
entries,
|
|
996
|
+
invalid,
|
|
997
|
+
complete,
|
|
998
|
+
notes: complete ? [] : [`the reader stopped at ${String(maxEntries)} entries`]
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return {
|
|
1003
|
+
kind: "text",
|
|
1004
|
+
entries,
|
|
1005
|
+
invalid,
|
|
1006
|
+
complete: true,
|
|
1007
|
+
notes: []
|
|
1008
|
+
};
|
|
1009
|
+
};
|
|
1010
|
+
/**
|
|
1011
|
+
* This function reads a sitemap, a sitemap index, a feed or a text sitemap, and it counts its
|
|
1012
|
+
* steps in `meter`. The function `readSitemap` uses it. A test uses it to show that the time is linear.
|
|
1013
|
+
*/
|
|
1014
|
+
const scanSitemap = (text, base, limits = {}, meter = { steps: 0 }) => {
|
|
1015
|
+
const maxEntries = Math.max(1, Math.floor(limits.maxEntries ?? SITEMAP_LIMITS.maxEntries));
|
|
1016
|
+
const maxDepth = Math.max(1, Math.floor(limits.maxDepth ?? SITEMAP_LIMITS.maxDepth));
|
|
1017
|
+
let at = text.charCodeAt(0) === 65279 ? 1 : 0;
|
|
1018
|
+
while (at < text.length && isSpace(text.charCodeAt(at))) at += 1;
|
|
1019
|
+
if (at < text.length && text[at] !== "<") return readLines(text.slice(at), maxEntries, meter);
|
|
1020
|
+
const n = text.length;
|
|
1021
|
+
const notes = [];
|
|
1022
|
+
const entries = [];
|
|
1023
|
+
const stack = [];
|
|
1024
|
+
const prefixes = /* @__PURE__ */ new Map();
|
|
1025
|
+
let kind = "unknown";
|
|
1026
|
+
let invalid = 0;
|
|
1027
|
+
let complete = true;
|
|
1028
|
+
let done = false;
|
|
1029
|
+
let feedBase = base;
|
|
1030
|
+
let entry;
|
|
1031
|
+
let field;
|
|
1032
|
+
const stop = (note) => {
|
|
1033
|
+
complete = false;
|
|
1034
|
+
done = true;
|
|
1035
|
+
notes.push(note);
|
|
1036
|
+
};
|
|
1037
|
+
/** This function tells if a name is a local name of one of the namespaces, or the local name with no prefix. */
|
|
1038
|
+
const named = (qname, local, spaces) => {
|
|
1039
|
+
const colon = qname.indexOf(":");
|
|
1040
|
+
if (colon === -1) return qname === local;
|
|
1041
|
+
return qname.slice(colon + 1) === local && spaces.includes(prefixes.get(qname.slice(0, colon)) ?? "");
|
|
1042
|
+
};
|
|
1043
|
+
const attribute = (tag, local, spaces) => {
|
|
1044
|
+
for (const [key, raw] of tag.attributes) if (spaces === void 0 ? key === local : named(key, local, spaces) && key.includes(":")) return decodeText(raw, meter);
|
|
1045
|
+
};
|
|
1046
|
+
const fieldOf = (qname) => {
|
|
1047
|
+
switch (kind) {
|
|
1048
|
+
case "urlset":
|
|
1049
|
+
case "sitemapindex":
|
|
1050
|
+
if (named(qname, "loc", SITEMAP_NS)) return "loc";
|
|
1051
|
+
if (named(qname, "lastmod", SITEMAP_NS)) return "lastmod";
|
|
1052
|
+
return kind === "urlset" && named(qname, "changefreq", SITEMAP_NS) ? "changefreq" : void 0;
|
|
1053
|
+
case "rss":
|
|
1054
|
+
if (qname === "link") return "link";
|
|
1055
|
+
if (qname === "guid") return "guid";
|
|
1056
|
+
return qname === "pubDate" ? "date" : void 0;
|
|
1057
|
+
case "atom": return named(qname, "updated", ATOM_NS) || named(qname, "published", ATOM_NS) ? "date" : void 0;
|
|
1058
|
+
case "rdf":
|
|
1059
|
+
if (named(qname, "link", RSS1_NS)) return "link";
|
|
1060
|
+
return named(qname, "date", DC_NS) ? "date" : void 0;
|
|
1061
|
+
default: return;
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
const isEntry = (qname, depth) => {
|
|
1065
|
+
switch (kind) {
|
|
1066
|
+
case "urlset": return depth === 2 && named(qname, "url", SITEMAP_NS);
|
|
1067
|
+
case "sitemapindex": return depth === 2 && named(qname, "sitemap", SITEMAP_NS);
|
|
1068
|
+
case "rss": return depth === 3 && qname === "item" && stack[1] === "channel";
|
|
1069
|
+
case "atom": return depth === 2 && named(qname, "entry", ATOM_NS);
|
|
1070
|
+
case "rdf": return depth === 2 && named(qname, "item", RSS1_NS);
|
|
1071
|
+
default: return false;
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
const resolveBase = (tag, against) => {
|
|
1075
|
+
const declared = attribute(tag, "xml:base");
|
|
1076
|
+
if (declared === void 0) return against;
|
|
1077
|
+
try {
|
|
1078
|
+
return new URL(declared.trim(), against).href;
|
|
1079
|
+
} catch {
|
|
1080
|
+
return against;
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
const open = (tag, depth) => {
|
|
1084
|
+
for (const [key, raw] of tag.attributes) if (key.startsWith("xmlns:")) {
|
|
1085
|
+
const uri = decodeText(raw, meter);
|
|
1086
|
+
if (uri !== void 0) prefixes.set(key.slice(6), uri.trim());
|
|
1087
|
+
}
|
|
1088
|
+
if (depth === 1) {
|
|
1089
|
+
const local = tag.name.slice(tag.name.indexOf(":") + 1);
|
|
1090
|
+
if (local === "urlset" || local === "sitemapindex" || local === "rss") kind = local;
|
|
1091
|
+
else if (local === "feed") kind = "atom";
|
|
1092
|
+
else if (local === "RDF") kind = "rdf";
|
|
1093
|
+
else {
|
|
1094
|
+
stop(`not a sitemap or a feed: the root element is <${short(tag.name)}>`);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
feedBase = resolveBase(tag, base);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (entry === void 0) {
|
|
1101
|
+
if (isEntry(tag.name, depth)) entry = {
|
|
1102
|
+
depth,
|
|
1103
|
+
base: kind === "atom" ? resolveBase(tag, feedBase) : feedBase,
|
|
1104
|
+
loc: void 0,
|
|
1105
|
+
link: void 0,
|
|
1106
|
+
guid: void 0,
|
|
1107
|
+
lastmod: void 0,
|
|
1108
|
+
changefreq: void 0,
|
|
1109
|
+
about: kind === "rdf" ? attribute(tag, "about", RDF_NS) : void 0,
|
|
1110
|
+
linkPage: false
|
|
1111
|
+
};
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (field !== void 0) {
|
|
1115
|
+
field.bad = true;
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (depth !== entry.depth + 1) return;
|
|
1119
|
+
if (kind === "atom" && named(tag.name, "link", ATOM_NS)) {
|
|
1120
|
+
const rel = (attribute(tag, "rel") ?? "alternate").trim().toLowerCase();
|
|
1121
|
+
const href = attribute(tag, "href");
|
|
1122
|
+
const type = (attribute(tag, "type") ?? "text/html").trim().toLowerCase();
|
|
1123
|
+
const page = type.startsWith("text/html") || type.startsWith("application/xhtml");
|
|
1124
|
+
if (rel === "alternate" && href !== void 0 && (entry.link === void 0 || page && !entry.linkPage)) {
|
|
1125
|
+
entry.link = href;
|
|
1126
|
+
entry.linkPage = page;
|
|
1127
|
+
}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const which = fieldOf(tag.name);
|
|
1131
|
+
if (which === void 0) return;
|
|
1132
|
+
if (which === "guid") {
|
|
1133
|
+
if ((attribute(tag, "isPermaLink") ?? "true").trim().toLowerCase() === "false") return;
|
|
1134
|
+
}
|
|
1135
|
+
field = {
|
|
1136
|
+
field: which,
|
|
1137
|
+
depth,
|
|
1138
|
+
parts: [],
|
|
1139
|
+
length: 0,
|
|
1140
|
+
bad: false
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
const finishEntry = (current) => {
|
|
1144
|
+
const raw = kind === "urlset" || kind === "sitemapindex" ? current.loc : current.link ?? (kind === "rdf" ? current.about : current.guid);
|
|
1145
|
+
const url = raw === void 0 ? void 0 : webUrl(raw, kind === "urlset" || kind === "sitemapindex" ? void 0 : current.base);
|
|
1146
|
+
if (url === void 0) {
|
|
1147
|
+
invalid += 1;
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const freq = current.changefreq?.trim().toLowerCase();
|
|
1151
|
+
entries.push({
|
|
1152
|
+
url,
|
|
1153
|
+
lastmod: current.lastmod === void 0 ? null : feedDate(current.lastmod),
|
|
1154
|
+
changefreq: freq !== void 0 && CHANGEFREQ.has(freq) && kind === "urlset" ? freq : null
|
|
1155
|
+
});
|
|
1156
|
+
if (entries.length >= maxEntries) stop(`the reader stopped at ${String(maxEntries)} entries`);
|
|
1157
|
+
};
|
|
1158
|
+
const close = (depth) => {
|
|
1159
|
+
if (field !== void 0 && depth === field.depth) {
|
|
1160
|
+
const finished = field;
|
|
1161
|
+
field = void 0;
|
|
1162
|
+
if (finished.bad || entry === void 0) return;
|
|
1163
|
+
const value = finished.parts.join("").trim();
|
|
1164
|
+
if (value.length === 0) return;
|
|
1165
|
+
if (finished.field === "loc") entry.loc ??= value;
|
|
1166
|
+
else if (finished.field === "link") entry.link ??= value;
|
|
1167
|
+
else if (finished.field === "guid") entry.guid ??= value;
|
|
1168
|
+
else if (finished.field === "lastmod" || finished.field === "date") entry.lastmod ??= value;
|
|
1169
|
+
else entry.changefreq ??= value;
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (entry !== void 0 && depth === entry.depth) {
|
|
1173
|
+
const finished = entry;
|
|
1174
|
+
entry = void 0;
|
|
1175
|
+
finishEntry(finished);
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
const addText = (value) => {
|
|
1179
|
+
if (field === void 0 || field.bad) return;
|
|
1180
|
+
if (value === void 0 || field.length + value.length > SITEMAP_LIMITS.maxValue) {
|
|
1181
|
+
field.bad = true;
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
field.parts.push(value);
|
|
1185
|
+
field.length += value.length;
|
|
1186
|
+
};
|
|
1187
|
+
while (at < n && !done) {
|
|
1188
|
+
meter.steps += 1;
|
|
1189
|
+
const lt = text.indexOf("<", at);
|
|
1190
|
+
const end = lt === -1 ? n : lt;
|
|
1191
|
+
if (end > at) {
|
|
1192
|
+
meter.steps += end - at;
|
|
1193
|
+
if (field !== void 0 && !field.bad) addText(end - at > SITEMAP_LIMITS.maxValue ? void 0 : decodeText(text.slice(at, end), meter));
|
|
1194
|
+
}
|
|
1195
|
+
if (lt === -1) break;
|
|
1196
|
+
at = lt;
|
|
1197
|
+
if (text.startsWith("<!--", at)) {
|
|
1198
|
+
const endOf = text.indexOf("-->", at + 4);
|
|
1199
|
+
if (endOf === -1) {
|
|
1200
|
+
stop("a comment does not end");
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
meter.steps += endOf + 3 - at;
|
|
1204
|
+
at = endOf + 3;
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (text.startsWith("<![CDATA[", at)) {
|
|
1208
|
+
const endOf = text.indexOf("]]>", at + 9);
|
|
1209
|
+
if (endOf === -1) {
|
|
1210
|
+
stop("a CDATA section does not end");
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
1213
|
+
meter.steps += endOf + 3 - at;
|
|
1214
|
+
if (field !== void 0 && !field.bad) addText(endOf - at - 9 > SITEMAP_LIMITS.maxValue ? void 0 : text.slice(at + 9, endOf));
|
|
1215
|
+
at = endOf + 3;
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
if (text.startsWith("<?", at)) {
|
|
1219
|
+
const endOf = text.indexOf("?>", at + 2);
|
|
1220
|
+
if (endOf === -1) {
|
|
1221
|
+
stop("a processing instruction does not end");
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
meter.steps += endOf + 2 - at;
|
|
1225
|
+
at = endOf + 2;
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
if (text.startsWith("<!", at)) {
|
|
1229
|
+
const declaration = declarationEnd(text, at + 2, meter);
|
|
1230
|
+
if (declaration.end === -1) {
|
|
1231
|
+
stop("a declaration does not end");
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
if (declaration.subset && /^<!DOCTYPE/i.test(text.slice(at, at + 9))) notes.push("the file declares a DTD: the reader ignores it and expands no entity");
|
|
1235
|
+
at = declaration.end;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
if (text.startsWith("</", at)) {
|
|
1239
|
+
const endOf = text.indexOf(">", at + 2);
|
|
1240
|
+
if (endOf === -1) {
|
|
1241
|
+
stop("an end tag does not end");
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
meter.steps += endOf + 1 - at;
|
|
1245
|
+
const name = text.slice(at + 2, Math.min(endOf, at + 300)).trim();
|
|
1246
|
+
const top = stack.pop();
|
|
1247
|
+
if (top !== name) {
|
|
1248
|
+
stop(`the end tag </${short(name)}> does not match <${short(top)}>`);
|
|
1249
|
+
break;
|
|
1250
|
+
}
|
|
1251
|
+
close(stack.length + 1);
|
|
1252
|
+
at = endOf + 1;
|
|
1253
|
+
if (stack.length === 0) break;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const tag = readTag(text, at + 1, meter);
|
|
1257
|
+
if (tag === void 0) {
|
|
1258
|
+
stop("a start tag is not well formed");
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
at = tag.end;
|
|
1262
|
+
const depth = stack.length + 1;
|
|
1263
|
+
if (depth > maxDepth) {
|
|
1264
|
+
stop(`the elements nest deeper than ${String(maxDepth)}`);
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
1267
|
+
open(tag, depth);
|
|
1268
|
+
if (done) break;
|
|
1269
|
+
if (tag.selfClosing) close(depth);
|
|
1270
|
+
else stack.push(tag.name);
|
|
1271
|
+
if (depth === 1 && tag.selfClosing) break;
|
|
1272
|
+
}
|
|
1273
|
+
if (!done && stack.length > 0) stop("the file ends inside an element");
|
|
1274
|
+
if (kind === "unknown" && notes.length === 0) {
|
|
1275
|
+
complete = false;
|
|
1276
|
+
notes.push("not a sitemap or a feed: the file has no root element");
|
|
1277
|
+
}
|
|
1278
|
+
return {
|
|
1279
|
+
kind,
|
|
1280
|
+
entries,
|
|
1281
|
+
invalid,
|
|
1282
|
+
complete,
|
|
1283
|
+
notes
|
|
1284
|
+
};
|
|
1285
|
+
};
|
|
1286
|
+
/**
|
|
1287
|
+
* This function reads a sitemap, a sitemap index, an RSS or Atom feed, or a text sitemap. The
|
|
1288
|
+
* root element of the file tells its kind. The entries of a feed can have URLs relative to `base`
|
|
1289
|
+
* (the URL of the file). The entries of a sitemap must have absolute URLs.
|
|
1290
|
+
*/
|
|
1291
|
+
const readSitemap = (text, base, limits) => scanSitemap(text, base, limits);
|
|
1292
|
+
//#endregion
|
|
1293
|
+
//#region src/discover.ts
|
|
1294
|
+
/**
|
|
1295
|
+
* Discovery finds the pages of a site from its sitemaps and its feeds. Each file is a fetch of the
|
|
1296
|
+
* crawler, so each polite rule applies to it. These rules are robots.txt, one request at a time for
|
|
1297
|
+
* each host, the pace, the stops, the egress policy and the capture log. These are the sources, in sequence:
|
|
1298
|
+
*
|
|
1299
|
+
* 1. The page at the URL of the site, for the RSS and Atom feeds that it links.
|
|
1300
|
+
* 2. The feeds that the caller gives.
|
|
1301
|
+
* 3. The sitemaps that the caller gives, and the `Sitemap:` lines of robots.txt. When there are
|
|
1302
|
+
* none, `/sitemap.xml`.
|
|
1303
|
+
* 4. The sitemaps of each sitemap index, the newest first, to a maximum depth.
|
|
1304
|
+
*
|
|
1305
|
+
* Discovery keeps a URL only when it is in scope (the same site by default) and robots.txt permits
|
|
1306
|
+
* it. It stops at a maximum of files, a maximum of URLs and a maximum time.
|
|
1307
|
+
*/
|
|
1308
|
+
/** These are the default limits of `discover`. */
|
|
1309
|
+
const DISCOVER_LIMITS = {
|
|
1310
|
+
/** The most sitemap and feed files that one discovery reads. */
|
|
1311
|
+
maxFiles: 20,
|
|
1312
|
+
/** The most URLs that one discovery gives. */
|
|
1313
|
+
maxUrls: 1e4,
|
|
1314
|
+
/** The most bytes of one file, after decompression: 10 MiB. */
|
|
1315
|
+
maxBytes: 10485760,
|
|
1316
|
+
/** The most levels of sitemap index under a file that discovery found first. */
|
|
1317
|
+
maxNesting: 3,
|
|
1318
|
+
/** The longest time of one discovery: 1 minute. */
|
|
1319
|
+
budgetMs: 6e4,
|
|
1320
|
+
/** The most origins whose robots.txt one discovery reads for the URLs that it finds. */
|
|
1321
|
+
maxOrigins: 10,
|
|
1322
|
+
/** The most feeds that discovery reads from the links of the page. */
|
|
1323
|
+
maxLinkedFeeds: 5
|
|
1324
|
+
};
|
|
1325
|
+
const PAGE_ACCEPT = "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5";
|
|
1326
|
+
const SITEMAP_ACCEPT = "application/xml,text/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5";
|
|
1327
|
+
const FEED_ACCEPT = "application/rss+xml,application/atom+xml,application/xml;q=0.9,text/xml;q=0.8,*/*;q=0.5";
|
|
1328
|
+
const FEED_KINDS = /* @__PURE__ */ new Set([
|
|
1329
|
+
"rss",
|
|
1330
|
+
"atom",
|
|
1331
|
+
"rdf"
|
|
1332
|
+
]);
|
|
1333
|
+
const whole$1 = (name, value, fallback, min) => {
|
|
1334
|
+
if (value === void 0) return fallback;
|
|
1335
|
+
if (!Number.isInteger(value) || value < min) throw new RangeError(`${name} must be an integer of ${String(min)} or more, not ${String(value)}`);
|
|
1336
|
+
return value;
|
|
1337
|
+
};
|
|
1338
|
+
/**
|
|
1339
|
+
* This function reads a body that is gzip data (a `.xml.gz` sitemap). It keeps at most `maxBytes`
|
|
1340
|
+
* of the decompressed data, so a decompression bomb stops at the limit.
|
|
1341
|
+
*/
|
|
1342
|
+
const gunzipBounded = async (body, maxBytes) => {
|
|
1343
|
+
const gunzip = createGunzip();
|
|
1344
|
+
const chunks = [];
|
|
1345
|
+
let size = 0;
|
|
1346
|
+
let truncated = false;
|
|
1347
|
+
let error;
|
|
1348
|
+
gunzip.end(body);
|
|
1349
|
+
try {
|
|
1350
|
+
for await (const chunk of gunzip) {
|
|
1351
|
+
const room = maxBytes - size;
|
|
1352
|
+
if (chunk.length > room) {
|
|
1353
|
+
chunks.push(chunk.subarray(0, room));
|
|
1354
|
+
size += room;
|
|
1355
|
+
truncated = true;
|
|
1356
|
+
break;
|
|
1357
|
+
}
|
|
1358
|
+
chunks.push(chunk);
|
|
1359
|
+
size += chunk.length;
|
|
1360
|
+
}
|
|
1361
|
+
} catch (caught) {
|
|
1362
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
1363
|
+
truncated = true;
|
|
1364
|
+
} finally {
|
|
1365
|
+
gunzip.destroy();
|
|
1366
|
+
}
|
|
1367
|
+
return {
|
|
1368
|
+
bytes: Buffer.concat(chunks, size),
|
|
1369
|
+
truncated,
|
|
1370
|
+
...error === void 0 ? {} : { error }
|
|
1371
|
+
};
|
|
1372
|
+
};
|
|
1373
|
+
const isGzip = (body) => body[0] === 31 && body[1] === 139;
|
|
1374
|
+
const parsedUrl = (value, base) => {
|
|
1375
|
+
try {
|
|
1376
|
+
const url = base === void 0 ? new URL(value.trim()) : new URL(value.trim(), base);
|
|
1377
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url : void 0;
|
|
1378
|
+
} catch {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
/** This function gives the value of a promise, or rejects when the signal aborts first. */
|
|
1383
|
+
const untilAbort = (work, signal) => {
|
|
1384
|
+
if (signal === void 0) return work;
|
|
1385
|
+
return new Promise((resolve, reject) => {
|
|
1386
|
+
const cancel = () => reject(signal.reason);
|
|
1387
|
+
if (signal.aborted) cancel();
|
|
1388
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
1389
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", cancel));
|
|
1390
|
+
});
|
|
1391
|
+
};
|
|
1392
|
+
/** This function tells if the note of a fetch says that its deadline came first. */
|
|
1393
|
+
const pastDeadline = (note) => note?.startsWith(DEADLINE_NOTE) === true;
|
|
1394
|
+
/**
|
|
1395
|
+
* This function finds the pages of a site from its sitemaps and its feeds. `PoliteCrawler.discover`
|
|
1396
|
+
* uses it. Each file is a fetch of the crawler with the recipe `sitemap` or `feed`, so the
|
|
1397
|
+
* capture log records it, and robots.txt applies to it.
|
|
1398
|
+
*/
|
|
1399
|
+
const discoverSite = async (runtime, site, options = {}) => {
|
|
1400
|
+
const start = parsedUrl(site);
|
|
1401
|
+
if (start === void 0) throw new Error(`discover wants an http(s) URL, not "${site}"`);
|
|
1402
|
+
const maxFiles = whole$1("maxFiles", options.maxFiles, DISCOVER_LIMITS.maxFiles, 1);
|
|
1403
|
+
const maxUrls = whole$1("maxUrls", options.maxUrls, DISCOVER_LIMITS.maxUrls, 1);
|
|
1404
|
+
const maxBytes = whole$1("maxBytes", options.maxBytes, DISCOVER_LIMITS.maxBytes, 1);
|
|
1405
|
+
const maxNesting = whole$1("maxNesting", options.maxNesting, DISCOVER_LIMITS.maxNesting, 0);
|
|
1406
|
+
const maxAge = options.maxAge ?? "24h";
|
|
1407
|
+
const maxAgeMs = maxAge === "auto" ? "auto" : durationMs(maxAge);
|
|
1408
|
+
const budgetMs = options.budgetMs ?? DISCOVER_LIMITS.budgetMs;
|
|
1409
|
+
if (!(budgetMs >= 0)) throw new RangeError(`budgetMs must be 0 or more, not ${String(budgetMs)}`);
|
|
1410
|
+
const now = () => Date.parse(runtime.now());
|
|
1411
|
+
const deadline = Math.min(now() + budgetMs, options.deadline ?? Number.POSITIVE_INFINITY);
|
|
1412
|
+
const crawler = runtime.crawler;
|
|
1413
|
+
const signal = options.signal;
|
|
1414
|
+
const cancelled = () => signal?.aborted === true;
|
|
1415
|
+
const marks = {
|
|
1416
|
+
scope: options.scope,
|
|
1417
|
+
labels: options.labels
|
|
1418
|
+
};
|
|
1419
|
+
const inScope = scopeTest([start], options);
|
|
1420
|
+
const urls = /* @__PURE__ */ new Map();
|
|
1421
|
+
const sources = [];
|
|
1422
|
+
const dropped = {
|
|
1423
|
+
scope: 0,
|
|
1424
|
+
robots: 0,
|
|
1425
|
+
duplicate: 0,
|
|
1426
|
+
invalid: 0,
|
|
1427
|
+
limit: 0
|
|
1428
|
+
};
|
|
1429
|
+
const origins = /* @__PURE__ */ new Set();
|
|
1430
|
+
const queued = /* @__PURE__ */ new Set();
|
|
1431
|
+
const pending = [];
|
|
1432
|
+
let stopped;
|
|
1433
|
+
let note = null;
|
|
1434
|
+
let files = 0;
|
|
1435
|
+
let next = 0;
|
|
1436
|
+
const stopNote = () => {
|
|
1437
|
+
switch (stopped) {
|
|
1438
|
+
case "max-files": return `discovery read ${String(maxFiles)} files, its limit, and ${String(pending.length - next)} more files wait`;
|
|
1439
|
+
case "max-urls": return `discovery found ${String(maxUrls)} URLs, its limit`;
|
|
1440
|
+
case "budget": return `the budget of ${String(budgetMs)} ms ended`;
|
|
1441
|
+
case "cancelled": return "the caller cancelled the discovery";
|
|
1442
|
+
case "closed": return "the crawler closed";
|
|
1443
|
+
default: return null;
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
const result = () => ({
|
|
1447
|
+
site: start.origin,
|
|
1448
|
+
urls: [...urls.values()].map((url, order) => ({
|
|
1449
|
+
url,
|
|
1450
|
+
order
|
|
1451
|
+
})).sort((a, b) => (b.url.lastmod === null ? -Infinity : Date.parse(b.url.lastmod)) - (a.url.lastmod === null ? -Infinity : Date.parse(a.url.lastmod)) || a.order - b.order).map(({ url }) => url),
|
|
1452
|
+
sources,
|
|
1453
|
+
dropped,
|
|
1454
|
+
stopped: stopped ?? "done",
|
|
1455
|
+
note: note ?? stopNote()
|
|
1456
|
+
});
|
|
1457
|
+
/** This function sets the reason of the stop from the note of a fetch, when the note gives one. */
|
|
1458
|
+
const stoppedBy = (fetched) => {
|
|
1459
|
+
if (cancelled()) stopped = "cancelled";
|
|
1460
|
+
else if (pastDeadline(fetched.note)) stopped = "budget";
|
|
1461
|
+
else if (fetched.note === "crawler closed") stopped = "closed";
|
|
1462
|
+
else return false;
|
|
1463
|
+
return true;
|
|
1464
|
+
};
|
|
1465
|
+
const enqueue = (url, via, from, nesting = 0) => {
|
|
1466
|
+
const parsed = parsedUrl(url, start.href);
|
|
1467
|
+
if (parsed === void 0) {
|
|
1468
|
+
dropped.invalid += 1;
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const key = cacheKeyOf(parsed.href);
|
|
1472
|
+
if (queued.has(key)) return;
|
|
1473
|
+
queued.add(key);
|
|
1474
|
+
pending.push({
|
|
1475
|
+
url: parsed.href,
|
|
1476
|
+
via,
|
|
1477
|
+
from,
|
|
1478
|
+
nesting
|
|
1479
|
+
});
|
|
1480
|
+
};
|
|
1481
|
+
if (cancelled()) {
|
|
1482
|
+
stopped = "cancelled";
|
|
1483
|
+
return result();
|
|
1484
|
+
}
|
|
1485
|
+
let sitemapLines = [];
|
|
1486
|
+
try {
|
|
1487
|
+
const robots = await untilAbort(crawler.readRobots(start.href, marks), signal);
|
|
1488
|
+
if (robots.robots.unreachable === "crawler closed") {
|
|
1489
|
+
stopped = "closed";
|
|
1490
|
+
return result();
|
|
1491
|
+
}
|
|
1492
|
+
if (robots.robots.unreachable !== void 0) {
|
|
1493
|
+
stopped = "robots";
|
|
1494
|
+
note = `robots.txt could not be read (${robots.robots.unreachable}), so nothing on the site is fetched`;
|
|
1495
|
+
return result();
|
|
1496
|
+
}
|
|
1497
|
+
sitemapLines = robots.robots.sitemaps;
|
|
1498
|
+
} catch {
|
|
1499
|
+
stopped = cancelled() ? "cancelled" : "closed";
|
|
1500
|
+
return result();
|
|
1501
|
+
}
|
|
1502
|
+
const fetchFile = (url, recipe) => crawler.fetch({
|
|
1503
|
+
url,
|
|
1504
|
+
mode: "page",
|
|
1505
|
+
recipe,
|
|
1506
|
+
accept: recipe === "feed" ? FEED_ACCEPT : SITEMAP_ACCEPT,
|
|
1507
|
+
maxBytes,
|
|
1508
|
+
maxAgeMs,
|
|
1509
|
+
onBehalfOf: options.onBehalfOf,
|
|
1510
|
+
signal,
|
|
1511
|
+
scope: options.scope,
|
|
1512
|
+
labels: options.labels,
|
|
1513
|
+
deadline
|
|
1514
|
+
});
|
|
1515
|
+
if (options.linkedFeeds !== false) {
|
|
1516
|
+
const page = await fetchAndIndex(runtime, {
|
|
1517
|
+
url: start.href,
|
|
1518
|
+
mode: "page",
|
|
1519
|
+
recipe: "page",
|
|
1520
|
+
accept: PAGE_ACCEPT,
|
|
1521
|
+
maxAgeMs,
|
|
1522
|
+
onBehalfOf: options.onBehalfOf,
|
|
1523
|
+
signal,
|
|
1524
|
+
scope: options.scope,
|
|
1525
|
+
labels: options.labels,
|
|
1526
|
+
deadline
|
|
1527
|
+
});
|
|
1528
|
+
if (stoppedBy(page.fetched)) return result();
|
|
1529
|
+
const doc = page.docs[0];
|
|
1530
|
+
const body = page.fetched.body;
|
|
1531
|
+
const html = /html/i.test(page.fetched.contentType ?? "text/html");
|
|
1532
|
+
if (body !== void 0 && html && doc !== void 0 && !isHeldBack(doc)) {
|
|
1533
|
+
let linked = [];
|
|
1534
|
+
try {
|
|
1535
|
+
linked = extractPage(decodeBody(body, page.fetched.contentType ?? null), page.fetched.url).feeds ?? [];
|
|
1536
|
+
} catch {}
|
|
1537
|
+
let taken = 0;
|
|
1538
|
+
for (const feed of linked) {
|
|
1539
|
+
const parsed = parsedUrl(feed);
|
|
1540
|
+
if (parsed === void 0 || !inScope(parsed)) {
|
|
1541
|
+
dropped.scope += 1;
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
if (taken >= DISCOVER_LIMITS.maxLinkedFeeds) {
|
|
1545
|
+
dropped.limit += 1;
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
taken += 1;
|
|
1549
|
+
enqueue(parsed.href, "feed", "page");
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
for (const feed of options.feeds ?? []) enqueue(feed, "feed", "given");
|
|
1554
|
+
for (const sitemap of options.sitemaps ?? []) enqueue(sitemap, "sitemap", "given");
|
|
1555
|
+
for (const sitemap of sitemapLines) enqueue(sitemap, "sitemap", "robots.txt");
|
|
1556
|
+
if ((options.sitemaps ?? []).length === 0 && sitemapLines.length === 0) enqueue(new URL("/sitemap.xml", start.origin).href, "sitemap", "default");
|
|
1557
|
+
/** This function keeps the page URLs of one file that are new, in scope and permitted. */
|
|
1558
|
+
const keep = async (read, source, via) => {
|
|
1559
|
+
let kept = 0;
|
|
1560
|
+
dropped.invalid += read.invalid;
|
|
1561
|
+
for (const entry of read.entries) {
|
|
1562
|
+
if (urls.size >= maxUrls) {
|
|
1563
|
+
stopped = "max-urls";
|
|
1564
|
+
return kept;
|
|
1565
|
+
}
|
|
1566
|
+
const known = urls.get(entry.url);
|
|
1567
|
+
if (known !== void 0) {
|
|
1568
|
+
dropped.duplicate += 1;
|
|
1569
|
+
if (entry.lastmod !== null && (known.lastmod === null || Date.parse(entry.lastmod) > Date.parse(known.lastmod))) urls.set(entry.url, {
|
|
1570
|
+
...known,
|
|
1571
|
+
lastmod: entry.lastmod
|
|
1572
|
+
});
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
const url = new URL(entry.url);
|
|
1576
|
+
if (!inScope(url)) {
|
|
1577
|
+
dropped.scope += 1;
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
if (!origins.has(url.origin)) {
|
|
1581
|
+
if (origins.size >= DISCOVER_LIMITS.maxOrigins) {
|
|
1582
|
+
dropped.limit += 1;
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
origins.add(url.origin);
|
|
1586
|
+
}
|
|
1587
|
+
let allowed;
|
|
1588
|
+
try {
|
|
1589
|
+
allowed = (await crawler.robotsAllow(entry.url, {
|
|
1590
|
+
onBehalfOf: options.onBehalfOf,
|
|
1591
|
+
signal,
|
|
1592
|
+
...marks
|
|
1593
|
+
})).allowed;
|
|
1594
|
+
} catch {
|
|
1595
|
+
stopped = "cancelled";
|
|
1596
|
+
return kept;
|
|
1597
|
+
}
|
|
1598
|
+
if (!allowed) {
|
|
1599
|
+
dropped.robots += 1;
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
urls.set(entry.url, {
|
|
1603
|
+
url: entry.url,
|
|
1604
|
+
lastmod: entry.lastmod,
|
|
1605
|
+
changefreq: entry.changefreq,
|
|
1606
|
+
via,
|
|
1607
|
+
source
|
|
1608
|
+
});
|
|
1609
|
+
kept += 1;
|
|
1610
|
+
}
|
|
1611
|
+
return kept;
|
|
1612
|
+
};
|
|
1613
|
+
for (; next < pending.length && stopped === void 0; next += 1) {
|
|
1614
|
+
if (cancelled()) {
|
|
1615
|
+
stopped = "cancelled";
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
if (now() >= deadline) {
|
|
1619
|
+
stopped = "budget";
|
|
1620
|
+
break;
|
|
1621
|
+
}
|
|
1622
|
+
if (files >= maxFiles) {
|
|
1623
|
+
stopped = "max-files";
|
|
1624
|
+
break;
|
|
1625
|
+
}
|
|
1626
|
+
const file = pending[next];
|
|
1627
|
+
files += 1;
|
|
1628
|
+
const fetched = await fetchFile(file.url, file.via);
|
|
1629
|
+
const base = {
|
|
1630
|
+
url: fetched.url,
|
|
1631
|
+
via: file.via,
|
|
1632
|
+
from: file.from,
|
|
1633
|
+
outcome: fetched.outcome,
|
|
1634
|
+
status: fetched.status,
|
|
1635
|
+
capture: fetched.capture ?? null,
|
|
1636
|
+
fetched_at: fetched.fetchedAt ?? null,
|
|
1637
|
+
from_cache: fetched.fromCache
|
|
1638
|
+
};
|
|
1639
|
+
if (stoppedBy(fetched)) {
|
|
1640
|
+
sources.push({
|
|
1641
|
+
...base,
|
|
1642
|
+
kind: null,
|
|
1643
|
+
entries: 0,
|
|
1644
|
+
urls: 0,
|
|
1645
|
+
truncated: false,
|
|
1646
|
+
note: fetched.note ?? null
|
|
1647
|
+
});
|
|
1648
|
+
break;
|
|
1649
|
+
}
|
|
1650
|
+
const body = fetched.body;
|
|
1651
|
+
if (fetched.outcome !== "ok" && fetched.outcome !== "not-modified" || body === void 0) {
|
|
1652
|
+
sources.push({
|
|
1653
|
+
...base,
|
|
1654
|
+
kind: null,
|
|
1655
|
+
entries: 0,
|
|
1656
|
+
urls: 0,
|
|
1657
|
+
truncated: false,
|
|
1658
|
+
note: fetched.note ?? `${fetched.outcome} ${String(fetched.status)}`
|
|
1659
|
+
});
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
let bytes = body;
|
|
1663
|
+
let truncated = fetched.truncated === true;
|
|
1664
|
+
const notes = [];
|
|
1665
|
+
if (isGzip(body)) {
|
|
1666
|
+
const unzipped = await gunzipBounded(body, maxBytes);
|
|
1667
|
+
bytes = unzipped.bytes;
|
|
1668
|
+
truncated ||= unzipped.truncated;
|
|
1669
|
+
if (unzipped.error !== void 0) notes.push(`the gzip data ended early (${unzipped.error})`);
|
|
1670
|
+
}
|
|
1671
|
+
const read = readSitemap(decodeBody(bytes, isGzip(body) ? null : fetched.contentType ?? null), fetched.url);
|
|
1672
|
+
if (truncated) notes.push(`the file is longer than ${String(maxBytes)} bytes, so discovery read its first part`);
|
|
1673
|
+
notes.push(...read.notes);
|
|
1674
|
+
let kept = 0;
|
|
1675
|
+
if (read.kind === "sitemapindex") {
|
|
1676
|
+
const children = [...read.entries].sort((a, b) => (b.lastmod === null ? -Infinity : Date.parse(b.lastmod)) - (a.lastmod === null ? -Infinity : Date.parse(a.lastmod)));
|
|
1677
|
+
for (const child of children) if (!inScope(new URL(child.url))) dropped.scope += 1;
|
|
1678
|
+
else if (file.nesting + 1 > maxNesting) dropped.limit += 1;
|
|
1679
|
+
else enqueue(child.url, "sitemap", "index", file.nesting + 1);
|
|
1680
|
+
if (file.nesting + 1 > maxNesting && children.length > 0) notes.push(`the files of the index are deeper than maxNesting (${String(maxNesting)}), so discovery reads none of them`);
|
|
1681
|
+
} else if (read.kind !== "unknown") kept = await keep(read, fetched.url, FEED_KINDS.has(read.kind) ? "feed" : "sitemap");
|
|
1682
|
+
sources.push({
|
|
1683
|
+
...base,
|
|
1684
|
+
kind: read.kind,
|
|
1685
|
+
entries: read.entries.length,
|
|
1686
|
+
urls: kept,
|
|
1687
|
+
truncated,
|
|
1688
|
+
note: notes.length > 0 ? notes.join("; ") : null
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
if (stopped === void 0 && urls.size >= maxUrls) stopped = "max-urls";
|
|
1692
|
+
return result();
|
|
1693
|
+
};
|
|
1694
|
+
//#endregion
|
|
1695
|
+
//#region src/frontier.ts
|
|
1696
|
+
/**
|
|
1697
|
+
* The frontier is a small crawl of a site. It starts at the seeds. It follows the links of each page
|
|
1698
|
+
* that it fetched, to a maximum of pages, a maximum depth and a maximum time.
|
|
1699
|
+
*
|
|
1700
|
+
* Each page is a fetch of the crawler, so each polite rule applies. These rules are robots.txt, one
|
|
1701
|
+
* request at a time for each host, the pace, the stops, the egress policy and the capture log. The
|
|
1702
|
+
* frontier does not start a second request to a host with a request in progress. The crawler
|
|
1703
|
+
* limits the hosts in progress (`maxActive`).
|
|
1704
|
+
*
|
|
1705
|
+
* The state of the frontier is in memory, for one call. A later version can make it resumable.
|
|
1706
|
+
*/
|
|
1707
|
+
/** These are the default limits of `crawl`, and the limits of the memory of the frontier. */
|
|
1708
|
+
const CRAWL_LIMITS = {
|
|
1709
|
+
/** The most pages that one crawl gives. */
|
|
1710
|
+
maxPages: 100,
|
|
1711
|
+
/** The most links from a seed to a page. A seed has the depth 0. */
|
|
1712
|
+
maxDepth: 3,
|
|
1713
|
+
/** The longest time of one crawl: 10 minutes. */
|
|
1714
|
+
budgetMs: 6e5,
|
|
1715
|
+
/** The most hosts that get a request of the crawl at one time. */
|
|
1716
|
+
hostsAtOnce: 16,
|
|
1717
|
+
/** The most URLs that wait in the frontier. */
|
|
1718
|
+
maxQueue: 1e4,
|
|
1719
|
+
/** The most URLs that the frontier remembers, so it fetches none of them two times. */
|
|
1720
|
+
maxSeen: 1e5
|
|
1721
|
+
};
|
|
1722
|
+
/** The lower depth first, then the lower tier, then the newer `lastmod`, then the first found. */
|
|
1723
|
+
const before = (a, b) => a.depth - b.depth || a.tier - b.tier || b.lastmod - a.lastmod || a.seq - b.seq;
|
|
1724
|
+
/** A binary heap of the URLs of one host, the first URL to fetch at the top. */
|
|
1725
|
+
var Heap = class {
|
|
1726
|
+
items = [];
|
|
1727
|
+
get size() {
|
|
1728
|
+
return this.items.length;
|
|
1729
|
+
}
|
|
1730
|
+
peek() {
|
|
1731
|
+
return this.items[0];
|
|
1732
|
+
}
|
|
1733
|
+
push(item) {
|
|
1734
|
+
const items = this.items;
|
|
1735
|
+
items.push(item);
|
|
1736
|
+
let at = items.length - 1;
|
|
1737
|
+
while (at > 0) {
|
|
1738
|
+
const parent = at - 1 >> 1;
|
|
1739
|
+
if (before(items[at], items[parent]) >= 0) break;
|
|
1740
|
+
[items[at], items[parent]] = [items[parent], items[at]];
|
|
1741
|
+
at = parent;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
pop() {
|
|
1745
|
+
const items = this.items;
|
|
1746
|
+
const top = items[0];
|
|
1747
|
+
const last = items.pop();
|
|
1748
|
+
if (top === void 0 || last === void 0 || items.length === 0) return top;
|
|
1749
|
+
items[0] = last;
|
|
1750
|
+
let at = 0;
|
|
1751
|
+
for (;;) {
|
|
1752
|
+
const left = at * 2 + 1;
|
|
1753
|
+
const right = left + 1;
|
|
1754
|
+
let first = at;
|
|
1755
|
+
if (left < items.length && before(items[left], items[first]) < 0) first = left;
|
|
1756
|
+
if (right < items.length && before(items[right], items[first]) < 0) first = right;
|
|
1757
|
+
if (first === at) return top;
|
|
1758
|
+
[items[at], items[first]] = [items[first], items[at]];
|
|
1759
|
+
at = first;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
};
|
|
1763
|
+
const hostOf = (url) => `${url.hostname.toLowerCase().replace(/\.$/, "")}${url.port === "" ? "" : `:${url.port}`}`;
|
|
1764
|
+
const matcher = (pattern) => {
|
|
1765
|
+
if (pattern instanceof RegExp) return (url) => {
|
|
1766
|
+
pattern.lastIndex = 0;
|
|
1767
|
+
return pattern.test(url.href);
|
|
1768
|
+
};
|
|
1769
|
+
if (typeof pattern !== "string" || pattern.length === 0) throw new RangeError("a URL pattern is a regular expression or a text that is not empty");
|
|
1770
|
+
return pattern.startsWith("/") ? (url) => matchesPattern(pattern, `${url.pathname}${url.search}`) : (url) => matchesPattern(pattern, url.href);
|
|
1771
|
+
};
|
|
1772
|
+
const whole = (name, value, fallback, min) => {
|
|
1773
|
+
if (value === void 0) return fallback;
|
|
1774
|
+
if (!Number.isInteger(value) || value < min) throw new RangeError(`${name} must be an integer of ${String(min)} or more, not ${String(value)}`);
|
|
1775
|
+
return value;
|
|
1776
|
+
};
|
|
1777
|
+
const failedPage = (url, note) => ({
|
|
1778
|
+
url,
|
|
1779
|
+
doc: null,
|
|
1780
|
+
outcome: "error",
|
|
1781
|
+
status: 0,
|
|
1782
|
+
title: null,
|
|
1783
|
+
chars: 0,
|
|
1784
|
+
rendered: false,
|
|
1785
|
+
structured: [],
|
|
1786
|
+
links: 0,
|
|
1787
|
+
captures: [],
|
|
1788
|
+
capture: null,
|
|
1789
|
+
capture_count: 0,
|
|
1790
|
+
fetched_at: null,
|
|
1791
|
+
from_cache: false,
|
|
1792
|
+
held_back: false,
|
|
1793
|
+
note
|
|
1794
|
+
});
|
|
1795
|
+
const REFUSALS$1 = /* @__PURE__ */ new Set([
|
|
1796
|
+
"skipped",
|
|
1797
|
+
"blocked",
|
|
1798
|
+
"challenge",
|
|
1799
|
+
"payment-required"
|
|
1800
|
+
]);
|
|
1801
|
+
/**
|
|
1802
|
+
* This function crawls a site from its seeds. `PoliteCrawler.crawl` uses it. It gives each page
|
|
1803
|
+
* when its fetch ends, and a summary at the end. The seeds come first, then the pages of the
|
|
1804
|
+
* sitemaps (the newest first), then the links, the lower depth first.
|
|
1805
|
+
*/
|
|
1806
|
+
const crawlSite = (runtime, seeds, options = {}) => {
|
|
1807
|
+
let resolveSummary = () => void 0;
|
|
1808
|
+
const summary = new Promise((resolve) => {
|
|
1809
|
+
resolveSummary = resolve;
|
|
1810
|
+
});
|
|
1811
|
+
let used = false;
|
|
1812
|
+
async function* pages() {
|
|
1813
|
+
const now = () => Date.parse(runtime.now());
|
|
1814
|
+
const startedAt = now();
|
|
1815
|
+
const abort = new AbortController();
|
|
1816
|
+
const outer = options.signal;
|
|
1817
|
+
const cancel = () => abort.abort(outer?.reason);
|
|
1818
|
+
if (outer?.aborted === true) cancel();
|
|
1819
|
+
outer?.addEventListener("abort", cancel, { once: true });
|
|
1820
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
1821
|
+
const heaps = /* @__PURE__ */ new Map();
|
|
1822
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1823
|
+
const counts = {
|
|
1824
|
+
pages: 0,
|
|
1825
|
+
fetched: 0,
|
|
1826
|
+
from_cache: 0,
|
|
1827
|
+
refused: 0,
|
|
1828
|
+
failed: 0,
|
|
1829
|
+
sitemap_urls: 0
|
|
1830
|
+
};
|
|
1831
|
+
const skips = {
|
|
1832
|
+
scope: 0,
|
|
1833
|
+
pattern: 0,
|
|
1834
|
+
depth: 0,
|
|
1835
|
+
held_back: 0,
|
|
1836
|
+
limit: 0,
|
|
1837
|
+
duplicate: 0
|
|
1838
|
+
};
|
|
1839
|
+
const got = /* @__PURE__ */ new Set();
|
|
1840
|
+
let queued = 0;
|
|
1841
|
+
let returned = 0;
|
|
1842
|
+
let seq = 0;
|
|
1843
|
+
let started = 0;
|
|
1844
|
+
let stopping;
|
|
1845
|
+
let reason;
|
|
1846
|
+
let finished = false;
|
|
1847
|
+
let maxPages = CRAWL_LIMITS.maxPages;
|
|
1848
|
+
let budgetMs = CRAWL_LIMITS.budgetMs;
|
|
1849
|
+
const crawler = runtime.crawler;
|
|
1850
|
+
const marks = {
|
|
1851
|
+
scope: options.scope,
|
|
1852
|
+
labels: options.labels
|
|
1853
|
+
};
|
|
1854
|
+
try {
|
|
1855
|
+
await options.ready?.();
|
|
1856
|
+
maxPages = whole("maxPages", options.maxPages, CRAWL_LIMITS.maxPages, 1);
|
|
1857
|
+
const maxDepth = whole("maxDepth", options.maxDepth, CRAWL_LIMITS.maxDepth, 0);
|
|
1858
|
+
budgetMs = options.budgetMs ?? CRAWL_LIMITS.budgetMs;
|
|
1859
|
+
if (!(budgetMs >= 0)) throw new RangeError(`budgetMs must be 0 or more, not ${String(budgetMs)}`);
|
|
1860
|
+
const maxAge = options.maxAge ?? "24h";
|
|
1861
|
+
const maxAgeMs = maxAge === "auto" ? "auto" : durationMs(maxAge);
|
|
1862
|
+
const includes = (options.include ?? []).map(matcher);
|
|
1863
|
+
const excludes = (options.exclude ?? []).map(matcher);
|
|
1864
|
+
const deadline = startedAt + budgetMs;
|
|
1865
|
+
const list = typeof seeds === "string" ? [seeds] : [...seeds];
|
|
1866
|
+
if (list.length === 0) throw new RangeError("a crawl needs one seed URL or more");
|
|
1867
|
+
const starts = list.flatMap((seed) => {
|
|
1868
|
+
try {
|
|
1869
|
+
const url = new URL(seed);
|
|
1870
|
+
return url.protocol === "http:" || url.protocol === "https:" ? [url] : [];
|
|
1871
|
+
} catch {
|
|
1872
|
+
return [];
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
const inScope = scopeTest(starts, options);
|
|
1876
|
+
const followed = (url) => (includes.length === 0 || includes.some((test) => test(url))) && !excludes.some((test) => test(url));
|
|
1877
|
+
/** This function adds a URL to the frontier, when it is new, in scope and not too deep. */
|
|
1878
|
+
const offer = (raw, depth, tier, lastmod, source, referrer) => {
|
|
1879
|
+
let url;
|
|
1880
|
+
try {
|
|
1881
|
+
url = new URL(raw);
|
|
1882
|
+
} catch {
|
|
1883
|
+
url = void 0;
|
|
1884
|
+
}
|
|
1885
|
+
const web = url !== void 0 && (url.protocol === "http:" || url.protocol === "https:");
|
|
1886
|
+
if (!web && source !== "seed") return;
|
|
1887
|
+
const key = cacheKeyOf(url?.href ?? raw);
|
|
1888
|
+
if (seen.has(key)) return;
|
|
1889
|
+
if (seen.size >= CRAWL_LIMITS.maxSeen) {
|
|
1890
|
+
skips.limit += 1;
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
seen.add(key);
|
|
1894
|
+
if (source !== "seed" && url !== void 0) {
|
|
1895
|
+
if (!inScope(url)) {
|
|
1896
|
+
skips.scope += 1;
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
if (!followed(url)) {
|
|
1900
|
+
skips.pattern += 1;
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (depth > maxDepth) {
|
|
1904
|
+
skips.depth += 1;
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
if (queued >= CRAWL_LIMITS.maxQueue) {
|
|
1909
|
+
skips.limit += 1;
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
const host = url === void 0 || !web ? `not-web:${raw}` : hostOf(url);
|
|
1913
|
+
let heap = heaps.get(host);
|
|
1914
|
+
if (heap === void 0) {
|
|
1915
|
+
heap = new Heap();
|
|
1916
|
+
heaps.set(host, heap);
|
|
1917
|
+
}
|
|
1918
|
+
seq += 1;
|
|
1919
|
+
heap.push({
|
|
1920
|
+
url: raw,
|
|
1921
|
+
key,
|
|
1922
|
+
host,
|
|
1923
|
+
depth,
|
|
1924
|
+
tier,
|
|
1925
|
+
lastmod,
|
|
1926
|
+
seq,
|
|
1927
|
+
source,
|
|
1928
|
+
referrer
|
|
1929
|
+
});
|
|
1930
|
+
queued += 1;
|
|
1931
|
+
};
|
|
1932
|
+
/** This function gives the first URL of a host with no request of the crawl in progress. */
|
|
1933
|
+
const pick = () => {
|
|
1934
|
+
for (;;) {
|
|
1935
|
+
let best;
|
|
1936
|
+
for (const [host, heap] of heaps) {
|
|
1937
|
+
if (inflight.has(host)) continue;
|
|
1938
|
+
const top = heap.peek();
|
|
1939
|
+
if (top === void 0) continue;
|
|
1940
|
+
if (best === void 0 || before(top, best.heap.peek()) < 0) best = {
|
|
1941
|
+
host,
|
|
1942
|
+
heap
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
if (best === void 0) return void 0;
|
|
1946
|
+
const item = best.heap.pop();
|
|
1947
|
+
queued -= 1;
|
|
1948
|
+
if (best.heap.size === 0) heaps.delete(best.host);
|
|
1949
|
+
if (!got.has(item.key)) return item;
|
|
1950
|
+
skips.duplicate += 1;
|
|
1951
|
+
}
|
|
1952
|
+
};
|
|
1953
|
+
const fetchItem = async (item) => {
|
|
1954
|
+
try {
|
|
1955
|
+
if (item.source !== "seed") {
|
|
1956
|
+
if (!(await crawler.robotsAllow(item.url, {
|
|
1957
|
+
onBehalfOf: options.onBehalfOf,
|
|
1958
|
+
signal: abort.signal,
|
|
1959
|
+
...marks
|
|
1960
|
+
})).allowed) return {
|
|
1961
|
+
kind: "refused",
|
|
1962
|
+
item
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
return {
|
|
1966
|
+
kind: "page",
|
|
1967
|
+
item,
|
|
1968
|
+
page: await fetchPage(runtime, item.url, {
|
|
1969
|
+
render: options.render ?? "auto",
|
|
1970
|
+
maxAgeMs,
|
|
1971
|
+
onBehalfOf: options.onBehalfOf,
|
|
1972
|
+
signal: abort.signal,
|
|
1973
|
+
scope: options.scope,
|
|
1974
|
+
labels: options.labels,
|
|
1975
|
+
deadline
|
|
1976
|
+
})
|
|
1977
|
+
};
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
if (abort.signal.aborted) return {
|
|
1980
|
+
kind: "aborted",
|
|
1981
|
+
item
|
|
1982
|
+
};
|
|
1983
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1984
|
+
return {
|
|
1985
|
+
kind: "page",
|
|
1986
|
+
item,
|
|
1987
|
+
page: failedPage(item.url, `crawl error: ${message}`)
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
/** This function adds the links of a page to the frontier. The canonical URL of the page is known after its fetch. */
|
|
1992
|
+
const follow = (page, item) => {
|
|
1993
|
+
if (page.doc === null) return;
|
|
1994
|
+
const doc = runtime.index.get(page.doc);
|
|
1995
|
+
if (doc === void 0) return;
|
|
1996
|
+
for (const same of [doc.url, doc.meta["canonical"]]) {
|
|
1997
|
+
if (typeof same !== "string") continue;
|
|
1998
|
+
seen.add(cacheKeyOf(same));
|
|
1999
|
+
got.add(cacheKeyOf(same));
|
|
2000
|
+
}
|
|
2001
|
+
const links = doc.meta["links"];
|
|
2002
|
+
if (!Array.isArray(links) || links.length === 0) return;
|
|
2003
|
+
if (isHeldBack(doc)) {
|
|
2004
|
+
skips.held_back += 1;
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
for (const link of links) if (typeof link?.href === "string") offer(link.href, item.depth + 1, 2, Number.NEGATIVE_INFINITY, "link", page.url);
|
|
2008
|
+
};
|
|
2009
|
+
for (const seed of list) offer(seed, 0, 0, Number.NEGATIVE_INFINITY, "seed", null);
|
|
2010
|
+
if (options.useSitemaps === true) {
|
|
2011
|
+
const sites = [...new Set(starts.map((start) => start.origin))];
|
|
2012
|
+
for (const site of sites) {
|
|
2013
|
+
if (abort.signal.aborted || now() >= deadline) break;
|
|
2014
|
+
try {
|
|
2015
|
+
const found = await discoverSite(runtime, site, {
|
|
2016
|
+
sameSite: options.sameSite,
|
|
2017
|
+
allowHosts: options.allowHosts,
|
|
2018
|
+
maxUrls: Math.min(1e4, Math.max(50, maxPages * 4)),
|
|
2019
|
+
linkedFeeds: false,
|
|
2020
|
+
maxAge,
|
|
2021
|
+
onBehalfOf: options.onBehalfOf,
|
|
2022
|
+
signal: abort.signal,
|
|
2023
|
+
scope: options.scope,
|
|
2024
|
+
labels: options.labels,
|
|
2025
|
+
deadline
|
|
2026
|
+
});
|
|
2027
|
+
counts.sitemap_urls += found.urls.length;
|
|
2028
|
+
for (const url of found.urls) offer(url.url, 0, 1, url.lastmod === null ? Number.NEGATIVE_INFINITY : Date.parse(url.lastmod), "sitemap", null);
|
|
2029
|
+
} catch {}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
for (;;) {
|
|
2033
|
+
if (stopping === void 0 && abort.signal.aborted) stopping = "cancelled";
|
|
2034
|
+
if (stopping === void 0 && now() >= deadline) stopping = "budget";
|
|
2035
|
+
while (stopping === void 0 && inflight.size < CRAWL_LIMITS.hostsAtOnce && started < maxPages) {
|
|
2036
|
+
const item = pick();
|
|
2037
|
+
if (item === void 0) break;
|
|
2038
|
+
started += 1;
|
|
2039
|
+
inflight.set(item.host, fetchItem(item));
|
|
2040
|
+
}
|
|
2041
|
+
if (inflight.size === 0) break;
|
|
2042
|
+
const done = await Promise.race(inflight.values());
|
|
2043
|
+
inflight.delete(done.item.host);
|
|
2044
|
+
if (done.kind === "aborted") {
|
|
2045
|
+
started -= 1;
|
|
2046
|
+
stopping ??= "cancelled";
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
if (done.kind === "refused") {
|
|
2050
|
+
started -= 1;
|
|
2051
|
+
counts.refused += 1;
|
|
2052
|
+
continue;
|
|
2053
|
+
}
|
|
2054
|
+
const page = done.page;
|
|
2055
|
+
if (pastDeadline(page.note)) {
|
|
2056
|
+
started -= 1;
|
|
2057
|
+
returned += 1;
|
|
2058
|
+
stopping ??= "budget";
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
if (abort.signal.aborted) {
|
|
2062
|
+
started -= 1;
|
|
2063
|
+
stopping ??= "cancelled";
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
if (page.outcome === "skipped" && page.note === "crawler closed") {
|
|
2067
|
+
started -= 1;
|
|
2068
|
+
stopping ??= "closed";
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
counts.pages += 1;
|
|
2072
|
+
if (page.outcome === "ok" || page.outcome === "not-modified") {
|
|
2073
|
+
counts.fetched += 1;
|
|
2074
|
+
if (page.from_cache) counts.from_cache += 1;
|
|
2075
|
+
} else if (REFUSALS$1.has(page.outcome)) counts.refused += 1;
|
|
2076
|
+
else counts.failed += 1;
|
|
2077
|
+
follow(page, done.item);
|
|
2078
|
+
yield {
|
|
2079
|
+
...page,
|
|
2080
|
+
depth: done.item.depth,
|
|
2081
|
+
source: done.item.source,
|
|
2082
|
+
referrer: done.item.referrer
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
stopping ??= queued > 0 && started >= maxPages ? "max-pages" : "done";
|
|
2086
|
+
finished = true;
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
stopping = "error";
|
|
2089
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
2090
|
+
finished = true;
|
|
2091
|
+
throw error;
|
|
2092
|
+
} finally {
|
|
2093
|
+
if (!finished) {
|
|
2094
|
+
stopping ??= "cancelled";
|
|
2095
|
+
reason ??= "the caller stopped the loop";
|
|
2096
|
+
}
|
|
2097
|
+
outer?.removeEventListener("abort", cancel);
|
|
2098
|
+
abort.abort(/* @__PURE__ */ new Error("the crawl ended"));
|
|
2099
|
+
await Promise.allSettled(inflight.values());
|
|
2100
|
+
const stop = stopping ?? "done";
|
|
2101
|
+
reason ??= stop === "done" ? "the crawl fetched each page in scope that it found" : stop === "max-pages" ? `the crawl gave ${String(maxPages)} pages, its limit (maxPages)` : stop === "budget" ? `the budget of ${String(budgetMs)} ms ended (budgetMs)` : stop === "cancelled" ? "the caller cancelled the crawl" : "the crawler closed";
|
|
2102
|
+
resolveSummary({
|
|
2103
|
+
...counts,
|
|
2104
|
+
skipped: skips.scope + skips.pattern + skips.depth + skips.held_back + skips.limit + skips.duplicate,
|
|
2105
|
+
skips,
|
|
2106
|
+
queued: queued + returned,
|
|
2107
|
+
stopped: stop,
|
|
2108
|
+
reason,
|
|
2109
|
+
elapsed_ms: Math.max(0, now() - startedAt)
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
return {
|
|
2114
|
+
summary,
|
|
2115
|
+
[Symbol.asyncIterator]() {
|
|
2116
|
+
if (used) throw new Error("a crawl runs one time: call crawl again for a new crawl");
|
|
2117
|
+
used = true;
|
|
2118
|
+
return pages();
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
};
|
|
2122
|
+
//#endregion
|
|
2123
|
+
//#region src/hoststate.ts
|
|
2124
|
+
/** A stop with no end goes into the table as this value, which is past each date that JavaScript can hold. */
|
|
2125
|
+
const NO_END = 9e15;
|
|
2126
|
+
/** A process that shares the store asks again after this wait, at most, during the lease of a different process. */
|
|
2127
|
+
const SHARED_WAIT_MS = 1e3;
|
|
2128
|
+
/** The store removes the rows of idle hosts after this many reservations. */
|
|
2129
|
+
const SWEEP_EVERY = 1e3;
|
|
2130
|
+
const verdictOf = (value) => value === "works" || value === "fails" ? value : "unknown";
|
|
2131
|
+
const memoOf = (row) => ({
|
|
2132
|
+
skipEtag: row.skip_etag === 1,
|
|
2133
|
+
etag: verdictOf(row.etag),
|
|
2134
|
+
lastModified: verdictOf(row.last_modified),
|
|
2135
|
+
etagMisses: row.etag_misses,
|
|
2136
|
+
dateMisses: row.date_misses,
|
|
2137
|
+
sinceProbe: row.since_probe,
|
|
2138
|
+
changedAt: row.changed_at
|
|
2139
|
+
});
|
|
2140
|
+
/**
|
|
2141
|
+
* This store keeps the state of each host in a SQLite file. Two or more processes on one store
|
|
2142
|
+
* share it, so they share the pace and the stops of each host. Thus a host gets one request at a
|
|
2143
|
+
* time from all of them. Each method is one transaction (`BEGIN IMMEDIATE`), and it waits for the lock
|
|
2144
|
+
* of a different process (`busy_timeout`).
|
|
2145
|
+
*
|
|
2146
|
+
* The lease of a request belongs to the store that gave it. A different process that asks during
|
|
2147
|
+
* the lease gets a wait of one second or less, and asks again. A process that stops during a
|
|
2148
|
+
* request holds its lease until the lease ends (5 minutes by default).
|
|
2149
|
+
*
|
|
2150
|
+
* The table `validators` keeps the validator memo of each host (`ValidatorMemo`), so the processes
|
|
2151
|
+
* share it and it stays after a restart. A file of version 0.1.0 gets the table when it opens.
|
|
2152
|
+
*
|
|
2153
|
+
* The state is not a projection of the capture log. A deletion of the file loses only the pace,
|
|
2154
|
+
* the counts, the stops and the memos, and the crawler starts them again.
|
|
2155
|
+
*/
|
|
2156
|
+
var SqliteHostStateStore = class {
|
|
2157
|
+
path;
|
|
2158
|
+
db;
|
|
2159
|
+
statements = /* @__PURE__ */ new Map();
|
|
2160
|
+
/** This id names the leases of this store. */
|
|
2161
|
+
owner = randomUUID();
|
|
2162
|
+
reservations = 0;
|
|
2163
|
+
closed = false;
|
|
2164
|
+
pollMs;
|
|
2165
|
+
constructor(path, options = {}) {
|
|
2166
|
+
this.path = path;
|
|
2167
|
+
if (path !== ":memory:") mkdirSync(dirname(path), {
|
|
2168
|
+
recursive: true,
|
|
2169
|
+
mode: 448
|
|
2170
|
+
});
|
|
2171
|
+
this.pollMs = Math.max(1, Math.min(SHARED_WAIT_MS, options.pollMs ?? SHARED_WAIT_MS));
|
|
2172
|
+
this.db = new DatabaseSync(path);
|
|
2173
|
+
const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
|
|
2174
|
+
this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
|
|
2175
|
+
if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
|
|
2176
|
+
this.db.exec("PRAGMA synchronous = NORMAL;");
|
|
2177
|
+
this.migrate();
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* This method makes the tables, or adds the tables of a later version to an older file. It is one
|
|
2181
|
+
* transaction, so two processes that open one old file at the same time do not collide.
|
|
2182
|
+
*/
|
|
2183
|
+
migrate() {
|
|
2184
|
+
this.step(() => {
|
|
2185
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS hosts (
|
|
2186
|
+
host TEXT PRIMARY KEY,
|
|
2187
|
+
next_at REAL NOT NULL DEFAULT 0,
|
|
2188
|
+
leased_until REAL NOT NULL DEFAULT 0,
|
|
2189
|
+
owner TEXT,
|
|
2190
|
+
failures INTEGER NOT NULL DEFAULT 0,
|
|
2191
|
+
waits INTEGER NOT NULL DEFAULT 0,
|
|
2192
|
+
stop_until REAL,
|
|
2193
|
+
stop_reason TEXT
|
|
2194
|
+
)`);
|
|
2195
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS validators (
|
|
2196
|
+
host TEXT PRIMARY KEY,
|
|
2197
|
+
skip_etag INTEGER NOT NULL DEFAULT 0,
|
|
2198
|
+
etag TEXT NOT NULL DEFAULT 'unknown',
|
|
2199
|
+
last_modified TEXT NOT NULL DEFAULT 'unknown',
|
|
2200
|
+
etag_misses INTEGER NOT NULL DEFAULT 0,
|
|
2201
|
+
date_misses INTEGER NOT NULL DEFAULT 0,
|
|
2202
|
+
since_probe INTEGER NOT NULL DEFAULT 0,
|
|
2203
|
+
changed_at REAL NOT NULL DEFAULT 0
|
|
2204
|
+
)`);
|
|
2205
|
+
const { user_version: version } = this.db.prepare("PRAGMA user_version").get();
|
|
2206
|
+
if (version < 1) this.db.exec(`PRAGMA user_version = ${String(1)}`);
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
stmt(sql) {
|
|
2210
|
+
let statement = this.statements.get(sql);
|
|
2211
|
+
if (statement === void 0) {
|
|
2212
|
+
statement = this.db.prepare(sql);
|
|
2213
|
+
this.statements.set(sql, statement);
|
|
2214
|
+
}
|
|
2215
|
+
return statement;
|
|
2216
|
+
}
|
|
2217
|
+
/** This method does one step in one transaction. */
|
|
2218
|
+
step(work) {
|
|
2219
|
+
if (this.closed) throw new Error("the host state store is closed");
|
|
2220
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2221
|
+
try {
|
|
2222
|
+
const result = work();
|
|
2223
|
+
this.db.exec("COMMIT");
|
|
2224
|
+
return result;
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
if (this.db.isTransaction) this.db.exec("ROLLBACK");
|
|
2227
|
+
throw error;
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
row(host) {
|
|
2231
|
+
this.stmt("INSERT OR IGNORE INTO hosts (host) VALUES (?)").run(host);
|
|
2232
|
+
return this.stmt("SELECT next_at, leased_until, owner, failures, waits, stop_until, stop_reason FROM hosts WHERE host = ?").get(host);
|
|
2233
|
+
}
|
|
2234
|
+
/** This method removes the hosts that have nothing to remember: no rest, no lease, no counts and no stop. */
|
|
2235
|
+
sweep(now) {
|
|
2236
|
+
this.stmt(`DELETE FROM hosts WHERE next_at <= ? AND leased_until <= ? AND failures = 0 AND waits = 0
|
|
2237
|
+
AND (stop_until IS NULL OR stop_until <= ?)`).run(now, now, now);
|
|
2238
|
+
}
|
|
2239
|
+
reserve(host, now, leaseMs) {
|
|
2240
|
+
return this.step(() => {
|
|
2241
|
+
this.reservations += 1;
|
|
2242
|
+
if (this.reservations % SWEEP_EVERY === 0) this.sweep(now);
|
|
2243
|
+
const row = this.row(host);
|
|
2244
|
+
if (row.leased_until > now) return row.owner === this.owner ? row.leased_until - now : Math.min(this.pollMs, row.leased_until - now);
|
|
2245
|
+
if (row.next_at > now) return row.next_at - now;
|
|
2246
|
+
this.stmt("UPDATE hosts SET leased_until = ?, owner = ? WHERE host = ?").run(now + leaseMs, this.owner, host);
|
|
2247
|
+
return 0;
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
record(host, result) {
|
|
2251
|
+
return this.step(() => {
|
|
2252
|
+
const row = this.row(host);
|
|
2253
|
+
let { failures, waits } = row;
|
|
2254
|
+
if (result === "success") {
|
|
2255
|
+
failures = 0;
|
|
2256
|
+
waits = 0;
|
|
2257
|
+
} else if (result === "failure") failures += 1;
|
|
2258
|
+
else if (result === "wait") waits += 1;
|
|
2259
|
+
if (failures !== row.failures || waits !== row.waits) this.stmt("UPDATE hosts SET failures = ?, waits = ? WHERE host = ?").run(failures, waits, host);
|
|
2260
|
+
return {
|
|
2261
|
+
failures,
|
|
2262
|
+
waits
|
|
2263
|
+
};
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
release(host, nextAt) {
|
|
2267
|
+
this.step(() => {
|
|
2268
|
+
this.row(host);
|
|
2269
|
+
this.stmt(`UPDATE hosts SET next_at = max(next_at, ?),
|
|
2270
|
+
leased_until = CASE WHEN owner = ? THEN 0 ELSE leased_until END,
|
|
2271
|
+
owner = CASE WHEN owner = ? THEN NULL ELSE owner END
|
|
2272
|
+
WHERE host = ?`).run(nextAt, this.owner, this.owner, host);
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
defer(host, until) {
|
|
2276
|
+
this.step(() => {
|
|
2277
|
+
this.row(host);
|
|
2278
|
+
this.stmt("UPDATE hosts SET next_at = max(next_at, ?) WHERE host = ?").run(until, host);
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
stop(host, until, reason) {
|
|
2282
|
+
const end = Number.isFinite(until) ? until : NO_END;
|
|
2283
|
+
this.step(() => {
|
|
2284
|
+
this.row(host);
|
|
2285
|
+
this.stmt(`UPDATE hosts SET stop_until = ?, stop_reason = ?
|
|
2286
|
+
WHERE host = ? AND (stop_until IS NULL OR stop_until < ?)`).run(end, reason, host, end);
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
stopped(host, now) {
|
|
2290
|
+
if (this.closed) throw new Error("the host state store is closed");
|
|
2291
|
+
const row = this.stmt("SELECT stop_until, stop_reason FROM hosts WHERE host = ?").get(host);
|
|
2292
|
+
if (row === void 0 || row.stop_until === null || row.stop_until <= now) return void 0;
|
|
2293
|
+
return {
|
|
2294
|
+
until: row.stop_until >= NO_END ? Number.POSITIVE_INFINITY : row.stop_until,
|
|
2295
|
+
reason: row.stop_reason ?? "stopped"
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
reset(host) {
|
|
2299
|
+
this.step(() => {
|
|
2300
|
+
if (host === void 0) this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL").run();
|
|
2301
|
+
else this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL WHERE host = ?").run(host);
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
validators(host) {
|
|
2305
|
+
if (this.closed) throw new Error("the host state store is closed");
|
|
2306
|
+
const row = this.stmt(`SELECT skip_etag, etag, last_modified, etag_misses, date_misses, since_probe, changed_at
|
|
2307
|
+
FROM validators WHERE host = ?`).get(host);
|
|
2308
|
+
return row === void 0 ? void 0 : memoOf(row);
|
|
2309
|
+
}
|
|
2310
|
+
noteValidators(host, report, now) {
|
|
2311
|
+
return this.step(() => {
|
|
2312
|
+
const memo = nextValidatorMemo(this.validators(host), report, now);
|
|
2313
|
+
this.stmt(`INSERT INTO validators (host, skip_etag, etag, last_modified, etag_misses, date_misses, since_probe, changed_at)
|
|
2314
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2315
|
+
ON CONFLICT(host) DO UPDATE SET skip_etag = excluded.skip_etag, etag = excluded.etag,
|
|
2316
|
+
last_modified = excluded.last_modified, etag_misses = excluded.etag_misses,
|
|
2317
|
+
date_misses = excluded.date_misses, since_probe = excluded.since_probe,
|
|
2318
|
+
changed_at = excluded.changed_at`).run(host, memo.skipEtag ? 1 : 0, memo.etag, memo.lastModified, memo.etagMisses, memo.dateMisses, memo.sinceProbe, memo.changedAt);
|
|
2319
|
+
return memo;
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
/** This value is the number of hosts that the store holds. */
|
|
2323
|
+
get size() {
|
|
2324
|
+
return this.stmt("SELECT count(*) AS n FROM hosts").get().n;
|
|
2325
|
+
}
|
|
2326
|
+
/** This method closes the file. A second close does nothing. */
|
|
2327
|
+
close() {
|
|
2328
|
+
if (this.closed) return;
|
|
2329
|
+
this.closed = true;
|
|
2330
|
+
this.statements.clear();
|
|
2331
|
+
this.db.close();
|
|
2332
|
+
}
|
|
2333
|
+
};
|
|
2334
|
+
//#endregion
|
|
2335
|
+
//#region src/watch.ts
|
|
2336
|
+
/**
|
|
2337
|
+
* A watch list keeps the pages that the crawler checks again on a schedule. `watch.run` fetches the
|
|
2338
|
+
* pages that are due through the normal fetch, so each polite rule applies. It gives an event for
|
|
2339
|
+
* each page: new, changed, unchanged, gone, refused or error.
|
|
2340
|
+
*
|
|
2341
|
+
* These are the rules of a watch:
|
|
2342
|
+
*
|
|
2343
|
+
* - A watch compares the title, the text and the JSON-LD of the page with the last confirmed version.
|
|
2344
|
+
* - With `confirm: 2` (the default), a change is suspected when a check first sees it. The next
|
|
2345
|
+
* check confirms it when it sees the same new version. A check that sees the earlier version
|
|
2346
|
+
* again clears the suspicion, because one read can be a glitch. With `confirm: 1`, each check confirms.
|
|
2347
|
+
* - `every` has a floor of 1 hour by default. A check that a stop of the host refused is due again
|
|
2348
|
+
* at the end of the stop, or later.
|
|
2349
|
+
* - The list is in `watch.sqlite` in the store, so it stays after a restart. A run claims each entry
|
|
2350
|
+
* with a lease in that file, so two processes do not check one entry two times.
|
|
2351
|
+
*
|
|
2352
|
+
* The package has no daemon. A scheduler of the operator starts `watch.run`.
|
|
2353
|
+
*/
|
|
2354
|
+
/** This value is the version of the schema of `watch.sqlite`. */
|
|
2355
|
+
const WATCH_SCHEMA = 1;
|
|
2356
|
+
/** These are the defaults and the limits of the watch list. */
|
|
2357
|
+
const WATCH_LIMITS = {
|
|
2358
|
+
/** The shortest `every` of a watch by default: 1 hour, so a watch cannot hammer a site. */
|
|
2359
|
+
floorMs: 36e5,
|
|
2360
|
+
/** The default `every`: 24 hours. */
|
|
2361
|
+
everyMs: 864e5,
|
|
2362
|
+
/** The default time of one run: 10 minutes. No request starts after it. */
|
|
2363
|
+
budgetMs: 6e5,
|
|
2364
|
+
/** The default number of entries that one run checks. */
|
|
2365
|
+
maxPages: 100,
|
|
2366
|
+
/** The most hosts that get a check of one run at one time. */
|
|
2367
|
+
hostsAtOnce: 8,
|
|
2368
|
+
/** A run holds the lease of an entry until the end of its budget and this time more: 2 minutes. */
|
|
2369
|
+
leaseMs: 12e4
|
|
2370
|
+
};
|
|
2371
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
2372
|
+
const parseJson = (text, fallback) => {
|
|
2373
|
+
if (text === null) return fallback;
|
|
2374
|
+
try {
|
|
2375
|
+
return JSON.parse(text);
|
|
2376
|
+
} catch {
|
|
2377
|
+
return fallback;
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
const RENDERS = /* @__PURE__ */ new Set([
|
|
2381
|
+
"auto",
|
|
2382
|
+
"never",
|
|
2383
|
+
"always"
|
|
2384
|
+
]);
|
|
2385
|
+
const entryOf = (row, now) => ({
|
|
2386
|
+
url: row.target,
|
|
2387
|
+
every_ms: row.every_ms,
|
|
2388
|
+
render: row.render,
|
|
2389
|
+
confirm: row.confirm === 1 ? 1 : 2,
|
|
2390
|
+
scope: row.scope,
|
|
2391
|
+
labels: parseJson(row.labels, null),
|
|
2392
|
+
on_behalf_of: parseJson(row.on_behalf_of, []),
|
|
2393
|
+
added_at: iso(row.added_at),
|
|
2394
|
+
next_at: iso(row.next_at),
|
|
2395
|
+
checked_at: row.last_at === null ? null : iso(row.last_at),
|
|
2396
|
+
last_kind: row.last_kind,
|
|
2397
|
+
last_note: row.last_note,
|
|
2398
|
+
checks: row.checks,
|
|
2399
|
+
changes: row.changes,
|
|
2400
|
+
doc: row.doc,
|
|
2401
|
+
capture: row.base_capture,
|
|
2402
|
+
fetched_at: row.base_fetched_at,
|
|
2403
|
+
gone: row.base_state === "gone",
|
|
2404
|
+
pending: row.pend_state === null ? null : {
|
|
2405
|
+
kind: row.pend_state === "gone" ? "gone" : row.base_state === "gone" ? "new" : "changed",
|
|
2406
|
+
capture: row.pend_capture,
|
|
2407
|
+
since: iso(row.pend_since ?? row.last_at ?? row.added_at)
|
|
2408
|
+
},
|
|
2409
|
+
leased_until: row.lease_until > now ? iso(row.lease_until) : null
|
|
2410
|
+
});
|
|
2411
|
+
/** This function gives the canonical key, the URL and the host of a page to watch. A URL that is not http(s) throws a `RangeError`. */
|
|
2412
|
+
const targetOf = (url) => {
|
|
2413
|
+
let parsed;
|
|
2414
|
+
try {
|
|
2415
|
+
parsed = new URL(url);
|
|
2416
|
+
} catch {
|
|
2417
|
+
throw new RangeError(`not a valid URL: ${url}`);
|
|
2418
|
+
}
|
|
2419
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new RangeError(`a watch needs an http or https URL, not ${url}`);
|
|
2420
|
+
parsed.hash = "";
|
|
2421
|
+
return {
|
|
2422
|
+
key: cacheKeyOf(parsed.href),
|
|
2423
|
+
target: parsed.href,
|
|
2424
|
+
host: parsed.host.toLowerCase()
|
|
2425
|
+
};
|
|
2426
|
+
};
|
|
2427
|
+
/**
|
|
2428
|
+
* This store keeps the watch list in a SQLite file (`watch.sqlite`). Two or more processes on one
|
|
2429
|
+
* store share it. Each step is one transaction (`BEGIN IMMEDIATE`), and it waits for the lock of a
|
|
2430
|
+
* different process (`busy_timeout`).
|
|
2431
|
+
*
|
|
2432
|
+
* The list is the decision of the operator, not a projection of the capture log. Thus it has its
|
|
2433
|
+
* own file: a deletion of `hosts.sqlite` or of the index does not lose it.
|
|
2434
|
+
*/
|
|
2435
|
+
var WatchStore = class {
|
|
2436
|
+
path;
|
|
2437
|
+
db;
|
|
2438
|
+
statements = /* @__PURE__ */ new Map();
|
|
2439
|
+
closed = false;
|
|
2440
|
+
/** This value is the shortest `every` in milliseconds. */
|
|
2441
|
+
floorMs;
|
|
2442
|
+
constructor(path, options = {}) {
|
|
2443
|
+
this.path = path;
|
|
2444
|
+
if (path !== ":memory:") mkdirSync(dirname(path), {
|
|
2445
|
+
recursive: true,
|
|
2446
|
+
mode: 448
|
|
2447
|
+
});
|
|
2448
|
+
const floor = options.floorMs ?? WATCH_LIMITS.floorMs;
|
|
2449
|
+
if (!Number.isFinite(floor) || floor < 0) throw new RangeError(`the floor of every must be 0 or more milliseconds, not ${String(floor)}`);
|
|
2450
|
+
this.floorMs = floor;
|
|
2451
|
+
this.db = new DatabaseSync(path);
|
|
2452
|
+
const wait = Math.max(0, Math.floor(options.busyTimeoutMs ?? 1e4));
|
|
2453
|
+
this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
|
|
2454
|
+
if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
|
|
2455
|
+
this.db.exec("PRAGMA synchronous = NORMAL;");
|
|
2456
|
+
this.step(() => {
|
|
2457
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS watches (
|
|
2458
|
+
url TEXT PRIMARY KEY,
|
|
2459
|
+
target TEXT NOT NULL,
|
|
2460
|
+
host TEXT NOT NULL,
|
|
2461
|
+
every_ms REAL NOT NULL,
|
|
2462
|
+
render TEXT NOT NULL,
|
|
2463
|
+
confirm INTEGER NOT NULL,
|
|
2464
|
+
scope TEXT,
|
|
2465
|
+
labels TEXT,
|
|
2466
|
+
on_behalf_of TEXT,
|
|
2467
|
+
added_at REAL NOT NULL,
|
|
2468
|
+
next_at REAL NOT NULL,
|
|
2469
|
+
lease_until REAL NOT NULL DEFAULT 0,
|
|
2470
|
+
lease_owner TEXT,
|
|
2471
|
+
checks INTEGER NOT NULL DEFAULT 0,
|
|
2472
|
+
changes INTEGER NOT NULL DEFAULT 0,
|
|
2473
|
+
last_at REAL,
|
|
2474
|
+
last_kind TEXT,
|
|
2475
|
+
last_note TEXT,
|
|
2476
|
+
doc TEXT,
|
|
2477
|
+
base_state TEXT,
|
|
2478
|
+
base_sha TEXT,
|
|
2479
|
+
base_capture TEXT,
|
|
2480
|
+
base_fetched_at TEXT,
|
|
2481
|
+
pend_state TEXT,
|
|
2482
|
+
pend_sha TEXT,
|
|
2483
|
+
pend_capture TEXT,
|
|
2484
|
+
pend_fetched_at TEXT,
|
|
2485
|
+
pend_since REAL
|
|
2486
|
+
)`);
|
|
2487
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS watches_due ON watches (next_at)");
|
|
2488
|
+
const { user_version: version } = this.db.prepare("PRAGMA user_version").get();
|
|
2489
|
+
if (version < 1) this.db.exec(`PRAGMA user_version = ${String(1)}`);
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
stmt(sql) {
|
|
2493
|
+
let statement = this.statements.get(sql);
|
|
2494
|
+
if (statement === void 0) {
|
|
2495
|
+
statement = this.db.prepare(sql);
|
|
2496
|
+
this.statements.set(sql, statement);
|
|
2497
|
+
}
|
|
2498
|
+
return statement;
|
|
2499
|
+
}
|
|
2500
|
+
/** This method does one step in one transaction. */
|
|
2501
|
+
step(work) {
|
|
2502
|
+
if (this.closed) throw new Error("the watch store is closed");
|
|
2503
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2504
|
+
try {
|
|
2505
|
+
const result = work();
|
|
2506
|
+
this.db.exec("COMMIT");
|
|
2507
|
+
return result;
|
|
2508
|
+
} catch (error) {
|
|
2509
|
+
if (this.db.isTransaction) this.db.exec("ROLLBACK");
|
|
2510
|
+
throw error;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
row(key) {
|
|
2514
|
+
return this.stmt("SELECT * FROM watches WHERE url = ?").get(key);
|
|
2515
|
+
}
|
|
2516
|
+
/**
|
|
2517
|
+
* This method adds a page, or changes the options of its watch and keeps its state. A new entry
|
|
2518
|
+
* is due at once. A changed entry is due one `every` after its last check. A bad option throws
|
|
2519
|
+
* a `RangeError`.
|
|
2520
|
+
*/
|
|
2521
|
+
add(url, options, now) {
|
|
2522
|
+
const { key, target, host } = targetOf(url);
|
|
2523
|
+
const every = options.every === void 0 ? WATCH_LIMITS.everyMs : durationMs(options.every);
|
|
2524
|
+
if (every < this.floorMs) throw new RangeError(`every must be ${String(this.floorMs / 6e4)} minutes or more, so a watch cannot hammer a site, not ${String(every / 6e4)} minutes`);
|
|
2525
|
+
const render = options.render ?? "auto";
|
|
2526
|
+
if (!RENDERS.has(render)) throw new RangeError(`render is auto, never or always, not ${JSON.stringify(render)}`);
|
|
2527
|
+
const confirm = options.confirm ?? 2;
|
|
2528
|
+
if (confirm !== 1 && confirm !== 2) throw new RangeError(`confirm is 1 or 2, not ${JSON.stringify(confirm)}`);
|
|
2529
|
+
const labels = options.labels === void 0 || Object.keys(options.labels).length === 0 ? null : JSON.stringify(options.labels);
|
|
2530
|
+
const agents = options.onBehalfOf === void 0 || options.onBehalfOf.length === 0 ? null : JSON.stringify([...options.onBehalfOf]);
|
|
2531
|
+
return this.step(() => {
|
|
2532
|
+
this.stmt(`INSERT INTO watches (url, target, host, every_ms, render, confirm, scope, labels, on_behalf_of, added_at, next_at)
|
|
2533
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2534
|
+
ON CONFLICT(url) DO UPDATE SET target = excluded.target, host = excluded.host,
|
|
2535
|
+
every_ms = excluded.every_ms, render = excluded.render, confirm = excluded.confirm,
|
|
2536
|
+
scope = excluded.scope, labels = excluded.labels, on_behalf_of = excluded.on_behalf_of,
|
|
2537
|
+
next_at = CASE WHEN watches.last_at IS NULL THEN watches.next_at
|
|
2538
|
+
ELSE watches.last_at + excluded.every_ms END`).run(key, target, host, every, render, confirm, options.scope ?? null, labels, agents, now, now);
|
|
2539
|
+
return entryOf(this.row(key), now);
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
/** This method removes a page. It gives false when the list does not have the page. */
|
|
2543
|
+
remove(url) {
|
|
2544
|
+
const { key } = targetOf(url);
|
|
2545
|
+
return this.step(() => Number(this.stmt("DELETE FROM watches WHERE url = ?").run(key).changes) > 0);
|
|
2546
|
+
}
|
|
2547
|
+
/** This method gives the entry of a page, or undefined. */
|
|
2548
|
+
get(url, now) {
|
|
2549
|
+
if (this.closed) throw new Error("the watch store is closed");
|
|
2550
|
+
const row = this.row(targetOf(url).key);
|
|
2551
|
+
return row === void 0 ? void 0 : entryOf(row, now);
|
|
2552
|
+
}
|
|
2553
|
+
/** This method gives the entries, the first due first. */
|
|
2554
|
+
list(now) {
|
|
2555
|
+
if (this.closed) throw new Error("the watch store is closed");
|
|
2556
|
+
return this.stmt("SELECT * FROM watches ORDER BY next_at, url").all().map((row) => entryOf(row, now));
|
|
2557
|
+
}
|
|
2558
|
+
/** This method counts the entries that are due at a time. */
|
|
2559
|
+
due(now) {
|
|
2560
|
+
if (this.closed) throw new Error("the watch store is closed");
|
|
2561
|
+
return this.stmt("SELECT count(*) AS n FROM watches WHERE next_at <= ?").get(now).n;
|
|
2562
|
+
}
|
|
2563
|
+
/**
|
|
2564
|
+
* This method claims the first due entry with no lease, on a host that is not in `busy`. The
|
|
2565
|
+
* lease of `owner` holds until `until`. Thus two processes do not get one entry at one time.
|
|
2566
|
+
*/
|
|
2567
|
+
claim(now, owner, until, busy) {
|
|
2568
|
+
return this.step(() => {
|
|
2569
|
+
const row = this.stmt(`SELECT * FROM watches WHERE next_at <= ? AND lease_until <= ?
|
|
2570
|
+
AND host NOT IN (SELECT value FROM json_each(?)) ORDER BY next_at, url LIMIT 1`).get(now, now, JSON.stringify(busy));
|
|
2571
|
+
if (row === void 0) return void 0;
|
|
2572
|
+
this.stmt("UPDATE watches SET lease_until = ?, lease_owner = ? WHERE url = ?").run(until, owner, row.url);
|
|
2573
|
+
return {
|
|
2574
|
+
...row,
|
|
2575
|
+
lease_until: until,
|
|
2576
|
+
lease_owner: owner
|
|
2577
|
+
};
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
/** This method ends the lease of `owner` and changes nothing else, so the entry stays due. A closed store does nothing. */
|
|
2581
|
+
release(key, owner) {
|
|
2582
|
+
if (this.closed) return;
|
|
2583
|
+
this.step(() => {
|
|
2584
|
+
this.stmt("UPDATE watches SET lease_until = 0, lease_owner = NULL WHERE url = ? AND lease_owner = ?").run(key, owner);
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* This method writes the result of a check and ends the lease. It gives false when `owner` does
|
|
2589
|
+
* not hold the lease, or when the store is closed.
|
|
2590
|
+
*/
|
|
2591
|
+
settle(key, owner, update) {
|
|
2592
|
+
if (this.closed) return false;
|
|
2593
|
+
return this.step(() => {
|
|
2594
|
+
const row = this.row(key);
|
|
2595
|
+
if (row === void 0 || row.lease_owner !== owner) return false;
|
|
2596
|
+
const base = update.base;
|
|
2597
|
+
const pending = update.pending;
|
|
2598
|
+
this.stmt(`UPDATE watches SET lease_until = 0, lease_owner = NULL, next_at = ?, last_at = ?, last_kind = ?,
|
|
2599
|
+
last_note = ?, checks = checks + 1, changes = changes + ?, doc = ?,
|
|
2600
|
+
base_state = ?, base_sha = ?, base_capture = ?, base_fetched_at = ?,
|
|
2601
|
+
pend_state = ?, pend_sha = ?, pend_capture = ?, pend_fetched_at = ?, pend_since = ?
|
|
2602
|
+
WHERE url = ?`).run(update.next_at, update.last_at, update.last_kind, update.last_note, update.changed ? 1 : 0, update.doc ?? row.doc, base === void 0 ? row.base_state : base.state, base === void 0 ? row.base_sha : base.sha, base === void 0 ? row.base_capture : base.capture, base === void 0 ? row.base_fetched_at : base.fetched_at, pending === void 0 ? row.pend_state : pending?.state ?? null, pending === void 0 ? row.pend_sha : pending?.sha ?? null, pending === void 0 ? row.pend_capture : pending?.capture ?? null, pending === void 0 ? row.pend_fetched_at : pending?.fetched_at ?? null, pending === void 0 ? row.pend_since : pending?.since ?? null, key);
|
|
2603
|
+
return true;
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
/** This method closes the file. A second close does nothing. */
|
|
2607
|
+
close() {
|
|
2608
|
+
if (this.closed) return;
|
|
2609
|
+
this.closed = true;
|
|
2610
|
+
this.statements.clear();
|
|
2611
|
+
this.db.close();
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
/** The outcomes of a check that the site or the crawler refused. */
|
|
2615
|
+
const REFUSALS = /* @__PURE__ */ new Set([
|
|
2616
|
+
"skipped",
|
|
2617
|
+
"blocked",
|
|
2618
|
+
"challenge",
|
|
2619
|
+
"payment-required"
|
|
2620
|
+
]);
|
|
2621
|
+
/** This function gives the fingerprint of a version: a hash of its title, its text and its JSON-LD. */
|
|
2622
|
+
const watchFingerprint = (version) => sha256(stableStringify([
|
|
2623
|
+
version.title,
|
|
2624
|
+
version.text,
|
|
2625
|
+
version.meta["jsonld"] ?? null
|
|
2626
|
+
]));
|
|
2627
|
+
const sameState = (a, b) => a !== void 0 && a.state === b.state && (a.state === "gone" || a.sha === b.sha);
|
|
2628
|
+
/**
|
|
2629
|
+
* This function checks the entries of a watch store that are due. `PoliteCrawler.watch.run` uses
|
|
2630
|
+
* it. Each check is a fetch of the page with `fetchPage`, with no stored copy (`maxAgeMs: 0`), so
|
|
2631
|
+
* each polite rule applies. A check that the budget stops before its request stays due. The store
|
|
2632
|
+
* can be a function that opens it: the run uses it before the first check.
|
|
2633
|
+
*/
|
|
2634
|
+
const runWatch = (runtime, source, options = {}) => {
|
|
2635
|
+
let resolveSummary = () => void 0;
|
|
2636
|
+
const summary = new Promise((resolve) => {
|
|
2637
|
+
resolveSummary = resolve;
|
|
2638
|
+
});
|
|
2639
|
+
let used = false;
|
|
2640
|
+
async function* events() {
|
|
2641
|
+
const now = () => Date.parse(runtime.now());
|
|
2642
|
+
const startedAt = now();
|
|
2643
|
+
const owner = randomUUID();
|
|
2644
|
+
const abort = new AbortController();
|
|
2645
|
+
const outer = options.signal;
|
|
2646
|
+
const cancel = () => abort.abort(outer?.reason);
|
|
2647
|
+
if (outer?.aborted === true) cancel();
|
|
2648
|
+
outer?.addEventListener("abort", cancel, { once: true });
|
|
2649
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
2650
|
+
const counts = {
|
|
2651
|
+
checked: 0,
|
|
2652
|
+
new: 0,
|
|
2653
|
+
changed: 0,
|
|
2654
|
+
unchanged: 0,
|
|
2655
|
+
gone: 0,
|
|
2656
|
+
refused: 0,
|
|
2657
|
+
error: 0,
|
|
2658
|
+
suspected: 0,
|
|
2659
|
+
confirmed: 0
|
|
2660
|
+
};
|
|
2661
|
+
let started = 0;
|
|
2662
|
+
let stopping;
|
|
2663
|
+
let reason;
|
|
2664
|
+
let finished = false;
|
|
2665
|
+
let budgetMs = WATCH_LIMITS.budgetMs;
|
|
2666
|
+
let maxPages = WATCH_LIMITS.maxPages;
|
|
2667
|
+
let opened;
|
|
2668
|
+
/** This function fetches one entry, compares it with its state, and writes the new state. */
|
|
2669
|
+
const check = async (store, row, deadline) => {
|
|
2670
|
+
const agents = [...options.onBehalfOf ?? [], ...parseJson(row.on_behalf_of, [])];
|
|
2671
|
+
const labels = parseJson(row.labels, void 0);
|
|
2672
|
+
let page;
|
|
2673
|
+
let failure;
|
|
2674
|
+
try {
|
|
2675
|
+
page = await fetchPage(runtime, row.target, {
|
|
2676
|
+
render: row.render,
|
|
2677
|
+
maxAgeMs: 0,
|
|
2678
|
+
onBehalfOf: agents,
|
|
2679
|
+
signal: abort.signal,
|
|
2680
|
+
scope: row.scope ?? void 0,
|
|
2681
|
+
labels,
|
|
2682
|
+
deadline
|
|
2683
|
+
});
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
failure = `watch error: ${error instanceof Error ? error.message : String(error)}`;
|
|
2686
|
+
}
|
|
2687
|
+
const deferred = (stop) => {
|
|
2688
|
+
store.release(row.url, owner);
|
|
2689
|
+
return {
|
|
2690
|
+
kind: "deferred",
|
|
2691
|
+
host: row.host,
|
|
2692
|
+
stop
|
|
2693
|
+
};
|
|
2694
|
+
};
|
|
2695
|
+
if (abort.signal.aborted) return deferred("cancelled");
|
|
2696
|
+
if (page !== void 0 && pastDeadline(page.note)) return deferred("budget");
|
|
2697
|
+
if (page?.outcome === "skipped" && page.note === "crawler closed") return deferred("closed");
|
|
2698
|
+
const at = now();
|
|
2699
|
+
const base = row.base_state === null ? void 0 : {
|
|
2700
|
+
state: row.base_state,
|
|
2701
|
+
sha: row.base_sha,
|
|
2702
|
+
capture: row.base_capture,
|
|
2703
|
+
fetched_at: row.base_fetched_at
|
|
2704
|
+
};
|
|
2705
|
+
const pending = row.pend_state === null ? void 0 : {
|
|
2706
|
+
state: row.pend_state,
|
|
2707
|
+
sha: row.pend_sha,
|
|
2708
|
+
capture: row.pend_capture,
|
|
2709
|
+
fetched_at: row.pend_fetched_at
|
|
2710
|
+
};
|
|
2711
|
+
let seen;
|
|
2712
|
+
let version;
|
|
2713
|
+
let held = false;
|
|
2714
|
+
let kind;
|
|
2715
|
+
let note = page?.note ?? failure ?? null;
|
|
2716
|
+
const ok = page !== void 0 && (page.outcome === "ok" || page.outcome === "not-modified");
|
|
2717
|
+
if (page !== void 0 && ok && page.doc !== null && page.capture !== null) {
|
|
2718
|
+
version = runtime.index.versionText(page.doc, page.capture);
|
|
2719
|
+
const doc = runtime.index.get(page.doc);
|
|
2720
|
+
held = doc !== void 0 && isHeldBack(doc) || version !== void 0 && isHeldBack(version);
|
|
2721
|
+
if (version !== void 0) seen = {
|
|
2722
|
+
state: "present",
|
|
2723
|
+
sha: watchFingerprint(version),
|
|
2724
|
+
capture: version.capture,
|
|
2725
|
+
fetched_at: version.fetched_at
|
|
2726
|
+
};
|
|
2727
|
+
} else if (page?.outcome === "not-found") seen = {
|
|
2728
|
+
state: "gone",
|
|
2729
|
+
sha: null,
|
|
2730
|
+
capture: null,
|
|
2731
|
+
fetched_at: page.fetched_at
|
|
2732
|
+
};
|
|
2733
|
+
let update;
|
|
2734
|
+
let suspected = false;
|
|
2735
|
+
let confirmed = false;
|
|
2736
|
+
if (seen === void 0) {
|
|
2737
|
+
kind = page !== void 0 && REFUSALS.has(page.outcome) ? "refused" : "error";
|
|
2738
|
+
if (ok) note = note ?? "the page gave no document, so the watch cannot compare it";
|
|
2739
|
+
update = { changed: false };
|
|
2740
|
+
} else if (base === void 0) {
|
|
2741
|
+
kind = seen.state === "gone" ? "gone" : "new";
|
|
2742
|
+
update = {
|
|
2743
|
+
changed: false,
|
|
2744
|
+
base: seen,
|
|
2745
|
+
pending: null
|
|
2746
|
+
};
|
|
2747
|
+
} else if (sameState(base, seen)) {
|
|
2748
|
+
kind = "unchanged";
|
|
2749
|
+
if (pending !== void 0) note = "the change that the last check saw did not repeat, so the watch takes it for a glitch";
|
|
2750
|
+
update = {
|
|
2751
|
+
changed: false,
|
|
2752
|
+
pending: null
|
|
2753
|
+
};
|
|
2754
|
+
} else {
|
|
2755
|
+
kind = seen.state === "gone" ? "gone" : base.state === "gone" ? "new" : "changed";
|
|
2756
|
+
const repeated = sameState(pending, seen) && (seen.state === "gone" || pending?.capture !== seen.capture);
|
|
2757
|
+
if (row.confirm === 1 || repeated) {
|
|
2758
|
+
confirmed = true;
|
|
2759
|
+
update = {
|
|
2760
|
+
changed: true,
|
|
2761
|
+
base: seen,
|
|
2762
|
+
pending: null
|
|
2763
|
+
};
|
|
2764
|
+
} else {
|
|
2765
|
+
suspected = true;
|
|
2766
|
+
note = pending === void 0 ? "the watch saw this change one time: the next check confirms it when it sees the same version" : "the watch saw a different change than the last check: the next check confirms it when it sees the same version";
|
|
2767
|
+
update = {
|
|
2768
|
+
changed: false,
|
|
2769
|
+
pending: {
|
|
2770
|
+
...seen,
|
|
2771
|
+
since: at
|
|
2772
|
+
}
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
let diff = null;
|
|
2777
|
+
if (kind === "changed" && base?.state === "present" && base.capture !== null && version !== void 0 && !held) {
|
|
2778
|
+
const docId = row.doc ?? version.doc;
|
|
2779
|
+
const before = runtime.index.versionText(docId, base.capture);
|
|
2780
|
+
if (before !== void 0 && !isHeldBack(before)) diff = diffTexts({
|
|
2781
|
+
text: before.text,
|
|
2782
|
+
fields: {
|
|
2783
|
+
title: before.title,
|
|
2784
|
+
meta: before.meta
|
|
2785
|
+
}
|
|
2786
|
+
}, {
|
|
2787
|
+
text: version.text,
|
|
2788
|
+
fields: {
|
|
2789
|
+
title: version.title,
|
|
2790
|
+
meta: version.meta
|
|
2791
|
+
}
|
|
2792
|
+
}).summary;
|
|
2793
|
+
}
|
|
2794
|
+
let next = at + row.every_ms;
|
|
2795
|
+
const stop = runtime.crawler.hostStop(row.target);
|
|
2796
|
+
if (stop !== void 0 && Number.isFinite(stop.until) && stop.until > next) next = stop.until;
|
|
2797
|
+
store.settle(row.url, owner, {
|
|
2798
|
+
...update,
|
|
2799
|
+
next_at: next,
|
|
2800
|
+
last_at: at,
|
|
2801
|
+
last_kind: kind,
|
|
2802
|
+
last_note: note,
|
|
2803
|
+
doc: page?.doc ?? null
|
|
2804
|
+
});
|
|
2805
|
+
const event = {
|
|
2806
|
+
url: row.target,
|
|
2807
|
+
doc: page?.doc ?? row.doc,
|
|
2808
|
+
kind,
|
|
2809
|
+
suspected,
|
|
2810
|
+
confirmed,
|
|
2811
|
+
capture: seen?.capture ?? null,
|
|
2812
|
+
fetched_at: seen?.fetched_at ?? null,
|
|
2813
|
+
previous: base?.capture ?? null,
|
|
2814
|
+
previous_fetched_at: base?.fetched_at ?? null,
|
|
2815
|
+
outcome: page?.outcome ?? "error",
|
|
2816
|
+
status: page?.status ?? 0,
|
|
2817
|
+
diff,
|
|
2818
|
+
held_back: held,
|
|
2819
|
+
next_at: iso(next),
|
|
2820
|
+
note
|
|
2821
|
+
};
|
|
2822
|
+
return {
|
|
2823
|
+
kind: "event",
|
|
2824
|
+
host: row.host,
|
|
2825
|
+
event
|
|
2826
|
+
};
|
|
2827
|
+
};
|
|
2828
|
+
try {
|
|
2829
|
+
const store = typeof source === "function" ? await source() : source;
|
|
2830
|
+
opened = store;
|
|
2831
|
+
await options.ready?.();
|
|
2832
|
+
budgetMs = options.budgetMs ?? WATCH_LIMITS.budgetMs;
|
|
2833
|
+
if (!(budgetMs >= 0)) throw new RangeError(`budgetMs must be 0 or more, not ${String(budgetMs)}`);
|
|
2834
|
+
maxPages = options.maxPages ?? WATCH_LIMITS.maxPages;
|
|
2835
|
+
if (!Number.isInteger(maxPages) || maxPages < 1) throw new RangeError(`maxPages must be an integer of 1 or more, not ${String(maxPages)}`);
|
|
2836
|
+
const deadline = startedAt + budgetMs;
|
|
2837
|
+
for (;;) {
|
|
2838
|
+
if (stopping === void 0 && abort.signal.aborted) stopping = "cancelled";
|
|
2839
|
+
if (stopping === void 0 && now() >= deadline) stopping = "budget";
|
|
2840
|
+
while (stopping === void 0 && inflight.size < WATCH_LIMITS.hostsAtOnce && started < maxPages) {
|
|
2841
|
+
const claimed = store.claim(now(), owner, Math.max(now(), deadline) + WATCH_LIMITS.leaseMs, [...inflight.keys()]);
|
|
2842
|
+
if (claimed === void 0) break;
|
|
2843
|
+
started += 1;
|
|
2844
|
+
inflight.set(claimed.host, check(store, claimed, deadline));
|
|
2845
|
+
}
|
|
2846
|
+
if (inflight.size === 0) break;
|
|
2847
|
+
const done = await Promise.race(inflight.values());
|
|
2848
|
+
inflight.delete(done.host);
|
|
2849
|
+
if (done.kind === "deferred") {
|
|
2850
|
+
started -= 1;
|
|
2851
|
+
stopping ??= done.stop;
|
|
2852
|
+
continue;
|
|
2853
|
+
}
|
|
2854
|
+
const event = done.event;
|
|
2855
|
+
counts.checked += 1;
|
|
2856
|
+
counts[event.kind] += 1;
|
|
2857
|
+
if (event.suspected) counts.suspected += 1;
|
|
2858
|
+
if (event.confirmed) counts.confirmed += 1;
|
|
2859
|
+
yield event;
|
|
2860
|
+
}
|
|
2861
|
+
stopping ??= started >= maxPages && store.due(now()) > 0 ? "max-pages" : "done";
|
|
2862
|
+
finished = true;
|
|
2863
|
+
} catch (error) {
|
|
2864
|
+
stopping = "error";
|
|
2865
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
2866
|
+
finished = true;
|
|
2867
|
+
throw error;
|
|
2868
|
+
} finally {
|
|
2869
|
+
if (!finished) {
|
|
2870
|
+
stopping ??= "cancelled";
|
|
2871
|
+
reason ??= "the caller stopped the loop";
|
|
2872
|
+
}
|
|
2873
|
+
outer?.removeEventListener("abort", cancel);
|
|
2874
|
+
abort.abort(/* @__PURE__ */ new Error("the watch run ended"));
|
|
2875
|
+
await Promise.allSettled(inflight.values());
|
|
2876
|
+
const stop = stopping ?? "done";
|
|
2877
|
+
reason ??= stop === "done" ? "the run checked each entry that was due" : stop === "max-pages" ? `the run checked ${String(maxPages)} entries, its limit (maxPages)` : stop === "budget" ? `the budget of ${String(budgetMs)} ms ended (budgetMs)` : stop === "cancelled" ? "the caller cancelled the run" : "the crawler closed";
|
|
2878
|
+
let due = 0;
|
|
2879
|
+
try {
|
|
2880
|
+
due = opened?.due(now()) ?? 0;
|
|
2881
|
+
} catch {}
|
|
2882
|
+
resolveSummary({
|
|
2883
|
+
...counts,
|
|
2884
|
+
due,
|
|
2885
|
+
stopped: stop,
|
|
2886
|
+
reason,
|
|
2887
|
+
elapsed_ms: Math.max(0, now() - startedAt)
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
return {
|
|
2892
|
+
summary,
|
|
2893
|
+
[Symbol.asyncIterator]() {
|
|
2894
|
+
if (used) throw new Error("a watch run operates one time: call run again for a new run");
|
|
2895
|
+
used = true;
|
|
2896
|
+
return events();
|
|
2897
|
+
}
|
|
2898
|
+
};
|
|
2899
|
+
};
|
|
2900
|
+
//#endregion
|
|
2901
|
+
//#region src/open.ts
|
|
2902
|
+
var open_exports = /* @__PURE__ */ __exportAll({
|
|
2903
|
+
BOT_PAGE: () => BOT_PAGE,
|
|
2904
|
+
DEFAULT_TOKEN: () => DEFAULT_TOKEN,
|
|
2905
|
+
createRuntime: () => createRuntime,
|
|
2906
|
+
defaultUserAgent: () => defaultUserAgent,
|
|
2907
|
+
isMailbox: () => isMailbox,
|
|
2908
|
+
openCrawler: () => openCrawler
|
|
2909
|
+
});
|
|
2910
|
+
/**
|
|
2911
|
+
* This function gives the default `User-Agent` header. The header names the crawler, its version
|
|
2912
|
+
* and the page about it, so a site owner can find out what made each request.
|
|
2913
|
+
*/
|
|
2914
|
+
const defaultUserAgent = (contact) => `${DEFAULT_TOKEN}/${VERSION} (+${BOT_PAGE}${contact === void 0 ? "" : `; ${contact}`})`;
|
|
2915
|
+
/** A mailbox is `name@host`, or a name and `<name@host>`. A URL is not a mailbox, also when it holds an `@`. */
|
|
2916
|
+
const MAILBOX = /^(?:[^\s<>@:/]+@[^\s<>@/]+|[^<>]*<[^\s<>@:/]+@[^\s<>@/]+>)$/;
|
|
2917
|
+
/**
|
|
2918
|
+
* This function tells if a contact is a mailbox. The `From` header holds only a mailbox (RFC 9110,
|
|
2919
|
+
* section 10.1.2). Thus the crawler sends a mailbox in `From` and in the `User-Agent`, and a URL in
|
|
2920
|
+
* the `User-Agent` only.
|
|
2921
|
+
*/
|
|
2922
|
+
const isMailbox = (contact) => MAILBOX.test(contact.trim());
|
|
2923
|
+
/** The parts of a store that can be a symbolic link to a different place only by mistake or by attack. */
|
|
2924
|
+
const STORE_PARTS = [
|
|
2925
|
+
"captures",
|
|
2926
|
+
"blobs",
|
|
2927
|
+
"index.sqlite",
|
|
2928
|
+
"hosts.sqlite",
|
|
2929
|
+
"watch.sqlite"
|
|
2930
|
+
];
|
|
2931
|
+
/**
|
|
2932
|
+
* This function makes the folder of a store for the owner only, where the system has modes. It
|
|
2933
|
+
* refuses a store folder of a different user, and a part of the store that is a symbolic link.
|
|
2934
|
+
* The store folder itself can be a link, for example to a larger disk.
|
|
2935
|
+
*/
|
|
2936
|
+
const guardStore = (dir) => {
|
|
2937
|
+
mkdirSync(dir, {
|
|
2938
|
+
recursive: true,
|
|
2939
|
+
mode: 448
|
|
2940
|
+
});
|
|
2941
|
+
const real = lstatSync(dir).isSymbolicLink() ? realpathSync(dir) : dir;
|
|
2942
|
+
const info = statSync(real);
|
|
2943
|
+
if (!info.isDirectory()) throw new Error(`the store ${dir} is not a folder`);
|
|
2944
|
+
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`);
|
|
2945
|
+
for (const part of STORE_PARTS) {
|
|
2946
|
+
let link = false;
|
|
2947
|
+
try {
|
|
2948
|
+
link = lstatSync(join(dir, part)).isSymbolicLink();
|
|
2949
|
+
} catch {}
|
|
2950
|
+
if (link) throw new Error(`${part} in the store ${dir} is a symbolic link: the crawler does not follow it`);
|
|
2951
|
+
}
|
|
2952
|
+
};
|
|
2953
|
+
/**
|
|
2954
|
+
* This function makes a runtime. It is the one place that puts the log, the blob store, the index,
|
|
2955
|
+
* the host state, the crawler and the extractors together. The function `openCrawler` uses it.
|
|
2956
|
+
* An application that needs the parts uses it too. The runtime and the crawler share one clock.
|
|
2957
|
+
*/
|
|
2958
|
+
const createRuntime = (options) => {
|
|
2959
|
+
const dir = options.dir;
|
|
2960
|
+
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");
|
|
2961
|
+
const contact = options.contact;
|
|
2962
|
+
if (contact !== void 0 && !/^[\t -~]*$/.test(contact)) throw new Error(`contact "${contact}" has a character that an HTTP header cannot hold: use printable ASCII`);
|
|
2963
|
+
if (dir !== void 0) guardStore(dir);
|
|
2964
|
+
const owned = [];
|
|
2965
|
+
const log = options.log ?? new CaptureLog(join(dir, "captures"));
|
|
2966
|
+
const blobs = options.blobs ?? new FileBlobStore(join(dir, "blobs"));
|
|
2967
|
+
let index = options.index;
|
|
2968
|
+
if (index === void 0) {
|
|
2969
|
+
const made = new DocIndex(join(dir, "index.sqlite"));
|
|
2970
|
+
owned.push(() => made.close());
|
|
2971
|
+
index = made;
|
|
2972
|
+
}
|
|
2973
|
+
let hostState = options.hostState;
|
|
2974
|
+
if (hostState === void 0) {
|
|
2975
|
+
if (dir === void 0) hostState = new MemoryHostStateStore();
|
|
2976
|
+
else {
|
|
2977
|
+
const made = new SqliteHostStateStore(join(dir, "hosts.sqlite"));
|
|
2978
|
+
owned.push(() => made.close());
|
|
2979
|
+
hostState = made;
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
const clock = options.clock ?? realClock;
|
|
2983
|
+
const renderer = options.renderer === false ? void 0 : options.renderer ?? checkedRenderer(playwrightRenderer());
|
|
2984
|
+
const crawler = new Crawler({
|
|
2985
|
+
token: options.token ?? "PoliteCrawler",
|
|
2986
|
+
userAgent: options.userAgent ?? defaultUserAgent(contact),
|
|
2987
|
+
from: contact !== void 0 && isMailbox(contact) ? contact.trim() : void 0,
|
|
2988
|
+
log,
|
|
2989
|
+
blobs,
|
|
2990
|
+
index,
|
|
2991
|
+
renderer,
|
|
2992
|
+
maxActive: options.maxActive,
|
|
2993
|
+
maxRenders: options.maxRenders,
|
|
2994
|
+
dispatcher: options.dispatcher,
|
|
2995
|
+
egress: options.egress,
|
|
2996
|
+
politeness: options.politeness,
|
|
2997
|
+
hostState,
|
|
2998
|
+
clock,
|
|
2999
|
+
random: options.random,
|
|
3000
|
+
scope: options.scope,
|
|
3001
|
+
scopeRank: options.scopeRank,
|
|
3002
|
+
obeyDefaultToken: options.obeyDefaultToken,
|
|
3003
|
+
onEvent: options.onEvent,
|
|
3004
|
+
signer: options.signer
|
|
3005
|
+
});
|
|
3006
|
+
const extractors = new Extractors();
|
|
3007
|
+
for (const [recipe, extractor] of Object.entries(options.extractors ?? {})) extractors.register(recipe, extractor);
|
|
3008
|
+
let closing;
|
|
3009
|
+
return {
|
|
3010
|
+
crawler,
|
|
3011
|
+
index,
|
|
3012
|
+
blobs,
|
|
3013
|
+
captures: log,
|
|
3014
|
+
log,
|
|
3015
|
+
extractors,
|
|
3016
|
+
hostState,
|
|
3017
|
+
now: () => new Date(clock.now()).toISOString(),
|
|
3018
|
+
close() {
|
|
3019
|
+
closing ??= (async () => {
|
|
3020
|
+
await crawler.close();
|
|
3021
|
+
for (const close of owned) await close();
|
|
3022
|
+
})();
|
|
3023
|
+
return closing;
|
|
3024
|
+
}
|
|
3025
|
+
};
|
|
3026
|
+
};
|
|
3027
|
+
/**
|
|
3028
|
+
* This function opens a crawler on a folder. The folder keeps the capture log (`captures/`), the
|
|
3029
|
+
* response bodies (`blobs/`), the search index (`index.sqlite`) and the state of each host
|
|
3030
|
+
* (`hosts.sqlite`). The index is a projection: `reindex` makes it again from the log and the bodies.
|
|
3031
|
+
* At open, `catchUpIndex` rebuilds an index of an earlier version, and gives an index that is
|
|
3032
|
+
* behind the log the captures that it missed.
|
|
3033
|
+
*
|
|
3034
|
+
* Each method gives a promise, and none throws before its promise. After `close`, each method
|
|
3035
|
+
* rejects.
|
|
3036
|
+
*/
|
|
3037
|
+
const openCrawler = (options) => {
|
|
3038
|
+
const floorMs = options.watchFloor === void 0 ? WATCH_LIMITS.floorMs : durationMs(options.watchFloor);
|
|
3039
|
+
const runtime = createRuntime(options);
|
|
3040
|
+
let closed = false;
|
|
3041
|
+
let rebuilt;
|
|
3042
|
+
const ready = existsSync(join(options.dir, "captures")) ? catchUpIndex(runtime).then((done) => {
|
|
3043
|
+
if (done?.mode === "rebuild") {
|
|
3044
|
+
const { mode, ...counts } = done;
|
|
3045
|
+
rebuilt = mode === "rebuild" ? counts : void 0;
|
|
3046
|
+
}
|
|
3047
|
+
}, () => void 0) : Promise.resolve();
|
|
3048
|
+
const live = async () => {
|
|
3049
|
+
if (closed) throw new Error("the crawler is closed");
|
|
3050
|
+
await ready;
|
|
3051
|
+
if (closed) throw new Error("the crawler is closed");
|
|
3052
|
+
};
|
|
3053
|
+
let watches;
|
|
3054
|
+
const watchStore = async () => {
|
|
3055
|
+
await live();
|
|
3056
|
+
watches ??= new WatchStore(join(options.dir, "watch.sqlite"), { floorMs });
|
|
3057
|
+
return watches;
|
|
3058
|
+
};
|
|
3059
|
+
const clockNow = () => Date.parse(runtime.now());
|
|
3060
|
+
return {
|
|
3061
|
+
runtime,
|
|
3062
|
+
async fetch(url, fetchOptions = {}) {
|
|
3063
|
+
await live();
|
|
3064
|
+
rebuilt = void 0;
|
|
3065
|
+
const maxAge = fetchOptions.maxAge ?? "24h";
|
|
3066
|
+
return fetchPage(runtime, url, {
|
|
3067
|
+
render: fetchOptions.render ?? "auto",
|
|
3068
|
+
maxAgeMs: maxAge === "auto" ? "auto" : durationMs(maxAge),
|
|
3069
|
+
onBehalfOf: [...options.onBehalfOf ?? [], ...fetchOptions.onBehalfOf ?? []],
|
|
3070
|
+
signal: fetchOptions.signal,
|
|
3071
|
+
scope: fetchOptions.scope,
|
|
3072
|
+
labels: fetchOptions.labels,
|
|
3073
|
+
fallback: fetchOptions.fallback,
|
|
3074
|
+
archiveDespiteRobots: fetchOptions.archiveDespiteRobots
|
|
3075
|
+
});
|
|
3076
|
+
},
|
|
3077
|
+
async find(q, findOptions = {}) {
|
|
3078
|
+
await live();
|
|
3079
|
+
return findDocuments(runtime, q, findOptions);
|
|
3080
|
+
},
|
|
3081
|
+
async read(id, readOptions = {}) {
|
|
3082
|
+
await live();
|
|
3083
|
+
return readDocument(runtime, id, readOptions);
|
|
3084
|
+
},
|
|
3085
|
+
async verify(id, quote, verifyOptions = {}) {
|
|
3086
|
+
await live();
|
|
3087
|
+
return verifyQuote(runtime, id, quote, verifyOptions);
|
|
3088
|
+
},
|
|
3089
|
+
async extract(id, fields, extractOptions = {}) {
|
|
3090
|
+
await live();
|
|
3091
|
+
return extractDocument(runtime, id, fields, extractOptions);
|
|
3092
|
+
},
|
|
3093
|
+
async versions(id, versionOptions = {}) {
|
|
3094
|
+
await live();
|
|
3095
|
+
const held = runtime.index.get(id)?.ai_input === "no";
|
|
3096
|
+
return runtime.index.versions(id, versionOptions).map((version) => held || version.ai_input === "no" ? {
|
|
3097
|
+
...version,
|
|
3098
|
+
title: ""
|
|
3099
|
+
} : version);
|
|
3100
|
+
},
|
|
3101
|
+
async discover(site, discoverOptions = {}) {
|
|
3102
|
+
await live();
|
|
3103
|
+
rebuilt = void 0;
|
|
3104
|
+
return discoverSite(runtime, site, {
|
|
3105
|
+
...discoverOptions,
|
|
3106
|
+
onBehalfOf: [...options.onBehalfOf ?? [], ...discoverOptions.onBehalfOf ?? []]
|
|
3107
|
+
});
|
|
3108
|
+
},
|
|
3109
|
+
crawl(seeds, crawlOptions = {}) {
|
|
3110
|
+
return crawlSite(runtime, seeds, {
|
|
3111
|
+
...crawlOptions,
|
|
3112
|
+
onBehalfOf: [...options.onBehalfOf ?? [], ...crawlOptions.onBehalfOf ?? []],
|
|
3113
|
+
ready: async () => {
|
|
3114
|
+
await live();
|
|
3115
|
+
rebuilt = void 0;
|
|
3116
|
+
}
|
|
3117
|
+
});
|
|
3118
|
+
},
|
|
3119
|
+
async reindex() {
|
|
3120
|
+
await live();
|
|
3121
|
+
const done = rebuilt;
|
|
3122
|
+
rebuilt = void 0;
|
|
3123
|
+
return done ?? reindex(runtime);
|
|
3124
|
+
},
|
|
3125
|
+
async diff(id, diffOptions = {}) {
|
|
3126
|
+
await live();
|
|
3127
|
+
return diffDocument(runtime, id, diffOptions);
|
|
3128
|
+
},
|
|
3129
|
+
watch: {
|
|
3130
|
+
async add(url, watchOptions = {}) {
|
|
3131
|
+
return (await watchStore()).add(url, watchOptions, clockNow());
|
|
3132
|
+
},
|
|
3133
|
+
async remove(url) {
|
|
3134
|
+
return (await watchStore()).remove(url);
|
|
3135
|
+
},
|
|
3136
|
+
async list() {
|
|
3137
|
+
return (await watchStore()).list(clockNow());
|
|
3138
|
+
},
|
|
3139
|
+
run(runOptions = {}) {
|
|
3140
|
+
return runWatch(runtime, watchStore, {
|
|
3141
|
+
...runOptions,
|
|
3142
|
+
onBehalfOf: options.onBehalfOf,
|
|
3143
|
+
ready: () => {
|
|
3144
|
+
rebuilt = void 0;
|
|
3145
|
+
return Promise.resolve();
|
|
3146
|
+
}
|
|
3147
|
+
});
|
|
3148
|
+
}
|
|
3149
|
+
},
|
|
3150
|
+
async close() {
|
|
3151
|
+
closed = true;
|
|
3152
|
+
await ready;
|
|
3153
|
+
await runtime.close();
|
|
3154
|
+
watches?.close();
|
|
3155
|
+
}
|
|
3156
|
+
};
|
|
3157
|
+
};
|
|
3158
|
+
//#endregion
|
|
3159
|
+
export { readSitemap as _, open_exports as a, WatchStore as c, CRAWL_LIMITS as d, crawlSite as f, feedDate as g, SITEMAP_LIMITS as h, openCrawler as i, runWatch as l, discoverSite as m, defaultUserAgent as n, WATCH_LIMITS as o, DISCOVER_LIMITS as p, isMailbox as r, WATCH_SCHEMA as s, createRuntime as t, SqliteHostStateStore as u, DocIndex as v, FAST_RANK_SHARE as y };
|
|
3160
|
+
|
|
3161
|
+
//# sourceMappingURL=open-DnrWgkC0.mjs.map
|