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
package/build/cli.mjs
CHANGED
|
@@ -1,58 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { i as VERSION, t as durationMs } from "./duration-iIuMMofi.mjs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
process.emitWarning = ((warning, ...rest) => {
|
|
9
|
-
const first = rest[0];
|
|
10
|
-
const kind = typeof first === "string" ? first : typeof first === "object" && first !== null && "type" in first ? first.type : warning instanceof Error ? warning.name : void 0;
|
|
11
|
-
const text = typeof warning === "string" ? warning : warning.message;
|
|
12
|
-
if (kind === "ExperimentalWarning" && text.startsWith("SQLite is an experimental feature")) return;
|
|
13
|
-
emitWarning(warning, ...rest);
|
|
14
|
-
});
|
|
15
|
-
const HELP = `polite-crawler ${VERSION}: fetch a source once, keep it, read it many times.
|
|
16
|
-
|
|
17
|
-
Usage:
|
|
18
|
-
polite-crawler fetch <url...> fetch pages into the store (robots.txt obeyed)
|
|
19
|
-
polite-crawler find <query> search the stored pages, with no request
|
|
20
|
-
polite-crawler read <doc> print a range of a stored document
|
|
21
|
-
polite-crawler verify <doc> <quote> check that a quote is in a stored document
|
|
22
|
-
polite-crawler reindex rebuild the search index from the capture log
|
|
23
|
-
polite-crawler mcp serve the tools over MCP (standard input and output)
|
|
24
|
-
|
|
25
|
-
Options:
|
|
26
|
-
--dir <path> the store (default $POLITE_CRAWLER_DIR, or ~/.polite-crawler)
|
|
27
|
-
--contact <text> an email (From and User-Agent) or a URL (User-Agent only)
|
|
28
|
-
($POLITE_CRAWLER_CONTACT)
|
|
29
|
-
--allow-private fetch loopback and private addresses too ($POLITE_CRAWLER_ALLOW_PRIVATE=1)
|
|
30
|
-
--no-browser never open the headless browser
|
|
31
|
-
--json print JSON
|
|
32
|
-
-v, --version print the version
|
|
33
|
-
-h, --help print this help
|
|
34
|
-
|
|
35
|
-
fetch --render <mode> auto (default), never or always
|
|
36
|
-
--max-age <age> reuse a stored copy this young: 30m, 24h (default), 7d; 0 fetches again
|
|
37
|
-
find --limit <n> how many results (default 10)
|
|
38
|
-
--kind <kind> only this kind of document, such as page or pdf
|
|
39
|
-
--rank <mode> exact (default): BM25 over each match; fast: leave common words
|
|
40
|
-
out of the one-word part, faster for a long question
|
|
41
|
-
read --from <n> where the range starts (default 0)
|
|
42
|
-
--length <n> how many characters (default 4000)
|
|
43
|
-
--user-provided you gave this URL yourself: read a page that refuses AI input (logged)
|
|
44
|
-
reindex rebuild; the documents of recipes that this command cannot read stay
|
|
45
|
-
mcp --allow-user-provided let user_provided work when the client cannot ask you
|
|
46
|
-
($POLITE_CRAWLER_ALLOW_USER_PROVIDED=1)
|
|
47
|
-
|
|
48
|
-
Exit codes:
|
|
49
|
-
0 done
|
|
50
|
-
1 not done: a page not fetched, no match, a quote not found, a document unknown or
|
|
51
|
-
held back, or an error
|
|
52
|
-
2 the command line is wrong
|
|
53
|
-
`;
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
//#region src/cli-line.ts
|
|
54
8
|
/** A usage error is a wrong command line. It exits with code 2. */
|
|
55
9
|
var UsageError = class extends Error {};
|
|
10
|
+
/** These options belong to the command `export`. */
|
|
11
|
+
const EXPORT_OPTIONS = {
|
|
12
|
+
format: { type: "string" },
|
|
13
|
+
out: { type: "string" },
|
|
14
|
+
since: { type: "string" },
|
|
15
|
+
until: { type: "string" },
|
|
16
|
+
host: { type: "string" },
|
|
17
|
+
prefix: { type: "string" },
|
|
18
|
+
id: {
|
|
19
|
+
type: "string",
|
|
20
|
+
multiple: true
|
|
21
|
+
},
|
|
22
|
+
"include-refusals": { type: "boolean" },
|
|
23
|
+
overwrite: { type: "boolean" }
|
|
24
|
+
};
|
|
25
|
+
/** These are the options of the command line: the options of the commands and of `export`. */
|
|
56
26
|
const OPTIONS = {
|
|
57
27
|
dir: { type: "string" },
|
|
58
28
|
contact: { type: "string" },
|
|
@@ -75,19 +45,81 @@ const OPTIONS = {
|
|
|
75
45
|
from: { type: "string" },
|
|
76
46
|
length: { type: "string" },
|
|
77
47
|
"user-provided": { type: "boolean" },
|
|
78
|
-
"allow-user-provided": { type: "boolean" }
|
|
48
|
+
"allow-user-provided": { type: "boolean" },
|
|
49
|
+
"max-urls": { type: "string" },
|
|
50
|
+
feed: {
|
|
51
|
+
type: "string",
|
|
52
|
+
multiple: true
|
|
53
|
+
},
|
|
54
|
+
"max-pages": { type: "string" },
|
|
55
|
+
"max-depth": { type: "string" },
|
|
56
|
+
budget: { type: "string" },
|
|
57
|
+
include: {
|
|
58
|
+
type: "string",
|
|
59
|
+
multiple: true
|
|
60
|
+
},
|
|
61
|
+
exclude: {
|
|
62
|
+
type: "string",
|
|
63
|
+
multiple: true
|
|
64
|
+
},
|
|
65
|
+
sitemaps: { type: "boolean" },
|
|
66
|
+
"allow-host": {
|
|
67
|
+
type: "string",
|
|
68
|
+
multiple: true
|
|
69
|
+
},
|
|
70
|
+
at: { type: "string" },
|
|
71
|
+
to: { type: "string" },
|
|
72
|
+
every: { type: "string" },
|
|
73
|
+
confirm: { type: "string" },
|
|
74
|
+
scope: { type: "string" },
|
|
75
|
+
label: {
|
|
76
|
+
type: "string",
|
|
77
|
+
multiple: true
|
|
78
|
+
},
|
|
79
|
+
"archive-fallback": { type: "boolean" },
|
|
80
|
+
"archive-despite-robots": { type: "boolean" },
|
|
81
|
+
"sign-key": { type: "string" },
|
|
82
|
+
"signature-agent": { type: "string" },
|
|
83
|
+
field: {
|
|
84
|
+
type: "string",
|
|
85
|
+
multiple: true
|
|
86
|
+
},
|
|
87
|
+
capture: { type: "string" },
|
|
88
|
+
...EXPORT_OPTIONS
|
|
79
89
|
};
|
|
80
|
-
/** These options belong to one command. */
|
|
90
|
+
/** These options belong to one command, or to the commands that the text names. */
|
|
81
91
|
const OWNER = {
|
|
82
|
-
render: "fetch",
|
|
83
|
-
"max-age": "fetch",
|
|
92
|
+
render: "fetch crawl watch",
|
|
93
|
+
"max-age": "fetch crawl discover",
|
|
84
94
|
limit: "find",
|
|
85
95
|
kind: "find",
|
|
86
96
|
rank: "find",
|
|
87
|
-
from: "read",
|
|
97
|
+
from: "read diff",
|
|
88
98
|
length: "read",
|
|
89
|
-
"user-provided": "read",
|
|
90
|
-
"allow-user-provided": "mcp"
|
|
99
|
+
"user-provided": "read extract",
|
|
100
|
+
"allow-user-provided": "mcp",
|
|
101
|
+
...Object.fromEntries(Object.keys(EXPORT_OPTIONS).map((option) => [option, "export"])),
|
|
102
|
+
"max-urls": "discover",
|
|
103
|
+
feed: "discover",
|
|
104
|
+
"max-pages": "crawl watch",
|
|
105
|
+
"max-depth": "crawl",
|
|
106
|
+
budget: "crawl watch",
|
|
107
|
+
include: "crawl",
|
|
108
|
+
exclude: "crawl",
|
|
109
|
+
sitemaps: "crawl",
|
|
110
|
+
"allow-host": "crawl discover",
|
|
111
|
+
at: "read verify extract",
|
|
112
|
+
to: "diff",
|
|
113
|
+
every: "watch",
|
|
114
|
+
confirm: "watch",
|
|
115
|
+
scope: "watch",
|
|
116
|
+
label: "watch",
|
|
117
|
+
"archive-fallback": "fetch",
|
|
118
|
+
"archive-despite-robots": "fetch",
|
|
119
|
+
"sign-key": "fetch discover crawl watch mcp key-directory",
|
|
120
|
+
"signature-agent": "fetch discover crawl watch mcp key-directory",
|
|
121
|
+
field: "extract",
|
|
122
|
+
capture: "extract"
|
|
91
123
|
};
|
|
92
124
|
/** These are the commands, with the smallest and the largest count of arguments. */
|
|
93
125
|
const COMMANDS = {
|
|
@@ -120,6 +152,41 @@ const COMMANDS = {
|
|
|
120
152
|
min: 0,
|
|
121
153
|
max: 0,
|
|
122
154
|
wants: "mcp takes no argument"
|
|
155
|
+
},
|
|
156
|
+
export: {
|
|
157
|
+
min: 0,
|
|
158
|
+
max: 0,
|
|
159
|
+
wants: "export takes no argument: give the file with --out"
|
|
160
|
+
},
|
|
161
|
+
discover: {
|
|
162
|
+
min: 1,
|
|
163
|
+
max: 1,
|
|
164
|
+
wants: "discover wants one site URL"
|
|
165
|
+
},
|
|
166
|
+
crawl: {
|
|
167
|
+
min: 1,
|
|
168
|
+
max: Infinity,
|
|
169
|
+
wants: "crawl wants at least one URL"
|
|
170
|
+
},
|
|
171
|
+
diff: {
|
|
172
|
+
min: 1,
|
|
173
|
+
max: 1,
|
|
174
|
+
wants: "diff wants one document id"
|
|
175
|
+
},
|
|
176
|
+
watch: {
|
|
177
|
+
min: 1,
|
|
178
|
+
max: Infinity,
|
|
179
|
+
wants: "watch wants add, remove, list or run"
|
|
180
|
+
},
|
|
181
|
+
"key-directory": {
|
|
182
|
+
min: 0,
|
|
183
|
+
max: 0,
|
|
184
|
+
wants: "key-directory takes no argument: give the key with --sign-key"
|
|
185
|
+
},
|
|
186
|
+
extract: {
|
|
187
|
+
min: 1,
|
|
188
|
+
max: 1,
|
|
189
|
+
wants: "extract wants one document id and --field name=value"
|
|
123
190
|
}
|
|
124
191
|
};
|
|
125
192
|
const parse = (args) => {
|
|
@@ -144,8 +211,8 @@ const whole = (name, text, fallback, min) => {
|
|
|
144
211
|
return value;
|
|
145
212
|
};
|
|
146
213
|
/** An environment variable that switches a feature on is 1 or 0 (also true or false, yes or no). */
|
|
147
|
-
const envFlag = (name) => {
|
|
148
|
-
const raw =
|
|
214
|
+
const envFlag = (env, name) => {
|
|
215
|
+
const raw = env[name];
|
|
149
216
|
const value = raw?.trim().toLowerCase() ?? "";
|
|
150
217
|
if ([
|
|
151
218
|
"",
|
|
@@ -180,19 +247,48 @@ const FS_CODES = /* @__PURE__ */ new Set([
|
|
|
180
247
|
"EISDIR"
|
|
181
248
|
]);
|
|
182
249
|
const ok = (page) => page.outcome === "ok" || page.outcome === "not-modified";
|
|
250
|
+
/** A fetch is done when the page came, or when an archived copy came in its place. */
|
|
251
|
+
const done = (page) => ok(page) || page.archived !== void 0;
|
|
183
252
|
const describePage = (page) => {
|
|
253
|
+
const head = `${ok(page) ? "✓" : page.archived !== void 0 ? "~" : "✗"} ${page.url}`;
|
|
254
|
+
const facts = [
|
|
255
|
+
`${page.outcome} ${String(page.status)}`,
|
|
256
|
+
page.doc === null ? null : `doc ${page.doc}`,
|
|
257
|
+
page.chars > 0 ? `${String(page.chars)} chars` : null,
|
|
258
|
+
page.rendered ? "rendered" : null,
|
|
259
|
+
page.structured.length > 0 ? `JSON-LD: ${page.structured.join(", ")}` : null
|
|
260
|
+
].filter((fact) => fact !== null);
|
|
261
|
+
const archived = page.archived === void 0 ? [] : [` archived copy (${page.archived.source}, captured ${page.archived.timestamp}): ${page.archived.url}`];
|
|
184
262
|
return [
|
|
185
|
-
|
|
186
|
-
` ${
|
|
187
|
-
|
|
188
|
-
page.doc === null ? null : `doc ${page.doc}`,
|
|
189
|
-
page.chars > 0 ? `${String(page.chars)} chars` : null,
|
|
190
|
-
page.rendered ? "rendered" : null,
|
|
191
|
-
page.structured.length > 0 ? `JSON-LD: ${page.structured.join(", ")}` : null
|
|
192
|
-
].filter((fact) => fact !== null).join(" · ")}`,
|
|
263
|
+
head,
|
|
264
|
+
` ${facts.join(" · ")}`,
|
|
265
|
+
...archived,
|
|
193
266
|
...page.note === null ? [] : [` ${page.note}`]
|
|
194
267
|
].join("\n");
|
|
195
268
|
};
|
|
269
|
+
/** This function reads the key file of `--sign-key`. An error names the file, and not the key. */
|
|
270
|
+
const keyFile = (path) => {
|
|
271
|
+
const file = path === "~" ? homedir() : /^~[\\/]/.test(path) ? resolve(homedir(), path.slice(2)) : resolve(path);
|
|
272
|
+
try {
|
|
273
|
+
return readFileSync(file, "utf8");
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const code = error.code;
|
|
276
|
+
throw new UsageError(`--sign-key: the file ${file} does not open (${typeof code === "string" ? code : "error"})`, { cause: error });
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* This function gives the type of the URL of `--signature-agent`. An origin names a key directory at
|
|
281
|
+
* the well-known path. A URL with a path names a JSON Web Key Set (`jwks_uri`), for a host that cannot
|
|
282
|
+
* serve the well-known path, for example a project site of GitHub Pages.
|
|
283
|
+
*/
|
|
284
|
+
const agentTypeOf = (url) => {
|
|
285
|
+
try {
|
|
286
|
+
const parsed = new URL(url);
|
|
287
|
+
return parsed.pathname === "/" && parsed.search === "" ? "directory" : "jwks_uri";
|
|
288
|
+
} catch {
|
|
289
|
+
return "directory";
|
|
290
|
+
}
|
|
291
|
+
};
|
|
196
292
|
/** This is the result of a URL that the crawler did not fetch. */
|
|
197
293
|
const failedPage = (url, note) => ({
|
|
198
294
|
url,
|
|
@@ -212,59 +308,626 @@ const failedPage = (url, note) => ({
|
|
|
212
308
|
held_back: false,
|
|
213
309
|
note
|
|
214
310
|
});
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
return 0;
|
|
311
|
+
const crawlOptionsOf = (values) => {
|
|
312
|
+
let budgetMs = 6e5;
|
|
313
|
+
if (values.budget !== void 0) try {
|
|
314
|
+
budgetMs = durationMs(values.budget);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
throw new UsageError(`--budget: ${error.message}`, { cause: error });
|
|
222
317
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
318
|
+
return {
|
|
319
|
+
maxUrls: whole("max-urls", values["max-urls"], 1e3, 1),
|
|
320
|
+
feeds: values.feed ?? [],
|
|
321
|
+
maxPages: whole("max-pages", values["max-pages"], 100, 1),
|
|
322
|
+
maxDepth: whole("max-depth", values["max-depth"], 3, 0),
|
|
323
|
+
budgetMs,
|
|
324
|
+
include: values.include ?? [],
|
|
325
|
+
exclude: values.exclude ?? [],
|
|
326
|
+
sitemaps: values.sitemaps === true,
|
|
327
|
+
allowHosts: values["allow-host"]
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
const describeSummary = (summary) => `${String(summary.pages)} pages: ${String(summary.fetched)} fetched (${String(summary.from_cache)} from the store), ${String(summary.refused)} refused, ${String(summary.failed)} failed. ${String(summary.skipped)} links not followed, ${String(summary.queued)} URLs waiting. Stopped (${summary.stopped}): ${summary.reason}.`;
|
|
331
|
+
/** A text that starts with a date is a time. A different text is a capture id. */
|
|
332
|
+
const DATE_START = /^\d{4}-\d{2}-\d{2}/;
|
|
333
|
+
/** This function reads a time of the command line. A text that is not a time is a usage error. */
|
|
334
|
+
const timeOf = (name, text) => {
|
|
335
|
+
if (text === void 0) return void 0;
|
|
336
|
+
if (!DATE_START.test(text) || !Number.isFinite(Date.parse(text))) throw new UsageError(`--${name} wants a time, such as 2026-10-01T12:00:00Z, not "${text}"`);
|
|
337
|
+
return text;
|
|
338
|
+
};
|
|
339
|
+
/** This function reads a version of `diff`: a time, or a capture id. */
|
|
340
|
+
const versionOf = (name, text) => {
|
|
341
|
+
if (text === void 0) return void 0;
|
|
342
|
+
if (text.trim() === "") throw new UsageError(`--${name} wants a capture id or a time`);
|
|
343
|
+
return DATE_START.test(text) ? { at: timeOf(name, text) } : text;
|
|
344
|
+
};
|
|
345
|
+
const ACTIONS = {
|
|
346
|
+
add: [1, Infinity],
|
|
347
|
+
remove: [1, Infinity],
|
|
348
|
+
list: [0, 0],
|
|
349
|
+
run: [0, 0]
|
|
350
|
+
};
|
|
351
|
+
/** These options belong to one action of `watch`. */
|
|
352
|
+
const WATCH_OWNER = {
|
|
353
|
+
every: "add",
|
|
354
|
+
confirm: "add",
|
|
355
|
+
scope: "add",
|
|
356
|
+
label: "add",
|
|
357
|
+
render: "add",
|
|
358
|
+
budget: "run",
|
|
359
|
+
"max-pages": "run",
|
|
360
|
+
"sign-key": "run",
|
|
361
|
+
"signature-agent": "run"
|
|
362
|
+
};
|
|
363
|
+
const watchLineOf = (values, rest) => {
|
|
364
|
+
const [action, ...urls] = rest;
|
|
365
|
+
const counts = action === void 0 ? void 0 : ACTIONS[action];
|
|
366
|
+
if (counts === void 0) throw new UsageError(`watch wants add, remove, list or run, not "${action ?? ""}"`);
|
|
367
|
+
if (urls.length < counts[0] || urls.length > counts[1]) throw new UsageError(counts[0] > 0 ? `watch ${action} wants at least one URL` : `watch ${action} takes no URL`);
|
|
368
|
+
for (const [option, owner] of Object.entries(WATCH_OWNER)) if (values[option] !== void 0 && owner !== action) throw new UsageError(`--${option} is an option of watch ${owner}`);
|
|
369
|
+
if (values.every !== void 0) {
|
|
370
|
+
let ms;
|
|
371
|
+
try {
|
|
372
|
+
ms = durationMs(values.every);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
throw new UsageError(`--every: ${error.message}`, { cause: error });
|
|
375
|
+
}
|
|
376
|
+
if (ms < 36e5) throw new UsageError("--every wants 1h or more, so a watch cannot hammer a site");
|
|
377
|
+
}
|
|
378
|
+
if (values.confirm !== void 0 && values.confirm !== "1" && values.confirm !== "2") throw new UsageError(`--confirm is 1 or 2, not "${values.confirm}"`);
|
|
379
|
+
let labels;
|
|
380
|
+
for (const label of values.label ?? []) {
|
|
381
|
+
const at = label.indexOf("=");
|
|
382
|
+
if (at < 1) throw new UsageError(`--label wants name=value, not "${label}"`);
|
|
383
|
+
labels = {
|
|
384
|
+
...labels,
|
|
385
|
+
[label.slice(0, at)]: label.slice(at + 1)
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
for (const url of urls) if (!/^https?:\/\//i.test(url) || !URL.canParse(url)) throw new UsageError(`watch ${action} wants http or https URLs, not "${url}"`);
|
|
389
|
+
return {
|
|
390
|
+
action,
|
|
391
|
+
urls: [...new Set(urls)],
|
|
392
|
+
every: values.every,
|
|
393
|
+
confirm: values.confirm === void 0 ? void 0 : values.confirm === "1" ? 1 : 2,
|
|
394
|
+
labels
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
const everyText = (ms) => ms % 864e5 === 0 ? `${String(ms / 864e5)}d` : ms % 36e5 === 0 ? `${String(ms / 36e5)}h` : `${String(Math.round(ms / 6e4))}m`;
|
|
398
|
+
const describeEntry = (entry) => {
|
|
399
|
+
const facts = [
|
|
400
|
+
`every ${everyText(entry.every_ms)}`,
|
|
401
|
+
`next ${entry.next_at}`,
|
|
402
|
+
entry.checked_at === null ? "not checked yet" : `last ${entry.last_kind ?? "check"} at ${entry.checked_at}`,
|
|
403
|
+
`${String(entry.checks)} checks, ${String(entry.changes)} changes`
|
|
404
|
+
];
|
|
405
|
+
return [
|
|
406
|
+
entry.url,
|
|
407
|
+
` ${facts.join(" · ")}`,
|
|
408
|
+
...entry.pending === null ? [] : [` suspected: ${entry.pending.kind} since ${entry.pending.since}`]
|
|
409
|
+
].join("\n");
|
|
410
|
+
};
|
|
411
|
+
const describeEvent = (event) => {
|
|
412
|
+
const state = event.suspected ? " (suspected)" : event.confirmed ? " (confirmed)" : "";
|
|
413
|
+
const facts = [
|
|
414
|
+
`${event.outcome} ${String(event.status)}`,
|
|
415
|
+
event.capture === null ? null : `capture ${event.capture}`,
|
|
416
|
+
event.previous === null || event.previous === event.capture ? null : `from ${event.previous}`,
|
|
417
|
+
event.diff === null ? null : `+${String(event.diff.lines_added)} −${String(event.diff.lines_removed)} lines, ${String(event.diff.meta_changes)} metadata changes`,
|
|
418
|
+
event.held_back ? "held back" : null
|
|
419
|
+
].filter((fact) => fact !== null);
|
|
420
|
+
return [
|
|
421
|
+
`${event.kind}${state} ${event.url}`,
|
|
422
|
+
` ${facts.join(" · ")}`,
|
|
423
|
+
...event.note === null ? [] : [` ${event.note}`]
|
|
424
|
+
].join("\n");
|
|
425
|
+
};
|
|
426
|
+
const describeWatchSummary = (summary) => `${String(summary.checked)} checked: ${String(summary.new)} new, ${String(summary.changed)} changed, ${String(summary.unchanged)} unchanged, ${String(summary.gone)} gone, ${String(summary.refused)} refused, ${String(summary.error)} errors. ${String(summary.suspected)} suspected, ${String(summary.confirmed)} confirmed, ${String(summary.due)} still due. Stopped (${summary.stopped}): ${summary.reason}.`;
|
|
427
|
+
/** These are the kinds of a field (`FIELD_KINDS` of `extract.ts`). The command line knows them before it loads the crawler. */
|
|
428
|
+
const KINDS = [
|
|
429
|
+
"text",
|
|
430
|
+
"number",
|
|
431
|
+
"money",
|
|
432
|
+
"date",
|
|
433
|
+
"url",
|
|
434
|
+
"email",
|
|
435
|
+
"phone"
|
|
436
|
+
];
|
|
437
|
+
/** This function reads the fields of the command line: `name=value`, with `:kind` at the end. The name and the value cannot be empty. */
|
|
438
|
+
const fieldsOf = (given) => {
|
|
439
|
+
if (given === void 0 || given.length === 0) throw new UsageError("extract wants at least one --field name=value[:kind]");
|
|
440
|
+
const fields = {};
|
|
441
|
+
for (const text of given) {
|
|
442
|
+
const equals = text.indexOf("=");
|
|
443
|
+
if (equals <= 0) throw new UsageError(`--field wants name=value[:kind], not "${text}"`);
|
|
444
|
+
const name = text.slice(0, equals);
|
|
445
|
+
let value = text.slice(equals + 1);
|
|
446
|
+
let kind;
|
|
447
|
+
const jsonLd = value.lastIndexOf(":json-ld:");
|
|
448
|
+
const colon = value.lastIndexOf(":");
|
|
449
|
+
if (jsonLd !== -1) {
|
|
450
|
+
kind = value.slice(jsonLd + 1);
|
|
451
|
+
value = value.slice(0, jsonLd);
|
|
452
|
+
if (kind === "json-ld:" || /\s/.test(kind)) throw new UsageError(`--field ${name} wants a path after json-ld:, such as json-ld:Product.offers.price`);
|
|
453
|
+
} else if (colon !== -1 && KINDS.includes(value.slice(colon + 1))) {
|
|
454
|
+
kind = value.slice(colon + 1);
|
|
455
|
+
value = value.slice(0, colon);
|
|
456
|
+
}
|
|
457
|
+
if (value.trim() === "") throw new UsageError(`--field ${name} wants a value`);
|
|
458
|
+
if (fields[name] !== void 0) throw new UsageError(`--field ${name} is given two times`);
|
|
459
|
+
fields[name] = kind === void 0 ? { value } : {
|
|
460
|
+
value,
|
|
461
|
+
kind
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return fields;
|
|
465
|
+
};
|
|
466
|
+
/** This function gives the pages of `fetch`: a rejected fetch is an error of its URL only. A page that needs a browser names the reason when rendering is off. */
|
|
467
|
+
const fetchedPages = (urls, settled, renderOff) => settled.map((result, at) => {
|
|
468
|
+
const url = urls[at];
|
|
469
|
+
if (result.status === "rejected") {
|
|
470
|
+
const reason = result.reason;
|
|
471
|
+
return failedPage(url, `${url}: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
472
|
+
}
|
|
473
|
+
const page = result.value;
|
|
474
|
+
return renderOff !== void 0 && page.note !== null && /no browser/i.test(page.note) ? {
|
|
475
|
+
...page,
|
|
476
|
+
note: `this page needs JavaScript, and rendering is off: ${renderOff}`
|
|
477
|
+
} : page;
|
|
478
|
+
});
|
|
479
|
+
/** This function gives the text of the results of `find`. An archived copy says so. */
|
|
480
|
+
const describeFound = (found) => found.length === 0 ? "nothing stored matches" : found.map((hit) => `${hit.doc} ${hit.title}${hit.archived === void 0 ? "" : ` [archived copy, captured ${hit.archived.timestamp}]`}\n ${hit.snippet.replace(/\s+/g, " ")}`).join("\n");
|
|
481
|
+
/** This function gives the text of `read`. With `--at`, the head names the capture and its fetch time. */
|
|
482
|
+
const describeRead = (read, at) => {
|
|
483
|
+
const version = at === void 0 ? "" : ` ${read.capture} (${read.fetched_at})`;
|
|
484
|
+
return read.before_first === true ? read.note ?? "" : read.held_back ? `${read.note ?? ""}\nAdd --user-provided if you gave this URL yourself. The crawler logs that read.` : `${read.title}\n${read.url ?? ""}\n${read.archived === void 0 ? "" : `${read.note ?? ""}\n`}[${String(read.from)}–${String(read.to)} of ${String(read.total)}]${version}\n\n${read.text}`;
|
|
485
|
+
};
|
|
486
|
+
/** This function gives the text of `verify`. An archived copy and an earlier capture say so. */
|
|
487
|
+
const describeVerify = (verified) => verified.found ? `found at ${String(verified.start)}–${String(verified.end)} in ${verified.capture ?? ""}${verified.stale ? " (an earlier capture)" : ""}${verified.archived === void 0 ? "" : ` (an archived copy from ${verified.archived.source}, captured ${verified.archived.timestamp})`}: ${verified.excerpt ?? ""}` : verified.held_back || verified.before_first === true ? verified.note ?? "held back" : "not found";
|
|
488
|
+
/** This function gives the lines of `discover`: each URL with its date, then the count of the files. */
|
|
489
|
+
const describeDiscover = (found) => {
|
|
490
|
+
const read = found.sources.filter((source) => source.kind !== null).length;
|
|
491
|
+
return [...found.urls.map((url) => url.lastmod === null ? url.url : `${url.url} ${url.lastmod.slice(0, 10)}`), `${String(found.urls.length)} URLs from ${String(read)} of ${String(found.sources.length)} files (${found.stopped}${found.note === null ? "" : `: ${found.note}`})`];
|
|
492
|
+
};
|
|
493
|
+
/** This function gives the text of `reindex`: the counts, the documents that stay, and the captures that it skipped. */
|
|
494
|
+
const describeReindex = (counts) => [
|
|
495
|
+
`${String(counts.captures)} captures replayed, ${String(counts.docs)} documents, ${String(counts.urls)} URLs`,
|
|
496
|
+
...counts.carried > 0 ? [`${String(counts.carried)} documents of other recipes kept: ${counts.unknown_recipes.join(", ")}`] : [],
|
|
497
|
+
...counts.failed + counts.missing + counts.damaged > 0 ? [`skipped: ${String(counts.failed)} captures whose reader failed, ${String(counts.missing)} with no stored body, ${String(counts.damaged)} damaged lines of the log`] : []
|
|
498
|
+
].join("\n");
|
|
499
|
+
/** This function gives the lines of `extract`: a line for each field, its candidates and its note, then the counts. */
|
|
500
|
+
const describeExtract = (result) => {
|
|
501
|
+
const lines = [];
|
|
502
|
+
for (const [name, field] of Object.entries(result.fields)) {
|
|
503
|
+
const mark = field.status === "verified" || field.status === "normalized" ? "✓" : "✗";
|
|
504
|
+
const place = field.span !== null ? ` at ${String(field.span.start)}–${String(field.span.end)}${field.span.in === "full_text" ? " of the whole text" : ""}` : field.path !== null ? ` at ${field.path}` : "";
|
|
505
|
+
const how = field.normalization === null ? "" : ` (${field.normalization})`;
|
|
506
|
+
const stored = field.raw === null ? "" : `: ${JSON.stringify(field.raw)}`;
|
|
507
|
+
lines.push(`${mark} ${name}: ${field.status}${how}${place}${stored}`);
|
|
508
|
+
for (const candidate of field.candidates) {
|
|
509
|
+
const at = candidate.span !== null ? `${String(candidate.span.start)}–${String(candidate.span.end)}` : candidate.path ?? "";
|
|
510
|
+
lines.push(` ${at}: ${JSON.stringify(candidate.raw)} can be ${candidate.readings.join(" or ")}`);
|
|
511
|
+
}
|
|
512
|
+
if (field.note !== null && field.status !== "held-back") lines.push(` ${field.note}`);
|
|
227
513
|
}
|
|
514
|
+
const summary = result.summary;
|
|
515
|
+
lines.push(result.held_back ? `${result.note ?? "held back"}\nAdd --user-provided if you gave this URL yourself. The crawler logs that check.` : `${String(summary.fields)} fields: ${String(summary.verified)} verified, ${String(summary.normalized)} normalized, ${String(summary.ambiguous)} ambiguous, ${String(summary.not_found)} not found, ${String(summary.invalid)} not valid, in ${result.capture ?? "no capture"}${result.note === null ? "" : `. ${result.note}`}`);
|
|
516
|
+
return lines;
|
|
517
|
+
};
|
|
518
|
+
/** This function gives the exit code of `extract`: 0 when each field is verified or normalized. */
|
|
519
|
+
const extractCode = (result) => {
|
|
520
|
+
const done = result.summary.verified + result.summary.normalized;
|
|
521
|
+
return done === result.summary.fields && done > 0 ? 0 : 1;
|
|
522
|
+
};
|
|
523
|
+
/** This function checks the options of `export`, and gives its format and its options. */
|
|
524
|
+
const exportPlan = (values) => {
|
|
525
|
+
const out = values.out;
|
|
526
|
+
if (out === void 0 || out.trim() === "") throw new UsageError("export wants --out <file>");
|
|
527
|
+
const format = values.format ?? (/\.wacz$/i.test(out) ? "wacz" : /\.warc\.gz$/i.test(out) ? "warc" : void 0);
|
|
528
|
+
if (format !== "warc" && format !== "wacz") throw new UsageError(values.format === void 0 ? "export wants --format warc or wacz, or a file that ends in .warc.gz or .wacz" : `--format is warc or wacz, not "${values.format}"`);
|
|
529
|
+
for (const name of ["since", "until"]) {
|
|
530
|
+
const value = values[name];
|
|
531
|
+
if (value !== void 0 && !Number.isFinite(Date.parse(value))) throw new UsageError(`--${name} wants a time, such as 2026-10-01, not "${value}"`);
|
|
532
|
+
}
|
|
533
|
+
if (values.host !== void 0 && (values.host.trim() === "" || /[/:@\s]/.test(values.host))) throw new UsageError(`--host wants a host name, such as example.com, not "${values.host}"`);
|
|
534
|
+
return {
|
|
535
|
+
format,
|
|
536
|
+
options: {
|
|
537
|
+
out: out === "~" ? homedir() : /^~[\\/]/.test(out) ? resolve(homedir(), out.slice(2)) : resolve(out),
|
|
538
|
+
since: values.since,
|
|
539
|
+
until: values.until,
|
|
540
|
+
host: values.host,
|
|
541
|
+
urlPrefix: values.prefix,
|
|
542
|
+
ids: values.id,
|
|
543
|
+
includeRefusals: values["include-refusals"] === true,
|
|
544
|
+
overwrite: values.overwrite === true
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
/** This function gives the text of an export: the file, the counts, and what the archive left out. */
|
|
549
|
+
const describeExport = (result, includeRefusals) => {
|
|
550
|
+
const left = [
|
|
551
|
+
result.bodiless > 0 && !includeRefusals ? `${String(result.bodiless)} captures with no body (add --include-refusals to keep them as metadata)` : null,
|
|
552
|
+
result.missing > 0 ? `${String(result.missing)} captures with no stored body` : null,
|
|
553
|
+
result.damaged > 0 ? `${String(result.damaged)} damaged lines of the log` : null
|
|
554
|
+
].filter((line) => line !== null);
|
|
555
|
+
return [
|
|
556
|
+
`wrote ${result.out} (${String(result.bytes)} bytes)`,
|
|
557
|
+
` ${String(result.captures)} captures: ${String(result.responses)} responses, ${String(result.revisits)} revisits, ${String(result.conversions)} rendered pages, ${String(result.metadata)} metadata records${result.format === "wacz" ? `, ${String(result.pages)} pages` : ""}`,
|
|
558
|
+
...left.length > 0 ? [` left out: ${left.join(", ")}`] : []
|
|
559
|
+
].join("\n");
|
|
560
|
+
};
|
|
561
|
+
/**
|
|
562
|
+
* This function reads the whole command line before anything opens the store. A wrong command line
|
|
563
|
+
* throws a `UsageError`, so the command exits with code 2 and makes no store.
|
|
564
|
+
*/
|
|
565
|
+
const readLine = (args, env) => {
|
|
566
|
+
const { values, positionals } = parse(args);
|
|
567
|
+
if (values.version === true) return { kind: "version" };
|
|
568
|
+
const [command, ...rest] = positionals;
|
|
569
|
+
if (values.help === true || command === void 0) return { kind: "help" };
|
|
228
570
|
const spec = COMMANDS[command];
|
|
229
571
|
if (spec === void 0) throw new UsageError(`no command "${command}"`);
|
|
230
572
|
if (rest.length < spec.min || rest.length > spec.max) throw new UsageError(spec.wants);
|
|
231
|
-
for (const [option, owner] of Object.entries(OWNER))
|
|
573
|
+
for (const [option, owner] of Object.entries(OWNER)) {
|
|
574
|
+
const owners = owner.split(" ");
|
|
575
|
+
if (values[option] !== void 0 && !owners.includes(command)) throw new UsageError(`--${option} is an option of ${owners.join(" or ")}`);
|
|
576
|
+
}
|
|
232
577
|
const render = values.render ?? "auto";
|
|
233
578
|
if (render !== "auto" && render !== "never" && render !== "always") throw new UsageError(`--render is auto, never or always, not "${render}"`);
|
|
234
579
|
const rank = values.rank ?? "exact";
|
|
235
580
|
if (rank !== "exact" && rank !== "fast") throw new UsageError(`--rank is exact or fast, not "${rank}"`);
|
|
236
581
|
const limit = whole("limit", values.limit, 10, 1);
|
|
237
|
-
const from = whole("from", values.from, 0, 0);
|
|
582
|
+
const from = command === "read" ? whole("from", values.from, 0, 0) : 0;
|
|
238
583
|
const length = whole("length", values.length, 4e3, 1);
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
584
|
+
const crawlLimits = crawlOptionsOf(values);
|
|
585
|
+
const at = timeOf("at", values.at);
|
|
586
|
+
const diffFrom = command === "diff" ? versionOf("from", values.from) : void 0;
|
|
587
|
+
const diffTo = versionOf("to", values.to);
|
|
588
|
+
const watchLine = command === "watch" ? watchLineOf(values, rest) : void 0;
|
|
589
|
+
const extractFields = command === "extract" ? fieldsOf(values.field) : void 0;
|
|
590
|
+
const dir = storeDir(values.dir ?? (env["POLITE_CRAWLER_DIR"] || void 0));
|
|
591
|
+
const allowPrivate = values["allow-private"] === true || envFlag(env, "POLITE_CRAWLER_ALLOW_PRIVATE");
|
|
592
|
+
const allowUserProvided = values["allow-user-provided"] === true || command === "mcp" && envFlag(env, "POLITE_CRAWLER_ALLOW_USER_PROVIDED");
|
|
593
|
+
if (values["archive-despite-robots"] === true && values["archive-fallback"] !== true) throw new UsageError("--archive-despite-robots works only with --archive-fallback");
|
|
594
|
+
const signs = OWNER["sign-key"].split(" ").includes(command) && (watchLine === void 0 || watchLine.action === "run");
|
|
595
|
+
const signKey = values["sign-key"] ?? (signs ? env["POLITE_CRAWLER_SIGN_KEY"] || void 0 : void 0);
|
|
596
|
+
const signatureAgent = values["signature-agent"] ?? (signs ? env["POLITE_CRAWLER_SIGNATURE_AGENT"] || void 0 : void 0);
|
|
597
|
+
if (command === "key-directory") {
|
|
598
|
+
if (signKey === void 0) throw new UsageError("key-directory wants --sign-key <file>");
|
|
599
|
+
} else if (signKey === void 0 !== (signatureAgent === void 0)) throw new UsageError("--sign-key and --signature-agent go together: the key signs, and the agent tells a site where its public key is");
|
|
600
|
+
let maxAge;
|
|
601
|
+
try {
|
|
602
|
+
maxAge = durationMs(values["max-age"] ?? "24h");
|
|
603
|
+
} catch (error) {
|
|
604
|
+
throw new UsageError(`--max-age: ${error.message}`, { cause: error });
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
kind: "command",
|
|
608
|
+
command,
|
|
609
|
+
rest,
|
|
610
|
+
values,
|
|
611
|
+
render,
|
|
612
|
+
rank,
|
|
613
|
+
limit,
|
|
614
|
+
from,
|
|
615
|
+
length,
|
|
616
|
+
maxAge,
|
|
617
|
+
crawlLimits,
|
|
618
|
+
at,
|
|
619
|
+
diffFrom,
|
|
620
|
+
diffTo,
|
|
621
|
+
watchLine,
|
|
622
|
+
extractFields,
|
|
623
|
+
exportPlan: command === "export" ? exportPlan(values) : void 0,
|
|
624
|
+
dir,
|
|
625
|
+
contact: values.contact ?? (env["POLITE_CRAWLER_CONTACT"] || void 0),
|
|
626
|
+
allowPrivate,
|
|
627
|
+
allowUserProvided,
|
|
628
|
+
signKey,
|
|
629
|
+
signatureAgent
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/cli.ts
|
|
634
|
+
const emitWarning = process.emitWarning.bind(process);
|
|
635
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
636
|
+
const first = rest[0];
|
|
637
|
+
const kind = typeof first === "string" ? first : typeof first === "object" && first !== null && "type" in first ? first.type : warning instanceof Error ? warning.name : void 0;
|
|
638
|
+
const text = typeof warning === "string" ? warning : warning.message;
|
|
639
|
+
if (kind === "ExperimentalWarning" && text.startsWith("SQLite is an experimental feature")) return;
|
|
640
|
+
emitWarning(warning, ...rest);
|
|
641
|
+
});
|
|
642
|
+
const HELP = `polite-crawler ${VERSION}: fetch a source once, keep it, read it many times.
|
|
643
|
+
|
|
644
|
+
Usage:
|
|
645
|
+
polite-crawler fetch <url...> fetch pages into the store (robots.txt obeyed)
|
|
646
|
+
polite-crawler find <query> search the stored pages, with no request
|
|
647
|
+
polite-crawler read <doc> print a range of a stored document
|
|
648
|
+
polite-crawler verify <doc> <quote> check that a quote is in a stored document
|
|
649
|
+
polite-crawler reindex rebuild the search index from the capture log
|
|
650
|
+
polite-crawler mcp serve the tools over MCP (standard input and output)
|
|
651
|
+
polite-crawler export --out <file> write the stored captures as a WARC or WACZ archive
|
|
652
|
+
polite-crawler discover <site> list the pages of a site from its sitemaps and feeds
|
|
653
|
+
polite-crawler crawl <url...> fetch a site and follow its links, within a budget
|
|
654
|
+
polite-crawler diff <doc> show what changed between two versions of a document
|
|
655
|
+
polite-crawler watch add <url...> check these pages again on a schedule
|
|
656
|
+
polite-crawler watch remove <url...> stop the watch of these pages
|
|
657
|
+
polite-crawler watch list print the watch list
|
|
658
|
+
polite-crawler watch run check the pages that are due, within a budget
|
|
659
|
+
polite-crawler key-directory print the key directory (JWKS) of --sign-key, to host
|
|
660
|
+
polite-crawler extract <doc> --field name=value[:kind]...
|
|
661
|
+
check proposed field values against a stored page
|
|
662
|
+
|
|
663
|
+
Options:
|
|
664
|
+
--dir <path> the store (default $POLITE_CRAWLER_DIR, or ~/.polite-crawler)
|
|
665
|
+
--contact <text> an email (From and User-Agent) or a URL (User-Agent only)
|
|
666
|
+
($POLITE_CRAWLER_CONTACT)
|
|
667
|
+
--allow-private fetch loopback and private addresses too ($POLITE_CRAWLER_ALLOW_PRIVATE=1)
|
|
668
|
+
--no-browser never open the headless browser
|
|
669
|
+
--sign-key <file> sign each request (Web Bot Auth) with this Ed25519 key: PKCS#8 PEM or JWK
|
|
670
|
+
($POLITE_CRAWLER_SIGN_KEY)
|
|
671
|
+
--signature-agent <url> the https origin of your key directory, or the URL of a
|
|
672
|
+
JSON Web Key Set on a path, for --sign-key ($POLITE_CRAWLER_SIGNATURE_AGENT)
|
|
673
|
+
--json print JSON
|
|
674
|
+
-v, --version print the version
|
|
675
|
+
-h, --help print this help
|
|
676
|
+
|
|
677
|
+
fetch --render <mode> auto (default), never or always
|
|
678
|
+
--max-age <age> reuse a stored copy this young: 30m, 24h (default), 7d; 0 fetches again
|
|
679
|
+
--archive-fallback when the site refuses or is down, give a copy from the Wayback
|
|
680
|
+
Machine or Common Crawl, marked archived (not a fresh fetch)
|
|
681
|
+
--archive-despite-robots with --archive-fallback: also when the robots.txt of the
|
|
682
|
+
site disallows the page now
|
|
683
|
+
find --limit <n> how many results (default 10)
|
|
684
|
+
--kind <kind> only this kind of document, such as page or pdf
|
|
685
|
+
--rank <mode> exact (default): BM25 over each match; fast: leave common words
|
|
686
|
+
out of the one-word part, faster for a long question
|
|
687
|
+
read --from <n> where the range starts (default 0)
|
|
688
|
+
--length <n> how many characters (default 4000)
|
|
689
|
+
--user-provided you gave this URL yourself: read a page that refuses AI input (logged)
|
|
690
|
+
read, verify, extract --at <time> use the version that was current then, such as
|
|
691
|
+
2026-10-01T12:00Z
|
|
692
|
+
reindex rebuild; the documents of recipes that this command cannot read stay
|
|
693
|
+
mcp --allow-user-provided let user_provided work when the client cannot ask you
|
|
694
|
+
($POLITE_CRAWLER_ALLOW_USER_PROVIDED=1)
|
|
695
|
+
export --out <file> the archive to write: a name that ends in .warc.gz or .wacz
|
|
696
|
+
--format <f> warc or wacz (default: from the name of the file)
|
|
697
|
+
--since <time> only the captures from this time on, such as 2026-10-01
|
|
698
|
+
--until <time> only the captures before this time
|
|
699
|
+
--host <host> only the captures of this host and its subdomains
|
|
700
|
+
--prefix <url> only the captures whose URL starts with this text
|
|
701
|
+
--id <capture> only this capture; give --id again for more captures
|
|
702
|
+
--include-refusals a metadata record for each capture with no body too
|
|
703
|
+
--overwrite replace the file when it exists
|
|
704
|
+
discover --max-urls <n> how many URLs (default 1000)
|
|
705
|
+
--feed <url> read this RSS or Atom feed too (repeat it)
|
|
706
|
+
--max-age <age> as for fetch
|
|
707
|
+
crawl --max-pages <n> how many pages (default 100)
|
|
708
|
+
--max-depth <n> how many links from a seed (default 3; 0 fetches the seeds only)
|
|
709
|
+
--budget <time> the longest time: 30s, 2m, 10m (default); no request starts after it
|
|
710
|
+
--include <pattern> follow only the URLs that match, for example /blog/* (repeat it)
|
|
711
|
+
--exclude <pattern> follow no URL that matches, for example *.pdf$ (repeat it)
|
|
712
|
+
--sitemaps add the pages of the sitemaps of the site
|
|
713
|
+
--render, --max-age as for fetch
|
|
714
|
+
discover, crawl --allow-host <host> keep the URLs on this host too (repeat it)
|
|
715
|
+
diff --from <version> the old version: a capture id or a time (default: the one before --to)
|
|
716
|
+
--to <version> the new version: a capture id or a time (default: the newest)
|
|
717
|
+
watch add --every <time> the time between two checks: 1h or more (default 24h)
|
|
718
|
+
--confirm <n> 2 (default): a change counts when two checks see it; 1: at once
|
|
719
|
+
--scope <text>, --label <name=value> marks for the captures (repeat --label)
|
|
720
|
+
--render <mode> as for fetch
|
|
721
|
+
watch run --budget <time> the longest time (default 10m); no request starts after it
|
|
722
|
+
--max-pages <n> how many pages to check (default 100)
|
|
723
|
+
extract --field <name=value[:kind]> a value to check, never made up by the crawler (repeat it);
|
|
724
|
+
kind: text (default), number, money, date, url, email, phone,
|
|
725
|
+
or json-ld:<path> such as json-ld:Product.offers.price
|
|
726
|
+
--capture <id> check the text of this capture, not the current text
|
|
727
|
+
--user-provided as for read
|
|
728
|
+
|
|
729
|
+
Exit codes:
|
|
730
|
+
0 done
|
|
731
|
+
1 not done: a page not fetched, no match, a quote not found, a document unknown or
|
|
732
|
+
held back, a watch check that failed or that is still due, a field value that extract
|
|
733
|
+
did not find or that is not clear, or an error
|
|
734
|
+
(fetch: a page with an archived copy counts as done)
|
|
735
|
+
2 the command line is wrong
|
|
736
|
+
`;
|
|
737
|
+
/**
|
|
738
|
+
* This function does the command `export`, and gives the exit code. `readLine` checked the whole
|
|
739
|
+
* command line first. The export only reads the store: it makes no store, and it sends no request.
|
|
740
|
+
*/
|
|
741
|
+
const exportCommand = async (plan, dir, json) => {
|
|
742
|
+
const result = plan.format === "wacz" ? await (await import("./wacz-sfOLT8e9.mjs").then((n) => n.n)).exportWacz(dir, plan.options) : await (await import("./warc-VIPscWxY.mjs").then((n) => n.a)).exportWarc(dir, plan.options);
|
|
743
|
+
process.stdout.write(`${json ? JSON.stringify(result, null, 2) : describeExport(result, plan.options.includeRefusals)}\n`);
|
|
744
|
+
return 0;
|
|
745
|
+
};
|
|
746
|
+
/** This function prints the key directory of a key file, and tells where to host it. */
|
|
747
|
+
const keyDirectoryCommand = async (key, agent) => {
|
|
748
|
+
const { KEY_DIRECTORY_PATH, KEY_DIRECTORY_TYPE, keyDirectory } = await import("./webbotauth-CjJdTAfE.mjs").then((n) => n.s);
|
|
749
|
+
let directory;
|
|
750
|
+
try {
|
|
751
|
+
directory = keyDirectory([key]);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
throw new UsageError(`--sign-key: ${error.message}`, { cause: error });
|
|
754
|
+
}
|
|
755
|
+
process.stdout.write(`${JSON.stringify(directory, null, 2)}\n`);
|
|
756
|
+
const where = agent !== void 0 && agentTypeOf(agent) === "jwks_uri" ? `${agent} (a JSON Web Key Set, the type jwks_uri)` : `${agent === void 0 ? "https://<your origin>" : agent.replace(/\/$/, "")}${KEY_DIRECTORY_PATH} with Content-Type: ${KEY_DIRECTORY_TYPE}`;
|
|
757
|
+
process.stderr.write(`polite-crawler: serve this JSON at ${where}\n`);
|
|
758
|
+
return 0;
|
|
759
|
+
};
|
|
760
|
+
/** This function lists the pages of a site. The exit code is 0 when it found a URL. */
|
|
761
|
+
const runDiscover = async (crawler, site, limits, maxAge, json, out) => {
|
|
762
|
+
const found = await crawler.discover(site, {
|
|
763
|
+
maxUrls: limits.maxUrls,
|
|
764
|
+
feeds: limits.feeds,
|
|
765
|
+
maxAge,
|
|
766
|
+
...limits.allowHosts === void 0 ? {} : { allowHosts: limits.allowHosts }
|
|
767
|
+
});
|
|
768
|
+
if (json) out(JSON.stringify(found, null, 2));
|
|
769
|
+
else for (const line of describeDiscover(found)) out(line);
|
|
770
|
+
return found.urls.length > 0 ? 0 : 1;
|
|
771
|
+
};
|
|
772
|
+
/** This function crawls from the seeds. It prints each page when it ends. The exit code is 0 when the crawl got a page. */
|
|
773
|
+
const runCrawl = async (crawler, seeds, limits, page, json, out) => {
|
|
774
|
+
const controller = new AbortController();
|
|
775
|
+
const stop = () => controller.abort(/* @__PURE__ */ new Error("interrupted"));
|
|
776
|
+
process.once("SIGINT", stop);
|
|
777
|
+
try {
|
|
778
|
+
const run = crawler.crawl([...new Set(seeds)], {
|
|
779
|
+
maxPages: limits.maxPages,
|
|
780
|
+
maxDepth: limits.maxDepth,
|
|
781
|
+
budgetMs: limits.budgetMs,
|
|
782
|
+
include: limits.include,
|
|
783
|
+
exclude: limits.exclude,
|
|
784
|
+
useSitemaps: limits.sitemaps,
|
|
785
|
+
render: page.render,
|
|
786
|
+
maxAge: page.maxAge,
|
|
787
|
+
signal: controller.signal,
|
|
788
|
+
...limits.allowHosts === void 0 ? {} : { allowHosts: limits.allowHosts }
|
|
789
|
+
});
|
|
790
|
+
const pages = [];
|
|
791
|
+
for await (const result of run) {
|
|
792
|
+
pages.push(result);
|
|
793
|
+
if (!json) out(`${describePage(result)}\n depth ${String(result.depth)}, ${result.source}`);
|
|
794
|
+
}
|
|
795
|
+
const summary = await run.summary;
|
|
796
|
+
out(json ? JSON.stringify({
|
|
797
|
+
pages,
|
|
798
|
+
summary
|
|
799
|
+
}, null, 2) : describeSummary(summary));
|
|
800
|
+
return summary.fetched > 0 ? 0 : 1;
|
|
801
|
+
} finally {
|
|
802
|
+
process.removeListener("SIGINT", stop);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
/** This function does the command `watch`, and gives the exit code. */
|
|
806
|
+
const runWatchCommand = async (crawler, line, values, limits, json, out) => {
|
|
807
|
+
const print = (value, text) => out(json ? JSON.stringify(value, null, 2) : text());
|
|
808
|
+
switch (line.action) {
|
|
809
|
+
case "add": {
|
|
810
|
+
const added = [];
|
|
811
|
+
for (const url of line.urls) added.push(await crawler.watch.add(url, {
|
|
812
|
+
...line.every === void 0 ? {} : { every: line.every },
|
|
813
|
+
...line.confirm === void 0 ? {} : { confirm: line.confirm },
|
|
814
|
+
...values.scope === void 0 ? {} : { scope: values.scope },
|
|
815
|
+
...line.labels === void 0 ? {} : { labels: line.labels },
|
|
816
|
+
...values.render === void 0 ? {} : { render: values.render }
|
|
817
|
+
}));
|
|
818
|
+
print(added, () => added.map(describeEntry).join("\n"));
|
|
819
|
+
return 0;
|
|
820
|
+
}
|
|
821
|
+
case "remove": {
|
|
822
|
+
const removed = [];
|
|
823
|
+
for (const url of line.urls) removed.push({
|
|
824
|
+
url,
|
|
825
|
+
removed: await crawler.watch.remove(url)
|
|
826
|
+
});
|
|
827
|
+
print(removed, () => removed.map((one) => `${one.removed ? "removed" : "not on the list"} ${one.url}`).join("\n"));
|
|
828
|
+
return removed.every((one) => one.removed) ? 0 : 1;
|
|
829
|
+
}
|
|
830
|
+
case "list": {
|
|
831
|
+
const entries = await crawler.watch.list();
|
|
832
|
+
print(entries, () => entries.length === 0 ? "the watch list is empty" : entries.map(describeEntry).join("\n"));
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
default: {
|
|
836
|
+
const controller = new AbortController();
|
|
837
|
+
const stop = () => controller.abort(/* @__PURE__ */ new Error("interrupted"));
|
|
838
|
+
process.once("SIGINT", stop);
|
|
839
|
+
try {
|
|
840
|
+
const run = crawler.watch.run({
|
|
841
|
+
budgetMs: values.budget === void 0 ? void 0 : limits.budgetMs,
|
|
842
|
+
maxPages: values["max-pages"] === void 0 ? void 0 : limits.maxPages,
|
|
843
|
+
signal: controller.signal
|
|
844
|
+
});
|
|
845
|
+
const events = [];
|
|
846
|
+
for await (const event of run) {
|
|
847
|
+
events.push(event);
|
|
848
|
+
if (!json) out(describeEvent(event));
|
|
849
|
+
}
|
|
850
|
+
const summary = await run.summary;
|
|
851
|
+
out(json ? JSON.stringify({
|
|
852
|
+
events,
|
|
853
|
+
summary
|
|
854
|
+
}, null, 2) : describeWatchSummary(summary));
|
|
855
|
+
return summary.refused + summary.error === 0 && summary.due === 0 ? 0 : 1;
|
|
856
|
+
} finally {
|
|
857
|
+
process.removeListener("SIGINT", stop);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
/** This function gives the text of a diff for a person: the two captures, then the text of `formatDiff`. */
|
|
863
|
+
const describeDiff = async (diff) => {
|
|
864
|
+
const { formatDiff } = await import("./diff-Dk2eSvi-.mjs").then((n) => n.i);
|
|
865
|
+
return [
|
|
866
|
+
`${diff.doc} ${diff.from.capture} (${diff.from.fetched_at}) -> ${diff.to.capture} (${diff.to.fetched_at})`,
|
|
867
|
+
formatDiff(diff),
|
|
868
|
+
...diff.same === true ? ["the two versions are the same"] : [],
|
|
869
|
+
...diff.note === null ? [] : [diff.note]
|
|
870
|
+
].join("\n");
|
|
871
|
+
};
|
|
872
|
+
/** This function checks the fields against a stored document, and prints a line for each field. The exit code is 0 when each field is verified or normalized. */
|
|
873
|
+
const runExtract = async (crawler, doc, fields, options, json, out) => {
|
|
874
|
+
const { extractDocument } = await import("./extract-DcwNrTK7.mjs").then((n) => n.i);
|
|
875
|
+
const claims = fields;
|
|
876
|
+
const result = crawler.extract === void 0 ? await extractDocument(crawler.runtime, doc, claims, options) : await crawler.extract(doc, claims, options);
|
|
877
|
+
if (json) out(JSON.stringify(result, null, 2));
|
|
878
|
+
else for (const line of describeExtract(result)) out(line);
|
|
879
|
+
return extractCode(result);
|
|
880
|
+
};
|
|
881
|
+
/** This function does the command of the command line, and gives the exit code. */
|
|
882
|
+
const main = async (args) => {
|
|
883
|
+
const out = (text) => void process.stdout.write(`${text}\n`);
|
|
884
|
+
const line = readLine(args, process.env);
|
|
885
|
+
if (line.kind === "version") {
|
|
886
|
+
out(VERSION);
|
|
887
|
+
return 0;
|
|
888
|
+
}
|
|
889
|
+
if (line.kind === "help") {
|
|
890
|
+
process.stdout.write(HELP);
|
|
891
|
+
return 0;
|
|
892
|
+
}
|
|
893
|
+
const { command, rest, values, render, rank, limit, from, length, maxAge, crawlLimits, at, diffFrom, diffTo, watchLine, extractFields, dir, signKey, signatureAgent } = line;
|
|
894
|
+
if (command === "key-directory") return keyDirectoryCommand(keyFile(signKey), signatureAgent);
|
|
895
|
+
let signer;
|
|
896
|
+
if (signKey !== void 0 && signatureAgent !== void 0) {
|
|
897
|
+
const { createWebBotAuthSigner } = await import("./webbotauth-CjJdTAfE.mjs").then((n) => n.s);
|
|
898
|
+
const privateKey = keyFile(signKey);
|
|
899
|
+
try {
|
|
900
|
+
signer = createWebBotAuthSigner({
|
|
901
|
+
privateKey,
|
|
902
|
+
signatureAgent,
|
|
903
|
+
agentType: agentTypeOf(signatureAgent)
|
|
904
|
+
});
|
|
905
|
+
} catch (error) {
|
|
906
|
+
throw new UsageError(`--sign-key: ${error.message}`, { cause: error });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
242
909
|
const options = {
|
|
243
910
|
dir,
|
|
244
|
-
contact:
|
|
911
|
+
contact: line.contact,
|
|
245
912
|
...values["no-browser"] === true ? { renderer: false } : {},
|
|
246
|
-
...allowPrivate ? { egress: { allowPrivate: true } } : {}
|
|
913
|
+
...line.allowPrivate ? { egress: { allowPrivate: true } } : {},
|
|
914
|
+
...signer === void 0 ? {} : { signer }
|
|
247
915
|
};
|
|
248
916
|
if (command === "mcp") {
|
|
249
917
|
const { startMcpServer } = await import("./mcp.mjs");
|
|
250
918
|
await startMcpServer({
|
|
251
919
|
...options,
|
|
252
|
-
allowUserProvided
|
|
920
|
+
allowUserProvided: line.allowUserProvided
|
|
253
921
|
});
|
|
254
922
|
return 0;
|
|
255
923
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
try {
|
|
259
|
-
maxAge = durationMs(values["max-age"] ?? "24h");
|
|
260
|
-
} catch (error) {
|
|
261
|
-
throw new UsageError(`--max-age: ${error.message}`, { cause: error });
|
|
262
|
-
}
|
|
924
|
+
if (line.exportPlan !== void 0) return exportCommand(line.exportPlan, dir, values.json === true);
|
|
925
|
+
const { openCrawler } = await import("./open-DnrWgkC0.mjs").then((n) => n.a);
|
|
263
926
|
let renderOff;
|
|
264
927
|
let renderer = options.renderer;
|
|
265
|
-
if (command
|
|
928
|
+
if (!(command === "fetch" || command === "crawl" || watchLine?.action === "run") || render === "never") renderer = false;
|
|
266
929
|
else if (renderer === void 0) {
|
|
267
|
-
const { browserMissing } = await import("./browser-
|
|
930
|
+
const { browserMissing } = await import("./browser-CKRNbIDT.mjs").then((n) => n.n);
|
|
268
931
|
renderOff = await browserMissing();
|
|
269
932
|
if (renderOff !== void 0) renderer = false;
|
|
270
933
|
}
|
|
@@ -284,23 +947,17 @@ const main = async (args) => {
|
|
|
284
947
|
switch (command) {
|
|
285
948
|
case "fetch": {
|
|
286
949
|
const urls = [...new Set(rest)];
|
|
287
|
-
const
|
|
950
|
+
const fallback = values["archive-fallback"] === true ? "archive" : void 0;
|
|
951
|
+
const archiveDespiteRobots = values["archive-despite-robots"] === true;
|
|
952
|
+
const settled = await Promise.allSettled(urls.map((url) => crawler.fetch(url, {
|
|
288
953
|
render,
|
|
289
|
-
maxAge
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return failedPage(url, `${url}: ${reason instanceof Error ? reason.message : String(reason)}`);
|
|
295
|
-
}
|
|
296
|
-
const page = result.value;
|
|
297
|
-
return renderOff !== void 0 && page.note !== null && /no browser/i.test(page.note) ? {
|
|
298
|
-
...page,
|
|
299
|
-
note: `this page needs JavaScript, and rendering is off: ${renderOff}`
|
|
300
|
-
} : page;
|
|
301
|
-
});
|
|
954
|
+
maxAge,
|
|
955
|
+
fallback,
|
|
956
|
+
archiveDespiteRobots
|
|
957
|
+
})));
|
|
958
|
+
const pages = fetchedPages(urls, settled, renderOff);
|
|
302
959
|
print(pages, () => pages.map(describePage).join("\n"));
|
|
303
|
-
return pages.every(
|
|
960
|
+
return pages.every(done) ? 0 : 1;
|
|
304
961
|
}
|
|
305
962
|
case "find": {
|
|
306
963
|
const found = await crawler.find(rest.join(" "), {
|
|
@@ -308,7 +965,7 @@ const main = async (args) => {
|
|
|
308
965
|
kind: values.kind ?? null,
|
|
309
966
|
rank
|
|
310
967
|
});
|
|
311
|
-
print(found, () => found
|
|
968
|
+
print(found, () => describeFound(found));
|
|
312
969
|
return found.length === 0 ? 1 : 0;
|
|
313
970
|
}
|
|
314
971
|
case "read": {
|
|
@@ -316,24 +973,41 @@ const main = async (args) => {
|
|
|
316
973
|
const read = await crawler.read(rest[0], {
|
|
317
974
|
from,
|
|
318
975
|
length,
|
|
319
|
-
userProvided
|
|
976
|
+
userProvided,
|
|
977
|
+
at
|
|
320
978
|
});
|
|
321
|
-
print(read, () =>
|
|
322
|
-
return read.held_back ? 1 : 0;
|
|
979
|
+
print(read, () => describeRead(read, at));
|
|
980
|
+
return read.held_back || read.before_first === true ? 1 : 0;
|
|
323
981
|
}
|
|
324
982
|
case "verify": {
|
|
325
983
|
const [doc, ...quote] = rest;
|
|
326
|
-
const verified = await crawler.verify(doc, quote.join(" "));
|
|
327
|
-
print(verified, () =>
|
|
984
|
+
const verified = await crawler.verify(doc, quote.join(" "), { at });
|
|
985
|
+
print(verified, () => describeVerify(verified));
|
|
328
986
|
return verified.found ? 0 : 1;
|
|
329
987
|
}
|
|
988
|
+
case "discover": return await runDiscover(crawler, rest[0], crawlLimits, maxAge, values.json === true, out);
|
|
989
|
+
case "crawl": return await runCrawl(crawler, rest, crawlLimits, {
|
|
990
|
+
render,
|
|
991
|
+
maxAge
|
|
992
|
+
}, values.json === true, out);
|
|
993
|
+
case "diff": {
|
|
994
|
+
const diff = await crawler.diff(rest[0], {
|
|
995
|
+
...diffFrom === void 0 ? {} : { from: diffFrom },
|
|
996
|
+
...diffTo === void 0 ? {} : { to: diffTo }
|
|
997
|
+
});
|
|
998
|
+
const text = values.json === true ? "" : await describeDiff(diff);
|
|
999
|
+
print(diff, () => text);
|
|
1000
|
+
return diff.held_back ? 1 : 0;
|
|
1001
|
+
}
|
|
1002
|
+
case "watch": return await runWatchCommand(crawler, watchLine, values, crawlLimits, values.json === true, out);
|
|
1003
|
+
case "extract": return await runExtract(crawler, rest[0], extractFields, {
|
|
1004
|
+
capture: values.capture,
|
|
1005
|
+
userProvided: values["user-provided"] === true,
|
|
1006
|
+
at
|
|
1007
|
+
}, values.json === true, out);
|
|
330
1008
|
default: {
|
|
331
1009
|
const counts = await crawler.reindex();
|
|
332
|
-
print(counts, () =>
|
|
333
|
-
`${String(counts.captures)} captures replayed, ${String(counts.docs)} documents, ${String(counts.urls)} URLs`,
|
|
334
|
-
...counts.carried > 0 ? [`${String(counts.carried)} documents of other recipes kept: ${counts.unknown_recipes.join(", ")}`] : [],
|
|
335
|
-
...counts.failed + counts.missing + counts.damaged > 0 ? [`skipped: ${String(counts.failed)} captures whose reader failed, ${String(counts.missing)} with no stored body, ${String(counts.damaged)} damaged lines of the log`] : []
|
|
336
|
-
].join("\n"));
|
|
1010
|
+
print(counts, () => describeReindex(counts));
|
|
337
1011
|
return 0;
|
|
338
1012
|
}
|
|
339
1013
|
}
|