polite-crawler 0.1.0 → 0.1.1
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 +45 -3
- package/build/cli.mjs +10 -3
- package/build/cli.mjs.map +1 -1
- package/build/index.d.mts +15 -5
- package/build/index.mjs +4 -4
- package/build/mcp.d.mts +1 -1
- package/build/mcp.mjs +7 -5
- package/build/mcp.mjs.map +1 -1
- package/build/{open-CJR4b44v.mjs → open-DYZ7KL2A.mjs} +130 -23
- package/build/open-DYZ7KL2A.mjs.map +1 -0
- package/build/{open-CZqwY7GH.d.mts → open-eHcriTds.d.mts} +53 -15
- package/build/{politeness-C5bZ1OKM.d.mts → politeness-yI3xBEx9.d.mts} +95 -2
- package/build/politeness.d.mts +2 -2
- package/build/politeness.mjs +102 -1
- package/build/politeness.mjs.map +1 -1
- package/build/{version-rVx8_y1_.mjs → version-no12qgos.mjs} +2 -2
- package/build/{version-rVx8_y1_.mjs.map → version-no12qgos.mjs.map} +1 -1
- package/package.json +1 -1
- package/record.schema.json +5 -0
- package/build/open-CJR4b44v.mjs.map +0 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { a as RenderRefused, o as playwrightRenderer, r as checkedRenderer, s as __exportAll } from "./browser-gJ_h4AhB.mjs";
|
|
2
2
|
import { classify, isClientError, parseHttpDate } from "./classify.mjs";
|
|
3
|
-
import { DEFAULT_POLICY, HostScheduler, HostStopped, MAX_STOP_MS, MemoryHostStateStore, checkPolicy, equalJitterMs, policyFor, realClock, restAfter } from "./politeness.mjs";
|
|
3
|
+
import { DEFAULT_POLICY, HostScheduler, HostStopped, MAX_STOP_MS, MemoryHostStateStore, checkPolicy, equalJitterMs, nextValidatorMemo, policyFor, realClock, restAfter, validatorsToSend } from "./politeness.mjs";
|
|
4
4
|
import { decide, isProductToken, robotsFromResponse, robotsFromStatus, signalsFor, unreachableRobots } from "./robots.mjs";
|
|
5
5
|
import { NO_SIGNALS, metaSignals, readSignals } from "./signals.mjs";
|
|
6
|
-
import { n as DEFAULT_TOKEN, r as VERSION, t as BOT_PAGE } from "./version-
|
|
6
|
+
import { n as DEFAULT_TOKEN, r as VERSION, t as BOT_PAGE } from "./version-no12qgos.mjs";
|
|
7
7
|
import { c as canonicalUrl, o as decodeBody, r as typesOf, s as cacheKeyOf, t as extractPage } from "./html-oNpGAHwi.mjs";
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
@@ -1069,6 +1069,14 @@ var Crawler = class {
|
|
|
1069
1069
|
work.then(() => this.running.delete(work), () => this.running.delete(work));
|
|
1070
1070
|
return work;
|
|
1071
1071
|
}
|
|
1072
|
+
/** This method gives the validator memo of a host. The memo is a hint, so an error of its store gives no memo. */
|
|
1073
|
+
memoOf(host) {
|
|
1074
|
+
try {
|
|
1075
|
+
return this.scheduler.store.validators?.(host);
|
|
1076
|
+
} catch {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1072
1080
|
stopHost(host, until, reason) {
|
|
1073
1081
|
this.scheduler.store.stop(host, until, reason);
|
|
1074
1082
|
this.emit({
|
|
@@ -1636,8 +1644,16 @@ var Crawler = class {
|
|
|
1636
1644
|
let stored;
|
|
1637
1645
|
if (usable?.blob && (usable.etag || usable.last_modified)) stored = await this.options.blobs.get(usable.blob);
|
|
1638
1646
|
const headers = { ...sent };
|
|
1639
|
-
|
|
1640
|
-
|
|
1647
|
+
const send = stored === void 0 || usable === void 0 ? {
|
|
1648
|
+
etag: false,
|
|
1649
|
+
lastModified: false
|
|
1650
|
+
} : validatorsToSend(robotsFile ? void 0 : this.memoOf(host), {
|
|
1651
|
+
etag: Boolean(usable.etag),
|
|
1652
|
+
lastModified: Boolean(usable.last_modified)
|
|
1653
|
+
}, this.clock.now());
|
|
1654
|
+
if (send.etag && usable?.etag) headers["if-none-match"] = usable.etag;
|
|
1655
|
+
if (send.lastModified && usable?.last_modified) headers["if-modified-since"] = usable.last_modified;
|
|
1656
|
+
const validators = ["if-none-match", "if-modified-since"].filter((name) => headers[name] !== void 0);
|
|
1641
1657
|
const started = this.clock.now();
|
|
1642
1658
|
const timeout = AbortSignal.timeout(policy.requestTimeoutMs);
|
|
1643
1659
|
const abort = AbortSignal.any([timeout, signal]);
|
|
@@ -1824,6 +1840,7 @@ var Crawler = class {
|
|
|
1824
1840
|
blob: blob ?? null,
|
|
1825
1841
|
etag,
|
|
1826
1842
|
last_modified: lastModified,
|
|
1843
|
+
...validators.length > 0 ? { validators } : {},
|
|
1827
1844
|
...revisit ? { revisit_of: usable.capture } : {},
|
|
1828
1845
|
robots: request_.mode === "page" ? "allowed" : "not-checked",
|
|
1829
1846
|
...check !== void 0 ? { robots_check: check } : {},
|
|
@@ -1866,6 +1883,14 @@ var Crawler = class {
|
|
|
1866
1883
|
fresh_until: freshness,
|
|
1867
1884
|
vary_key: varyKey
|
|
1868
1885
|
}, next);
|
|
1886
|
+
const answer = validators.length === 0 || capture === void 0 || robotsFile ? void 0 : outcome === "not-modified" ? "not-modified" : outcome === "ok" && blob !== void 0 && !cut ? blob === usable?.blob ? "same" : "changed" : void 0;
|
|
1887
|
+
if (answer !== void 0) try {
|
|
1888
|
+
this.scheduler.store.noteValidators?.(host, {
|
|
1889
|
+
etag: headers["if-none-match"] !== void 0,
|
|
1890
|
+
lastModified: headers["if-modified-since"] !== void 0,
|
|
1891
|
+
answer
|
|
1892
|
+
}, fetchedAt);
|
|
1893
|
+
} catch {}
|
|
1869
1894
|
const delayMs = this.settle(host, policy, {
|
|
1870
1895
|
outcome,
|
|
1871
1896
|
status: response.statusCode,
|
|
@@ -2593,6 +2618,7 @@ const parseQuery = (q) => {
|
|
|
2593
2618
|
return {
|
|
2594
2619
|
all: parts.map((part) => part.text).join(" AND "),
|
|
2595
2620
|
any: any.map((part) => part.text).join(" OR "),
|
|
2621
|
+
anyParts: any.map((part) => part.text),
|
|
2596
2622
|
terms: any.map((part) => part.term),
|
|
2597
2623
|
parts: parts.length
|
|
2598
2624
|
};
|
|
@@ -2637,6 +2663,14 @@ const snippetOf = (text, terms, width = 160) => {
|
|
|
2637
2663
|
};
|
|
2638
2664
|
/** The search index reads this many characters of each text, at most. */
|
|
2639
2665
|
const MAX_INDEX_CHARS = 2e5;
|
|
2666
|
+
/** In the mode `fast`, a part is common when more than this share of the documents hold it. */
|
|
2667
|
+
const FAST_RANK_SHARE = .2;
|
|
2668
|
+
/** This function gives the rank mode of an option. A value that is not a rank mode throws a `RangeError`. */
|
|
2669
|
+
const rankModeOf = (value) => {
|
|
2670
|
+
if (value === void 0 || value === "exact") return "exact";
|
|
2671
|
+
if (value === "fast") return "fast";
|
|
2672
|
+
throw new RangeError(`rank must be "exact" or "fast", not ${JSON.stringify(value) ?? "undefined"}`);
|
|
2673
|
+
};
|
|
2640
2674
|
const namesOf = (generation) => ({
|
|
2641
2675
|
docs: `docs_${String(generation)}`,
|
|
2642
2676
|
fts: `fts_${String(generation)}`,
|
|
@@ -3121,9 +3155,10 @@ var DocIndex = class {
|
|
|
3121
3155
|
* This method searches the documents with BM25, the best first. The syntax of the query is the
|
|
3122
3156
|
* syntax of `parseQuery`. It first asks for each part. When that gives too few documents, it
|
|
3123
3157
|
* adds the documents that match one part. A document that asked not to be AI input is not in
|
|
3124
|
-
* the results, so a search cannot show its text.
|
|
3158
|
+
* the results, so a search cannot show its text. The option `rank` chooses the ranking (`RankMode`).
|
|
3125
3159
|
*/
|
|
3126
3160
|
find(q, options) {
|
|
3161
|
+
const rank = rankModeOf(options.rank);
|
|
3127
3162
|
const query = parseQuery(q);
|
|
3128
3163
|
if (query === void 0) return [];
|
|
3129
3164
|
const limit = Math.max(1, Math.min(1e3, Math.floor(options.limit) || 10));
|
|
@@ -3142,7 +3177,7 @@ var DocIndex = class {
|
|
|
3142
3177
|
const rows = run(query.all);
|
|
3143
3178
|
if (rows.length < limit && query.parts > 1) {
|
|
3144
3179
|
const seen = new Set(rows.map((row) => row.doc));
|
|
3145
|
-
for (const row of run(query.any)) {
|
|
3180
|
+
for (const row of run(rank === "fast" ? this.rareParts(n, query.anyParts) : query.any)) {
|
|
3146
3181
|
if (rows.length >= limit) break;
|
|
3147
3182
|
if (!seen.has(row.doc)) rows.push(row);
|
|
3148
3183
|
}
|
|
@@ -3164,18 +3199,36 @@ var DocIndex = class {
|
|
|
3164
3199
|
* This method scores a set of documents against a query, with the best first. It leaves out a
|
|
3165
3200
|
* document that matches no term, and a document that asked not to be AI input. It ranks the
|
|
3166
3201
|
* results of a source again, because the order of the source is only as good as its search engine.
|
|
3202
|
+
* In the mode `fast`, it also leaves out a document that holds only common parts (`RankMode`).
|
|
3167
3203
|
*/
|
|
3168
|
-
rankWithin(q, docs) {
|
|
3204
|
+
rankWithin(q, docs, options = {}) {
|
|
3205
|
+
const rank = rankModeOf(options.rank);
|
|
3169
3206
|
const query = parseQuery(q);
|
|
3170
3207
|
if (query === void 0 || docs.length === 0) return [];
|
|
3171
3208
|
return this.live((n) => this.stmt(`SELECT d.doc AS doc, bm25(${n.fts}, 3.0, 1.0) AS rank
|
|
3172
3209
|
FROM ${n.fts} JOIN ${n.docs} d ON d.rowid = ${n.fts}.rowid
|
|
3173
3210
|
WHERE ${n.fts} MATCH ? AND d.ai_input != 'no' AND d.doc IN (SELECT value FROM json_each(?))
|
|
3174
|
-
ORDER BY rank`).all(query.any, JSON.stringify(docs)).map((row) => ({
|
|
3211
|
+
ORDER BY rank`).all(rank === "fast" ? this.rareParts(n, query.anyParts) : query.any, JSON.stringify(docs)).map((row) => ({
|
|
3175
3212
|
doc: row.doc,
|
|
3176
3213
|
score: Math.round(-row.rank * 1e3) / 1e3
|
|
3177
3214
|
})));
|
|
3178
3215
|
}
|
|
3216
|
+
/**
|
|
3217
|
+
* This method gives the form of a query that needs one part, without the common parts (the mode
|
|
3218
|
+
* `fast` of `RankMode`). It counts the documents that hold each part. When each part is common,
|
|
3219
|
+
* it keeps the rarest part.
|
|
3220
|
+
*/
|
|
3221
|
+
rareParts(n, parts) {
|
|
3222
|
+
if (parts.length < 2) return parts.join(" OR ");
|
|
3223
|
+
const total = this.stmt(`SELECT count(*) AS n FROM ${n.docs}`).get().n;
|
|
3224
|
+
const counted = parts.map((part) => ({
|
|
3225
|
+
part,
|
|
3226
|
+
count: this.stmt(`SELECT count(*) AS n FROM ${n.fts} WHERE ${n.fts} MATCH ?`).get(part).n
|
|
3227
|
+
}));
|
|
3228
|
+
const rare = counted.filter((one) => one.count <= FAST_RANK_SHARE * total);
|
|
3229
|
+
if (rare.length > 0) return rare.map((one) => one.part).join(" OR ");
|
|
3230
|
+
return counted.reduce((best, one) => one.count < best.count ? one : best).part;
|
|
3231
|
+
}
|
|
3179
3232
|
/** This method gives the cache entry of a URL. The key is canonical, so two spellings of one URL give one entry. */
|
|
3180
3233
|
latest(url, variant) {
|
|
3181
3234
|
const key = keyOf(url, variant);
|
|
@@ -3353,6 +3406,16 @@ const NO_END = 9e15;
|
|
|
3353
3406
|
const SHARED_WAIT_MS = 1e3;
|
|
3354
3407
|
/** The store removes the rows of idle hosts after this many reservations. */
|
|
3355
3408
|
const SWEEP_EVERY = 1e3;
|
|
3409
|
+
const verdictOf = (value) => value === "works" || value === "fails" ? value : "unknown";
|
|
3410
|
+
const memoOf = (row) => ({
|
|
3411
|
+
skipEtag: row.skip_etag === 1,
|
|
3412
|
+
etag: verdictOf(row.etag),
|
|
3413
|
+
lastModified: verdictOf(row.last_modified),
|
|
3414
|
+
etagMisses: row.etag_misses,
|
|
3415
|
+
dateMisses: row.date_misses,
|
|
3416
|
+
sinceProbe: row.since_probe,
|
|
3417
|
+
changedAt: row.changed_at
|
|
3418
|
+
});
|
|
3356
3419
|
/**
|
|
3357
3420
|
* This store keeps the state of each host in a SQLite file. Two or more processes on one store
|
|
3358
3421
|
* share it, so they share the pace and the stops of each host. Thus a host gets one request at a
|
|
@@ -3363,8 +3426,11 @@ const SWEEP_EVERY = 1e3;
|
|
|
3363
3426
|
* the lease gets a wait of one second or less, and asks again. A process that stops during a
|
|
3364
3427
|
* request holds its lease until the lease ends (5 minutes by default).
|
|
3365
3428
|
*
|
|
3429
|
+
* The table `validators` keeps the validator memo of each host (`ValidatorMemo`), so the processes
|
|
3430
|
+
* share it and it stays after a restart. A file of version 0.1.0 gets the table when it opens.
|
|
3431
|
+
*
|
|
3366
3432
|
* The state is not a projection of the capture log. A deletion of the file loses only the pace,
|
|
3367
|
-
* the counts and the
|
|
3433
|
+
* the counts, the stops and the memos, and the crawler starts them again.
|
|
3368
3434
|
*/
|
|
3369
3435
|
var SqliteHostStateStore = class {
|
|
3370
3436
|
path;
|
|
@@ -3387,16 +3453,37 @@ var SqliteHostStateStore = class {
|
|
|
3387
3453
|
this.db.exec(`PRAGMA busy_timeout = ${String(wait)};`);
|
|
3388
3454
|
if (path !== ":memory:") this.db.exec("PRAGMA journal_mode = WAL;");
|
|
3389
3455
|
this.db.exec("PRAGMA synchronous = NORMAL;");
|
|
3390
|
-
this.
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3456
|
+
this.migrate();
|
|
3457
|
+
}
|
|
3458
|
+
/**
|
|
3459
|
+
* This method makes the tables, or adds the tables of a later version to an older file. It is one
|
|
3460
|
+
* transaction, so two processes that open one old file at the same time do not collide.
|
|
3461
|
+
*/
|
|
3462
|
+
migrate() {
|
|
3463
|
+
this.step(() => {
|
|
3464
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS hosts (
|
|
3465
|
+
host TEXT PRIMARY KEY,
|
|
3466
|
+
next_at REAL NOT NULL DEFAULT 0,
|
|
3467
|
+
leased_until REAL NOT NULL DEFAULT 0,
|
|
3468
|
+
owner TEXT,
|
|
3469
|
+
failures INTEGER NOT NULL DEFAULT 0,
|
|
3470
|
+
waits INTEGER NOT NULL DEFAULT 0,
|
|
3471
|
+
stop_until REAL,
|
|
3472
|
+
stop_reason TEXT
|
|
3473
|
+
)`);
|
|
3474
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS validators (
|
|
3475
|
+
host TEXT PRIMARY KEY,
|
|
3476
|
+
skip_etag INTEGER NOT NULL DEFAULT 0,
|
|
3477
|
+
etag TEXT NOT NULL DEFAULT 'unknown',
|
|
3478
|
+
last_modified TEXT NOT NULL DEFAULT 'unknown',
|
|
3479
|
+
etag_misses INTEGER NOT NULL DEFAULT 0,
|
|
3480
|
+
date_misses INTEGER NOT NULL DEFAULT 0,
|
|
3481
|
+
since_probe INTEGER NOT NULL DEFAULT 0,
|
|
3482
|
+
changed_at REAL NOT NULL DEFAULT 0
|
|
3483
|
+
)`);
|
|
3484
|
+
const { user_version: version } = this.db.prepare("PRAGMA user_version").get();
|
|
3485
|
+
if (version < 1) this.db.exec(`PRAGMA user_version = ${String(1)}`);
|
|
3486
|
+
});
|
|
3400
3487
|
}
|
|
3401
3488
|
stmt(sql) {
|
|
3402
3489
|
let statement = this.statements.get(sql);
|
|
@@ -3493,6 +3580,24 @@ var SqliteHostStateStore = class {
|
|
|
3493
3580
|
else this.stmt("UPDATE hosts SET failures = 0, waits = 0, stop_until = NULL, stop_reason = NULL WHERE host = ?").run(host);
|
|
3494
3581
|
});
|
|
3495
3582
|
}
|
|
3583
|
+
validators(host) {
|
|
3584
|
+
if (this.closed) throw new Error("the host state store is closed");
|
|
3585
|
+
const row = this.stmt(`SELECT skip_etag, etag, last_modified, etag_misses, date_misses, since_probe, changed_at
|
|
3586
|
+
FROM validators WHERE host = ?`).get(host);
|
|
3587
|
+
return row === void 0 ? void 0 : memoOf(row);
|
|
3588
|
+
}
|
|
3589
|
+
noteValidators(host, report, now) {
|
|
3590
|
+
return this.step(() => {
|
|
3591
|
+
const memo = nextValidatorMemo(this.validators(host), report, now);
|
|
3592
|
+
this.stmt(`INSERT INTO validators (host, skip_etag, etag, last_modified, etag_misses, date_misses, since_probe, changed_at)
|
|
3593
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3594
|
+
ON CONFLICT(host) DO UPDATE SET skip_etag = excluded.skip_etag, etag = excluded.etag,
|
|
3595
|
+
last_modified = excluded.last_modified, etag_misses = excluded.etag_misses,
|
|
3596
|
+
date_misses = excluded.date_misses, since_probe = excluded.since_probe,
|
|
3597
|
+
changed_at = excluded.changed_at`).run(host, memo.skipEtag ? 1 : 0, memo.etag, memo.lastModified, memo.etagMisses, memo.dateMisses, memo.sinceProbe, memo.changedAt);
|
|
3598
|
+
return memo;
|
|
3599
|
+
});
|
|
3600
|
+
}
|
|
3496
3601
|
/** This value is the number of hosts that the store holds. */
|
|
3497
3602
|
get size() {
|
|
3498
3603
|
return this.stmt("SELECT count(*) AS n FROM hosts").get().n;
|
|
@@ -4434,13 +4539,15 @@ const verifyQuote = async (runtime, id, quote, options = {}) => {
|
|
|
4434
4539
|
};
|
|
4435
4540
|
/**
|
|
4436
4541
|
* This function searches the stored documents. The syntax of the query is the syntax of
|
|
4437
|
-
* `parseQuery`. A held-back document is not in the results.
|
|
4542
|
+
* `parseQuery`. A held-back document is not in the results. The option `rank` chooses the
|
|
4543
|
+
* ranking (`RankMode`), and the default is `exact`.
|
|
4438
4544
|
*/
|
|
4439
4545
|
const findDocuments = async (runtime, q, options = {}) => {
|
|
4440
4546
|
await Promise.resolve();
|
|
4441
4547
|
return runtime.index.find(q, {
|
|
4442
4548
|
limit: options.limit ?? 10,
|
|
4443
|
-
kind: options.kind ?? null
|
|
4549
|
+
kind: options.kind ?? null,
|
|
4550
|
+
...options.rank !== void 0 ? { rank: options.rank } : {}
|
|
4444
4551
|
});
|
|
4445
4552
|
};
|
|
4446
4553
|
const RENDERED_MARK = "#rendered";
|
|
@@ -4883,6 +4990,6 @@ const openCrawler = (options) => {
|
|
|
4883
4990
|
};
|
|
4884
4991
|
};
|
|
4885
4992
|
//#endregion
|
|
4886
|
-
export {
|
|
4993
|
+
export { Crawler as A, readPdf as C, FAST_RANK_SHARE as D, DocIndex as E, egressLookup as F, FileBlobStore as I, renderedKey as M, makeRecord as N, locate as O, EgressRefused as P, isPdf as S, SqliteHostStateStore as T, readDocument as _, openCrawler as a, renderAndIndex as b, HELD_BACK as c, fetchPage as d, findDocuments as f, pageExtractor as g, pageDocId as h, isMailbox as i, DEFAULT_SECRET_PARAMS as j, CAPTURE_SCHEMA as k, catchUpIndex as l, isHeldBack as m, defaultUserAgent as n, open_exports as o, heldBackMeta as p, durationMs as r, Extractors as s, createRuntime as t, fetchAndIndex as u, recordOverride as v, CaptureLog as w, verifyQuote as x, reindex as y };
|
|
4887
4994
|
|
|
4888
|
-
//# sourceMappingURL=open-
|
|
4995
|
+
//# sourceMappingURL=open-DYZ7KL2A.mjs.map
|