polite-crawler 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +601 -12
  2. package/build/browser-CKRNbIDT.mjs +62 -0
  3. package/build/browser-CKRNbIDT.mjs.map +1 -0
  4. package/build/cli.mjs +801 -120
  5. package/build/cli.mjs.map +1 -1
  6. package/build/diff-Dk2eSvi-.mjs +648 -0
  7. package/build/diff-Dk2eSvi-.mjs.map +1 -0
  8. package/build/duration-iIuMMofi.mjs +38 -0
  9. package/build/duration-iIuMMofi.mjs.map +1 -0
  10. package/build/extract-DcwNrTK7.mjs +1793 -0
  11. package/build/extract-DcwNrTK7.mjs.map +1 -0
  12. package/build/{html-nWYFVsRU.d.mts → html-BACljlkf.d.mts} +8 -9
  13. package/build/{html-oNpGAHwi.mjs → html-DkQUAE_d.mjs} +18 -39
  14. package/build/html-DkQUAE_d.mjs.map +1 -0
  15. package/build/html.d.mts +2 -1
  16. package/build/html.mjs +3 -1
  17. package/build/index.d.mts +106 -6
  18. package/build/index.mjs +14 -6
  19. package/build/log-Dx-wXjJl.mjs +342 -0
  20. package/build/log-Dx-wXjJl.mjs.map +1 -0
  21. package/build/mcp.d.mts +7 -4
  22. package/build/mcp.mjs +561 -35
  23. package/build/mcp.mjs.map +1 -1
  24. package/build/open-Ca1lcWR3.d.mts +2875 -0
  25. package/build/open-DnrWgkC0.mjs +3161 -0
  26. package/build/open-DnrWgkC0.mjs.map +1 -0
  27. package/build/{politeness-C5bZ1OKM.d.mts → politeness-yI3xBEx9.d.mts} +95 -2
  28. package/build/politeness.d.mts +2 -2
  29. package/build/politeness.mjs +102 -1
  30. package/build/politeness.mjs.map +1 -1
  31. package/build/{browser-gJ_h4AhB.mjs → render-C1CUraWF.mjs} +31 -74
  32. package/build/render-C1CUraWF.mjs.map +1 -0
  33. package/build/rolldown-runtime-D7D4PA-g.mjs +13 -0
  34. package/build/{open-CJR4b44v.mjs → runtime-k8mo1_7U.mjs} +1018 -1582
  35. package/build/runtime-k8mo1_7U.mjs.map +1 -0
  36. package/build/urls-BHFeSyEK.mjs +62 -0
  37. package/build/urls-BHFeSyEK.mjs.map +1 -0
  38. package/build/urls-BTDSgDLC.d.mts +16 -0
  39. package/build/wacz-sfOLT8e9.mjs +275 -0
  40. package/build/wacz-sfOLT8e9.mjs.map +1 -0
  41. package/build/warc-VIPscWxY.mjs +728 -0
  42. package/build/warc-VIPscWxY.mjs.map +1 -0
  43. package/build/webbotauth-CjJdTAfE.mjs +568 -0
  44. package/build/webbotauth-CjJdTAfE.mjs.map +1 -0
  45. package/package.json +1 -1
  46. package/record.schema.json +20 -1
  47. package/build/browser-gJ_h4AhB.mjs.map +0 -1
  48. package/build/html-oNpGAHwi.mjs.map +0 -1
  49. package/build/open-CJR4b44v.mjs.map +0 -1
  50. package/build/open-CZqwY7GH.d.mts +0 -1461
  51. package/build/version-rVx8_y1_.mjs +0 -11
  52. package/build/version-rVx8_y1_.mjs.map +0 -1
package/build/cli.mjs CHANGED
@@ -1,56 +1,28 @@
1
1
  #!/usr/bin/env node
