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.
Files changed (48) hide show
  1. package/README.md +557 -10
  2. package/build/browser-CKRNbIDT.mjs +62 -0
  3. package/build/browser-CKRNbIDT.mjs.map +1 -0
  4. package/build/cli.mjs +795 -121
  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 +94 -4
  18. package/build/index.mjs +13 -5
  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 +556 -32
  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/{browser-gJ_h4AhB.mjs → render-C1CUraWF.mjs} +31 -74
  28. package/build/render-C1CUraWF.mjs.map +1 -0
  29. package/build/rolldown-runtime-D7D4PA-g.mjs +13 -0
  30. package/build/{open-DYZ7KL2A.mjs → runtime-k8mo1_7U.mjs} +986 -1657
  31. package/build/runtime-k8mo1_7U.mjs.map +1 -0
  32. package/build/urls-BHFeSyEK.mjs +62 -0
  33. package/build/urls-BHFeSyEK.mjs.map +1 -0
  34. package/build/urls-BTDSgDLC.d.mts +16 -0
  35. package/build/wacz-sfOLT8e9.mjs +275 -0
  36. package/build/wacz-sfOLT8e9.mjs.map +1 -0
  37. package/build/warc-VIPscWxY.mjs +728 -0
  38. package/build/warc-VIPscWxY.mjs.map +1 -0
  39. package/build/webbotauth-CjJdTAfE.mjs +568 -0
  40. package/build/webbotauth-CjJdTAfE.mjs.map +1 -0
  41. package/package.json +1 -1
  42. package/record.schema.json +15 -1
  43. package/build/browser-gJ_h4AhB.mjs.map +0 -1
  44. package/build/html-oNpGAHwi.mjs.map +0 -1
  45. package/build/open-DYZ7KL2A.mjs.map +0 -1
  46. package/build/open-eHcriTds.d.mts +0 -1499
  47. package/build/version-no12qgos.mjs +0 -11
  48. 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 { r as VERSION } from "./version-no12qgos.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
- --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 = process.env[name];
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
- `${ok(page) ? "✓" : "✗"} ${page.url}`,
186
- ` ${[
187
- `${page.outcome} ${String(page.status)}`,
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
- /** This function does the command of the command line, and gives the exit code. */
216
- const main = async (args) => {
217
- const { values, positionals } = parse(args);
218
- const out = (text) => void process.stdout.write(`${text}\n`);
219
- if (values.version === true) {
220
- out(VERSION);
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
- const [command, ...rest] = positionals;
224
- if (values.help === true || command === void 0) {
225
- process.stdout.write(HELP);
226
- 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}`);
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)) 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
+ }
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 dir = storeDir(values.dir ?? (process.env["POLITE_CRAWLER_DIR"] || void 0));
240
- const allowPrivate = values["allow-private"] === true || envFlag("POLITE_CRAWLER_ALLOW_PRIVATE");
241
- 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
+ }
242
909
  const options = {
243
910
  dir,
244
- contact: values.contact ?? (process.env["POLITE_CRAWLER_CONTACT"] || void 0),
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
- const { durationMs, openCrawler } = await import("./open-DYZ7KL2A.mjs").then((n) => n.o);
257
- let maxAge;
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 !== "fetch" || render === "never") renderer = false;
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-gJ_h4AhB.mjs").then((n) => n.n);
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 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, {
288
953
  render,
289
- maxAge
290
- })))).map((result, at) => {
291
- const url = urls[at];
292
- if (result.status === "rejected") {
293
- const reason = result.reason;
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(ok) ? 0 : 1;
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.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));
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, () => 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}`);
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, () => 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));
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
  }