2
- import { r as VERSION } from "./version-rVx8_y1_.mjs";
2
+ import { i as VERSION, t as durationMs } from "./duration-iIuMMofi.mjs";
3
3
  import { join, resolve } from "node:path";
4
- import { homedir } from "node:os";
4
+ import { readFileSync } from "node:fs";
5
5
  import { parseArgs } from "node:util";
6
- //#region src/cli.ts
7
- const emitWarning = process.emitWarning.bind(process);
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
- read --from <n> where the range starts (default 0)
40
- --length <n> how many characters (default 4000)
41
- --user-provided you gave this URL yourself: read a page that refuses AI input (logged)
42
- reindex rebuild; the documents of recipes that this command cannot read stay
43
- mcp --allow-user-provided let user_provided work when the client cannot ask you
44
- ($POLITE_CRAWLER_ALLOW_USER_PROVIDED=1)
45
-
46
- Exit codes:
47
- 0 done
48
- 1 not done: a page not fetched, no match, a quote not found, a document unknown or
49
- held back, or an error
50
- 2 the command line is wrong
51
- `;
6
+ import { homedir } from "node:os";
7
+ //#region src/cli-line.ts
52
8
  /** A usage error is a wrong command line. It exits with code 2. */
53
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`. */
54
26
  const OPTIONS = {
55
27
  dir: { type: "string" },
56
28
  contact: { type: "string" },
@@ -69,21 +41,85 @@ const OPTIONS = {
69
41
  "max-age": { type: "string" },
70
42
  limit: { type: "string" },
71
43
  kind: { type: "string" },
44
+ rank: { type: "string" },
72
45
  from: { type: "string" },
73
46
  length: { type: "string" },
74
47
  "user-provided": { type: "boolean" },
75
- "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
76
89
  };
77
- /** These options belong to one command. */
90
+ /** These options belong to one command, or to the commands that the text names. */
78
91
  const OWNER = {
79
- render: "fetch",
80
- "max-age": "fetch",
92
+ render: "fetch crawl watch",
93
+ "max-age": "fetch crawl discover",
81
94
  limit: "find",
82
95
  kind: "find",
83
- from: "read",
96
+ rank: "find",
97
+ from: "read diff",
84
98
  length: "read",
85
- "user-provided": "read",
86
- "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"
87
123
  };
88
124
  /** These are the commands, with the smallest and the largest count of arguments. */
89
125
  const COMMANDS = {
@@ -116,6 +152,41 @@ const COMMANDS = {
116
152
  min: 0,
117
153
  max: 0,
118
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"
119
190
  }
120
191
  };
121
192
  const parse = (args) => {
@@ -140,8 +211,8 @@ const whole = (name, text, fallback, min) => {
140
211
  return value;
141
212
  };
142
213
  /** An environment variable that switches a feature on is 1 or 0 (also true or false, yes or no). */
143
- const envFlag = (name) => {
144
- const raw = process.env[name];
214
+ const envFlag = (env, name) => {
215
+ const raw = env[name];
145
216
  const value = raw?.trim().toLowerCase() ?? "";
146
217
  if ([
147
218
  "",
@@ -176,19 +247,48 @@ const FS_CODES = /* @__PURE__ */ new Set([
176
247
  "EISDIR"
177
248
  ]);
178
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;
179
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}`];
180
262
  return [
181
- `${ok(page) ? "✓" : "✗"} ${page.url}`,
182
- ` ${[
183
- `${page.outcome} ${String(page.status)}`,
184
- page.doc === null ? null : `doc ${page.doc}`,
185
- page.chars > 0 ? `${String(page.chars)} chars` : null,
186
- page.rendered ? "rendered" : null,
187
- page.structured.length > 0 ? `JSON-LD: ${page.structured.join(", ")}` : null
188
- ].filter((fact) => fact !== null).join(" · ")}`,
263
+ head,
264
+ ` ${facts.join(" · ")}`,
265
+ ...archived,
189
266
  ...page.note === null ? [] : [` ${page.note}`]
190
267
  ].join("\n");
191
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
+ };
192
292
  /** This is the result of a URL that the crawler did not fetch. */
193
293
  const failedPage = (url, note) => ({
194
294
  url,
@@ -208,57 +308,626 @@ const failedPage = (url, note) => ({
208
308
  held_back: false,
209
309
  note
210
310
  });
211
- /** This function does the command of the command line, and gives the exit code. */
212
- const main = async (args) => {
213
- const { values, positionals } = parse(args);
214
- const out = (text) => void process.stdout.write(`${text}\n`);
215
- if (values.version === true) {
216
- out(VERSION);
217
- 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 });
218
317
  }
219
- const [command, ...rest] = positionals;
220
- if (values.help === true || command === void 0) {
221
- process.stdout.write(HELP);
222
- return 0;
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}`);
223
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" };
224
570
  const spec = COMMANDS[command];
225
571
  if (spec === void 0) throw new UsageError(`no command "${command}"`);
226
572
  if (rest.length < spec.min || rest.length > spec.max) throw new UsageError(spec.wants);
227
- for (const [option, owner] of Object.entries(OWNER)) if (values[option] !== void 0 && owner !== command) throw new UsageError(`--${option} is an option of ${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
+ }
228
577
  const render = values.render ?? "auto";
229
578
  if (render !== "auto" && render !== "never" && render !== "always") throw new UsageError(`--render is auto, never or always, not "${render}"`);
579
+ const rank = values.rank ?? "exact";
580
+ if (rank !== "exact" && rank !== "fast") throw new UsageError(`--rank is exact or fast, not "${rank}"`);
230
581
  const limit = whole("limit", values.limit, 10, 1);
231
- const from = whole("from", values.from, 0, 0);
582
+ const from = command === "read" ? whole("from", values.from, 0, 0) : 0;
232
583
  const length = whole("length", values.length, 4e3, 1);
233
- const dir = storeDir(values.dir ?? (process.env["POLITE_CRAWLER_DIR"] || void 0));
234
- const allowPrivate = values["allow-private"] === true || envFlag("POLITE_CRAWLER_ALLOW_PRIVATE");
235
- const allowUserProvided = values["allow-user-provided"] === true || command === "mcp" && envFlag("POLITE_CRAWLER_ALLOW_USER_PROVIDED");
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
+ }
236
909
  const options = {
237
910
  dir,
238
- contact: values.contact ?? (process.env["POLITE_CRAWLER_CONTACT"] || void 0),
911
+ contact: line.contact,
239
912
  ...values["no-browser"] === true ? { renderer: false } : {},
240
- ...allowPrivate ? { egress: { allowPrivate: true } } : {}
913
+ ...line.allowPrivate ? { egress: { allowPrivate: true } } : {},
914
+ ...signer === void 0 ? {} : { signer }
241
915
  };
242
916
  if (command === "mcp") {
243
917
  const { startMcpServer } = await import("./mcp.mjs");
244
918
  await startMcpServer({
245
919
  ...options,
246
- allowUserProvided
920
+ allowUserProvided: line.allowUserProvided
247
921
  });
248
922
  return 0;
249
923
  }
250
- const { durationMs, openCrawler } = await import("./open-CJR4b44v.mjs").then((n) => n.o);
251
- let maxAge;
252
- try {
253
- maxAge = durationMs(values["max-age"] ?? "24h");
254
- } catch (error) {
255
- throw new UsageError(`--max-age: ${error.message}`, { cause: error });
256
- }
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);
257
926
  let renderOff;
258
927
  let renderer = options.renderer;
259
- if (command !== "fetch" || render === "never") renderer = false;
928
+ if (!(command === "fetch" || command === "crawl" || watchLine?.action === "run") || render === "never") renderer = false;
260
929
  else if (renderer === void 0) {
261
- const { browserMissing } = await import("./browser-gJ_h4AhB.mjs").then((n) => n.n);
930
+ const { browserMissing } = await import("./browser-CKRNbIDT.mjs").then((n) => n.n);
262
931
  renderOff = await browserMissing();
263
932
  if (renderOff !== void 0) renderer = false;
264
933
  }
@@ -278,30 +947,25 @@ const main = async (args) => {
278
947
  switch (command) {
279
948
  case "fetch": {
280
949
  const urls = [...new Set(rest)];
281
- const pages = (await Promise.allSettled(urls.map((url) => crawler.fetch(url, {
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, {
282
953
  render,
283
- maxAge
284
- })))).map((result, at) => {
285
- const url = urls[at];
286
- if (result.status === "rejected") {
287
- const reason = result.reason;
288
- return failedPage(url, `${url}: ${reason instanceof Error ? reason.message : String(reason)}`);
289
- }
290
- const page = result.value;
291
- return renderOff !== void 0 && page.note !== null && /no browser/i.test(page.note) ? {
292
- ...page,
293
- note: `this page needs JavaScript, and rendering is off: ${renderOff}`
294
- } : page;
295
- });
954
+ maxAge,
955
+ fallback,
956
+ archiveDespiteRobots
957
+ })));
958
+ const pages = fetchedPages(urls, settled, renderOff);
296
959
  print(pages, () => pages.map(describePage).join("\n"));
297
- return pages.every(ok) ? 0 : 1;
960
+ return pages.every(done) ? 0 : 1;
298
961
  }
299
962
  case "find": {
300
963
  const found = await crawler.find(rest.join(" "), {
301
964
  limit,
302
- kind: values.kind ?? null
965
+ kind: values.kind ?? null,
966
+ rank
303
967
  });
304
- print(found, () => found.length === 0 ? "nothing stored matches" : found.map((hit) => `${hit.doc} ${hit.title}\n ${hit.snippet.replace(/\s+/g, " ")}`).join("\n"));
968
+ print(found, () => describeFound(found));
305
969
  return found.length === 0 ? 1 : 0;
306
970
  }
307
971
  case "read": {
@@ -309,24 +973,41 @@ const main = async (args) => {
309
973
  const read = await crawler.read(rest[0], {
310
974
  from,
311
975
  length,
312
- userProvided
976
+ userProvided,
977
+ at
313
978
  });
314
- print(read, () => 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[${String(read.from)}–${String(read.to)} of ${String(read.total)}]\n\n${read.text}`);
315
- return read.held_back ? 1 : 0;
979
+ print(read, () => describeRead(read, at));
980
+ return read.held_back || read.before_first === true ? 1 : 0;
316
981
  }
317
982
  case "verify": {
318
983
  const [doc, ...quote] = rest;
319
- const verified = await crawler.verify(doc, quote.join(" "));
320
- print(verified, () => verified.found ? `found at ${String(verified.start)}–${String(verified.end)} in ${verified.capture ?? ""}${verified.stale ? " (an earlier capture)" : ""}: ${verified.excerpt ?? ""}` : verified.held_back ? verified.note ?? "held back" : "not found");
984
+ const verified = await crawler.verify(doc, quote.join(" "), { at });
985
+ print(verified, () => describeVerify(verified));
321
986
  return verified.found ? 0 : 1;
322
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);
323
1008
  default: {
324
1009
  const counts = await crawler.reindex();
325
- print(counts, () => [
326
- `${String(counts.captures)} captures replayed, ${String(counts.docs)} documents, ${String(counts.urls)} URLs`,
327
- ...counts.carried > 0 ? [`${String(counts.carried)} documents of other recipes kept: ${counts.unknown_recipes.join(", ")}`] : [],
328
- ...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`] : []
329
- ].join("\n"));
1010
+ print(counts, () => describeReindex(counts));
330
1011
  return 0;
331
1012
  }
332
1013
  }