polite-crawler 0.1.1 → 0.1.3

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 (62) hide show
  1. package/README.md +1087 -15
  2. package/build/browser-CKRNbIDT.mjs +62 -0
  3. package/build/browser-CKRNbIDT.mjs.map +1 -0
  4. package/build/cli-daemon--cTmq0Z5.mjs +38 -0
  5. package/build/cli-daemon--cTmq0Z5.mjs.map +1 -0
  6. package/build/cli.mjs +1065 -123
  7. package/build/cli.mjs.map +1 -1
  8. package/build/daemon-DP4EqjFL.d.mts +3772 -0
  9. package/build/daemon-fZTE24P5.mjs +515 -0
  10. package/build/daemon-fZTE24P5.mjs.map +1 -0
  11. package/build/diff-NQcQqKe9.mjs +648 -0
  12. package/build/diff-NQcQqKe9.mjs.map +1 -0
  13. package/build/extract-ByFfc8Zk.mjs +1793 -0
  14. package/build/extract-ByFfc8Zk.mjs.map +1 -0
  15. package/build/hours.d.mts +26 -0
  16. package/build/hours.mjs +48 -1
  17. package/build/hours.mjs.map +1 -1
  18. package/build/{html-nWYFVsRU.d.mts → html-BACljlkf.d.mts} +8 -9
  19. package/build/{html-oNpGAHwi.mjs → html-DkQUAE_d.mjs} +18 -39
  20. package/build/html-DkQUAE_d.mjs.map +1 -0
  21. package/build/html.d.mts +2 -1
  22. package/build/html.mjs +3 -1
  23. package/build/index.d.mts +582 -12
  24. package/build/index.mjs +903 -6
  25. package/build/index.mjs.map +1 -0
  26. package/build/log-Dx-wXjJl.mjs +342 -0
  27. package/build/log-Dx-wXjJl.mjs.map +1 -0
  28. package/build/loopback-C_QYowSf.mjs +22 -0
  29. package/build/loopback-C_QYowSf.mjs.map +1 -0
  30. package/build/mcp.d.mts +164 -5
  31. package/build/mcp.mjs +1115 -42
  32. package/build/mcp.mjs.map +1 -1
  33. package/build/ollama-BJxkjny4.mjs +160 -0
  34. package/build/ollama-BJxkjny4.mjs.map +1 -0
  35. package/build/open-C4fhXJgD.mjs +4544 -0
  36. package/build/open-C4fhXJgD.mjs.map +1 -0
  37. package/build/{browser-gJ_h4AhB.mjs → render-C1CUraWF.mjs} +31 -74
  38. package/build/render-C1CUraWF.mjs.map +1 -0
  39. package/build/rolldown-runtime-D7D4PA-g.mjs +13 -0
  40. package/build/{open-DYZ7KL2A.mjs → runtime-JToRO63o.mjs} +986 -1657
  41. package/build/runtime-JToRO63o.mjs.map +1 -0
  42. package/build/transformers-BQMlaGj4.mjs +133 -0
  43. package/build/transformers-BQMlaGj4.mjs.map +1 -0
  44. package/build/urls-BHFeSyEK.mjs +62 -0
  45. package/build/urls-BHFeSyEK.mjs.map +1 -0
  46. package/build/urls-BTDSgDLC.d.mts +16 -0
  47. package/build/version-ntbY-6wh.mjs +38 -0
  48. package/build/version-ntbY-6wh.mjs.map +1 -0
  49. package/build/wacz-BFNG42Kt.mjs +275 -0
  50. package/build/wacz-BFNG42Kt.mjs.map +1 -0
  51. package/build/warc-CVS0t1fW.mjs +728 -0
  52. package/build/warc-CVS0t1fW.mjs.map +1 -0
  53. package/build/webbotauth-CjJdTAfE.mjs +568 -0
  54. package/build/webbotauth-CjJdTAfE.mjs.map +1 -0
  55. package/package.json +10 -2
  56. package/record.schema.json +15 -1
  57. package/build/browser-gJ_h4AhB.mjs.map +0 -1
  58. package/build/html-oNpGAHwi.mjs.map +0 -1
  59. package/build/open-DYZ7KL2A.mjs.map +0 -1
  60. package/build/open-eHcriTds.d.mts +0 -1499
  61. package/build/version-no12qgos.mjs +0 -11
  62. package/build/version-no12qgos.mjs.map +0 -1
package/build/cli.mjs CHANGED
@@ -1,58 +1,29 @@
1
1
  #!/usr/bin/env node
2
- import { r as VERSION } from "./version-no12qgos.mjs";
2
+ import { i as durationMs, r as VERSION } from "./version-ntbY-6wh.mjs";
3
+ import { t as isLoopbackHost } from "./loopback-C_QYowSf.mjs";
3
4
  import { join, resolve } from "node:path";
4
- import { homedir } from "node:os";
5
+ import { readFileSync } from "node:fs";
5
6
  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
- `;
7
+ import { homedir } from "node:os";
8
+ //#region src/cli-line.ts
54
9
  /** A usage error is a wrong command line. It exits with code 2. */
55
10
  var UsageError = class extends Error {};
11
+ /** These options belong to the command `export`. */
12
+ const EXPORT_OPTIONS = {
13
+ format: { type: "string" },
14
+ out: { type: "string" },
15
+ since: { type: "string" },
16
+ until: { type: "string" },
17
+ host: { type: "string" },
18
+ prefix: { type: "string" },
19
+ id: {
20
+ type: "string",
21
+ multiple: true
22
+ },
23
+ "include-refusals": { type: "boolean" },
24
+ overwrite: { type: "boolean" }
25
+ };
26
+ /** These are the options of the command line: the options of the commands and of `export`. */
56
27
  const OPTIONS = {
57
28
  dir: { type: "string" },
58
29
  contact: { type: "string" },
@@ -75,19 +46,119 @@ const OPTIONS = {
75
46
  from: { type: "string" },
76
47
  length: { type: "string" },
77
48
  "user-provided": { type: "boolean" },
78
- "allow-user-provided": { type: "boolean" }
49
+ "allow-user-provided": { type: "boolean" },
50
+ "max-urls": { type: "string" },
51
+ feed: {
52
+ type: "string",
53
+ multiple: true
54
+ },
55
+ "max-pages": { type: "string" },
56
+ "max-depth": { type: "string" },
57
+ budget: { type: "string" },
58
+ include: {
59
+ type: "string",
60
+ multiple: true
61
+ },
62
+ exclude: {
63
+ type: "string",
64
+ multiple: true
65
+ },
66
+ sitemaps: { type: "boolean" },
67
+ "allow-host": {
68
+ type: "string",
69
+ multiple: true
70
+ },
71
+ at: { type: "string" },
72
+ to: { type: "string" },
73
+ every: { type: "string" },
74
+ confirm: { type: "string" },
75
+ scope: { type: "string" },
76
+ label: {
77
+ type: "string",
78
+ multiple: true
79
+ },
80
+ "archive-fallback": { type: "boolean" },
81
+ "archive-despite-robots": { type: "boolean" },
82
+ "sign-key": { type: "string" },
83
+ "signature-agent": { type: "string" },
84
+ tick: { type: "string" },
85
+ jitter: { type: "string" },
86
+ "tick-budget": { type: "string" },
87
+ "tick-pages": { type: "string" },
88
+ grace: { type: "string" },
89
+ events: { type: "string" },
90
+ "on-change": { type: "string" },
91
+ "mcp-http": { type: "boolean" },
92
+ "mcp-port": { type: "string" },
93
+ "mcp-host": { type: "string" },
94
+ "mcp-token-file": { type: "string" },
95
+ "allow-remote": { type: "boolean" },
96
+ field: {
97
+ type: "string",
98
+ multiple: true
99
+ },
100
+ capture: { type: "string" },
101
+ embedder: { type: "string" },
102
+ "embed-model": { type: "string" },
103
+ "ollama-url": { type: "string" },
104
+ "allow-remote-ollama": { type: "boolean" },
105
+ "max-docs": { type: "string" },
106
+ ...EXPORT_OPTIONS
79
107
  };
80
- /** These options belong to one command. */
108
+ /** These options belong to `daemon`, and not to `daemon status`. */
109
+ const DAEMON_RUN_OPTIONS = [
110
+ "tick",
111
+ "jitter",
112
+ "tick-budget",
113
+ "tick-pages",
114
+ "grace",
115
+ "events",
116
+ "on-change",
117
+ "mcp-http",
118
+ "mcp-port",
119
+ "mcp-host",
120
+ "mcp-token-file",
121
+ "allow-remote"
122
+ ];
123
+ /** These options belong to one command, or to the commands that the text names. */
81
124
  const OWNER = {
82
- render: "fetch",
83
- "max-age": "fetch",
125
+ render: "fetch crawl watch",
126
+ "max-age": "fetch crawl discover",
84
127
  limit: "find",
85
- kind: "find",
128
+ kind: "find embed",
86
129
  rank: "find",
87
- from: "read",
130
+ from: "read diff",
88
131
  length: "read",
89
- "user-provided": "read",
90
- "allow-user-provided": "mcp"
132
+ "user-provided": "read extract",
133
+ "allow-user-provided": "mcp daemon",
134
+ ...Object.fromEntries(Object.keys(EXPORT_OPTIONS).map((option) => [option, "export"])),
135
+ "max-urls": "discover",
136
+ feed: "discover",
137
+ "max-pages": "crawl watch",
138
+ "max-depth": "crawl",
139
+ budget: "crawl watch embed",
140
+ include: "crawl",
141
+ exclude: "crawl",
142
+ sitemaps: "crawl",
143
+ "allow-host": "crawl discover",
144
+ at: "read verify extract",
145
+ to: "diff",
146
+ every: "watch",
147
+ confirm: "watch",
148
+ scope: "watch",
149
+ label: "watch",
150
+ "archive-fallback": "fetch",
151
+ "archive-despite-robots": "fetch",
152
+ "sign-key": "fetch discover crawl watch mcp key-directory daemon",
153
+ "signature-agent": "fetch discover crawl watch mcp key-directory daemon",
154
+ ...Object.fromEntries(DAEMON_RUN_OPTIONS.map((option) => [option, "daemon"])),
155
+ field: "extract",
156
+ capture: "extract",
157
+ embedder: "find embed mcp",
158
+ "embed-model": "find embed mcp",
159
+ "ollama-url": "find embed mcp",
160
+ "allow-remote-ollama": "find embed mcp",
161
+ "max-docs": "embed"
91
162
  };
92
163
  /** These are the commands, with the smallest and the largest count of arguments. */
93
164
  const COMMANDS = {
@@ -120,6 +191,51 @@ const COMMANDS = {
120
191
  min: 0,
121
192
  max: 0,
122
193
  wants: "mcp takes no argument"
194
+ },
195
+ export: {
196
+ min: 0,
197
+ max: 0,
198
+ wants: "export takes no argument: give the file with --out"
199
+ },
200
+ discover: {
201
+ min: 1,
202
+ max: 1,
203
+ wants: "discover wants one site URL"
204
+ },
205
+ crawl: {
206
+ min: 1,
207
+ max: Infinity,
208
+ wants: "crawl wants at least one URL"
209
+ },
210
+ diff: {
211
+ min: 1,
212
+ max: 1,
213
+ wants: "diff wants one document id"
214
+ },
215
+ watch: {
216
+ min: 1,
217
+ max: Infinity,
218
+ wants: "watch wants add, remove, list or run"
219
+ },
220
+ daemon: {
221
+ min: 0,
222
+ max: 1,
223
+ wants: "daemon takes no argument, or status"
224
+ },
225
+ "key-directory": {
226
+ min: 0,
227
+ max: 0,
228
+ wants: "key-directory takes no argument: give the key with --sign-key"
229
+ },
230
+ extract: {
231
+ min: 1,
232
+ max: 1,
233
+ wants: "extract wants one document id and --field name=value"
234
+ },
235
+ embed: {
236
+ min: 0,
237
+ max: 0,
238
+ wants: "embed takes no argument: give the model with --embedder"
123
239
  }
124
240
  };
125
241
  const parse = (args) => {
@@ -144,8 +260,8 @@ const whole = (name, text, fallback, min) => {
144
260
  return value;
145
261
  };
146
262
  /** 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];
263
+ const envFlag = (env, name) => {
264
+ const raw = env[name];
149
265
  const value = raw?.trim().toLowerCase() ?? "";
150
266
  if ([
151
267
  "",
@@ -180,19 +296,48 @@ const FS_CODES = /* @__PURE__ */ new Set([
180
296
  "EISDIR"
181
297
  ]);
182
298
  const ok = (page) => page.outcome === "ok" || page.outcome === "not-modified";
299
+ /** A fetch is done when the page came, or when an archived copy came in its place. */
300
+ const done = (page) => ok(page) || page.archived !== void 0;
183
301
  const describePage = (page) => {
302
+ const head = `${ok(page) ? "✓" : page.archived !== void 0 ? "~" : "✗"} ${page.url}`;
303
+ const facts = [
304
+ `${page.outcome} ${String(page.status)}`,
305
+ page.doc === null ? null : `doc ${page.doc}`,
306
+ page.chars > 0 ? `${String(page.chars)} chars` : null,
307
+ page.rendered ? "rendered" : null,
308
+ page.structured.length > 0 ? `JSON-LD: ${page.structured.join(", ")}` : null
309
+ ].filter((fact) => fact !== null);
310
+ const archived = page.archived === void 0 ? [] : [` archived copy (${page.archived.source}, captured ${page.archived.timestamp}): ${page.archived.url}`];
184
311
  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(" · ")}`,
312
+ head,
313
+ ` ${facts.join(" · ")}`,
314
+ ...archived,
193
315
  ...page.note === null ? [] : [` ${page.note}`]
194
316
  ].join("\n");
195
317
  };
318
+ /** This function reads the key file of `--sign-key`. An error names the file, and not the key. */
319
+ const keyFile = (path) => {
320
+ const file = path === "~" ? homedir() : /^~[\\/]/.test(path) ? resolve(homedir(), path.slice(2)) : resolve(path);
321
+ try {
322
+ return readFileSync(file, "utf8");
323
+ } catch (error) {
324
+ const code = error.code;
325
+ throw new UsageError(`--sign-key: the file ${file} does not open (${typeof code === "string" ? code : "error"})`, { cause: error });
326
+ }
327
+ };
328
+ /**
329
+ * This function gives the type of the URL of `--signature-agent`. An origin names a key directory at
330
+ * the well-known path. A URL with a path names a JSON Web Key Set (`jwks_uri`), for a host that cannot
331
+ * serve the well-known path, for example a project site of GitHub Pages.
332
+ */
333
+ const agentTypeOf = (url) => {
334
+ try {
335
+ const parsed = new URL(url);
336
+ return parsed.pathname === "/" && parsed.search === "" ? "directory" : "jwks_uri";
337
+ } catch {
338
+ return "directory";
339
+ }
340
+ };
196
341
  /** This is the result of a URL that the crawler did not fetch. */
197
342
  const failedPage = (url, note) => ({
198
343
  url,
@@ -212,59 +357,841 @@ const failedPage = (url, note) => ({
212
357
  held_back: false,
213
358
  note
214
359
  });
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;
360
+ const crawlOptionsOf = (values) => {
361
+ let budgetMs = 6e5;
362
+ if (values.budget !== void 0) try {
363
+ budgetMs = durationMs(values.budget);
364
+ } catch (error) {
365
+ throw new UsageError(`--budget: ${error.message}`, { cause: error });
222
366
  }
223
- const [command, ...rest] = positionals;
224
- if (values.help === true || command === void 0) {
225
- process.stdout.write(HELP);
226
- return 0;
367
+ return {
368
+ maxUrls: whole("max-urls", values["max-urls"], 1e3, 1),
369
+ feeds: values.feed ?? [],
370
+ maxPages: whole("max-pages", values["max-pages"], 100, 1),
371
+ maxDepth: whole("max-depth", values["max-depth"], 3, 0),
372
+ budgetMs,
373
+ include: values.include ?? [],
374
+ exclude: values.exclude ?? [],
375
+ sitemaps: values.sitemaps === true,
376
+ allowHosts: values["allow-host"]
377
+ };
378
+ };
379
+ 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}.`;
380
+ /** A text that starts with a date is a time. A different text is a capture id. */
381
+ const DATE_START = /^\d{4}-\d{2}-\d{2}/;
382
+ /** This function reads a time of the command line. A text that is not a time is a usage error. */
383
+ const timeOf = (name, text) => {
384
+ if (text === void 0) return void 0;
385
+ 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}"`);
386
+ return text;
387
+ };
388
+ /** This function reads a version of `diff`: a time, or a capture id. */
389
+ const versionOf = (name, text) => {
390
+ if (text === void 0) return void 0;
391
+ if (text.trim() === "") throw new UsageError(`--${name} wants a capture id or a time`);
392
+ return DATE_START.test(text) ? { at: timeOf(name, text) } : text;
393
+ };
394
+ const ACTIONS = {
395
+ add: [1, Infinity],
396
+ remove: [1, Infinity],
397
+ list: [0, 0],
398
+ run: [0, 0]
399
+ };
400
+ /** These options belong to one action of `watch`. */
401
+ const WATCH_OWNER = {
402
+ every: "add",
403
+ confirm: "add",
404
+ scope: "add",
405
+ label: "add",
406
+ render: "add",
407
+ budget: "run",
408
+ "max-pages": "run",
409
+ "sign-key": "run",
410
+ "signature-agent": "run"
411
+ };
412
+ const watchLineOf = (values, rest) => {
413
+ const [action, ...urls] = rest;
414
+ const counts = action === void 0 ? void 0 : ACTIONS[action];
415
+ if (counts === void 0) throw new UsageError(`watch wants add, remove, list or run, not "${action ?? ""}"`);
416
+ 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`);
417
+ 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}`);
418
+ if (values.every !== void 0) {
419
+ let ms;
420
+ try {
421
+ ms = durationMs(values.every);
422
+ } catch (error) {
423
+ throw new UsageError(`--every: ${error.message}`, { cause: error });
424
+ }
425
+ if (ms < 36e5) throw new UsageError("--every wants 1h or more, so a watch cannot hammer a site");
426
+ }
427
+ if (values.confirm !== void 0 && values.confirm !== "1" && values.confirm !== "2") throw new UsageError(`--confirm is 1 or 2, not "${values.confirm}"`);
428
+ let labels;
429
+ for (const label of values.label ?? []) {
430
+ const at = label.indexOf("=");
431
+ if (at < 1) throw new UsageError(`--label wants name=value, not "${label}"`);
432
+ labels = {
433
+ ...labels,
434
+ [label.slice(0, at)]: label.slice(at + 1)
435
+ };
436
+ }
437
+ 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}"`);
438
+ return {
439
+ action,
440
+ urls: [...new Set(urls)],
441
+ every: values.every,
442
+ confirm: values.confirm === void 0 ? void 0 : values.confirm === "1" ? 1 : 2,
443
+ labels
444
+ };
445
+ };
446
+ const everyText = (ms) => ms % 864e5 === 0 ? `${String(ms / 864e5)}d` : ms % 36e5 === 0 ? `${String(ms / 36e5)}h` : `${String(Math.round(ms / 6e4))}m`;
447
+ const describeEntry = (entry) => {
448
+ const facts = [
449
+ `every ${everyText(entry.every_ms)}`,
450
+ `next ${entry.next_at}`,
451
+ entry.checked_at === null ? "not checked yet" : `last ${entry.last_kind ?? "check"} at ${entry.checked_at}`,
452
+ `${String(entry.checks)} checks, ${String(entry.changes)} changes`
453
+ ];
454
+ return [
455
+ entry.url,
456
+ ` ${facts.join(" · ")}`,
457
+ ...entry.pending === null ? [] : [` suspected: ${entry.pending.kind} since ${entry.pending.since}`]
458
+ ].join("\n");
459
+ };
460
+ const describeEvent = (event) => {
461
+ const state = event.suspected ? " (suspected)" : event.confirmed ? " (confirmed)" : "";
462
+ const facts = [
463
+ `${event.outcome} ${String(event.status)}`,
464
+ event.capture === null ? null : `capture ${event.capture}`,
465
+ event.previous === null || event.previous === event.capture ? null : `from ${event.previous}`,
466
+ event.diff === null ? null : `+${String(event.diff.lines_added)} −${String(event.diff.lines_removed)} lines, ${String(event.diff.meta_changes)} metadata changes`,
467
+ event.held_back ? "held back" : null
468
+ ].filter((fact) => fact !== null);
469
+ return [
470
+ `${event.kind}${state} ${event.url}`,
471
+ ` ${facts.join(" · ")}`,
472
+ ...event.note === null ? [] : [` ${event.note}`]
473
+ ].join("\n");
474
+ };
475
+ 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}.`;
476
+ /** This function reads a file name of the command line: a leading `~` is the home folder. */
477
+ const fileOf = (text) => text === "~" ? homedir() : /^~[\\/]/.test(text) ? resolve(homedir(), text.slice(2)) : resolve(text);
478
+ /** This function reads a duration of `daemon`, with a floor in milliseconds. */
479
+ const daemonDuration = (name, text, floor) => {
480
+ if (text === void 0) return void 0;
481
+ let ms;
482
+ try {
483
+ ms = durationMs(text);
484
+ } catch (error) {
485
+ throw new UsageError(`--${name}: ${error.message}`, { cause: error });
486
+ }
487
+ if (ms < floor) throw new UsageError(floor === 0 ? `--${name} wants 0 or more` : `--${name} wants ${String(floor / 1e3)}s or more`);
488
+ return ms;
489
+ };
490
+ /** These options of `daemon` belong to its MCP server, so they need `--mcp-http`. */
491
+ const MCP_OPTIONS = [
492
+ "mcp-port",
493
+ "mcp-host",
494
+ "mcp-token-file",
495
+ "allow-remote"
496
+ ];
497
+ /** This function checks the options of `daemon` and of `daemon status`. */
498
+ const daemonLineOf = (values, rest) => {
499
+ const [action] = rest;
500
+ if (action !== void 0 && action !== "status") throw new UsageError(`daemon takes no argument, or status, not "${action}"`);
501
+ if (action === "status") {
502
+ const run = [
503
+ ...DAEMON_RUN_OPTIONS,
504
+ "allow-user-provided",
505
+ "sign-key",
506
+ "signature-agent"
507
+ ];
508
+ for (const option of run) if (values[option] !== void 0) throw new UsageError(`--${option} is an option of daemon, not of daemon status`);
509
+ }
510
+ if (values["mcp-http"] !== true) {
511
+ for (const option of MCP_OPTIONS) if (values[option] !== void 0) throw new UsageError(`--${option} goes with --mcp-http`);
512
+ }
513
+ const host = (values["mcp-host"] ?? "127.0.0.1").trim();
514
+ if (host === "") throw new UsageError("--mcp-host wants a host");
515
+ const allowRemote = values["allow-remote"] === true;
516
+ if (values["mcp-http"] === true && !isLoopbackHost(host) && !allowRemote) throw new UsageError(`--mcp-host ${host} is not a loopback address: add --allow-remote to serve other computers, after you read the warning in the README`);
517
+ const port = whole("mcp-port", values["mcp-port"], 9307, 0);
518
+ if (port > 65535) throw new UsageError("--mcp-port wants a port from 0 to 65535");
519
+ const onChange = values["on-change"];
520
+ if (onChange !== void 0 && onChange.trim() === "") throw new UsageError("--on-change wants a command");
521
+ const events = values.events;
522
+ if (events !== void 0 && events.trim() === "") throw new UsageError("--events wants a file");
523
+ const tokenFile = values["mcp-token-file"];
524
+ if (tokenFile !== void 0 && tokenFile.trim() === "") throw new UsageError("--mcp-token-file wants a file");
525
+ return {
526
+ action: action === "status" ? "status" : "run",
527
+ tickMs: daemonDuration("tick", values.tick, 1e3),
528
+ jitterMs: daemonDuration("jitter", values.jitter, 0),
529
+ budgetMs: daemonDuration("tick-budget", values["tick-budget"], 1e3),
530
+ maxPages: values["tick-pages"] === void 0 ? void 0 : whole("tick-pages", values["tick-pages"], 1, 1),
531
+ graceMs: daemonDuration("grace", values.grace, 0),
532
+ events: events === void 0 ? void 0 : fileOf(events),
533
+ onChange,
534
+ mcp: values["mcp-http"] === true ? {
535
+ port,
536
+ host,
537
+ allowRemote,
538
+ tokenFile: tokenFile === void 0 ? void 0 : fileOf(tokenFile)
539
+ } : void 0
540
+ };
541
+ };
542
+ /** This function gives the first line of `daemon status`: the daemon runs, stopped, or stopped with no clean stop. */
543
+ const daemonHead = (state) => state.alive ? `the daemon runs (process ${String(state.pid)}) since ${state.started_at}, on the store ${state.store}` : state.running ? `the daemon of process ${String(state.pid)} does not run: it stopped with no clean stop. Its state is of ${state.updated_at}` : `the daemon stopped at ${state.stopped_at ?? state.updated_at}`;
544
+ /** This function gives the line of the last tick of `daemon status`. */
545
+ const lastTick = (state) => {
546
+ const tick = state.last_tick;
547
+ if (tick === null) return "last tick: none yet";
548
+ if (tick.summary === null) return `last tick: ${tick.ended_at}, failed: ${tick.error ?? "error"}`;
549
+ const summary = tick.summary;
550
+ return `last tick: ${tick.ended_at}, ${String(summary.checked)} checked, ${String(summary.confirmed)} changes, ${String(summary.refused)} refused, ${String(summary.error)} errors (${summary.stopped})`;
551
+ };
552
+ /** This function gives the text of `daemon status`. */
553
+ const describeDaemonStatus = (state, dir) => {
554
+ if (state === void 0) return `no daemon has run on the store ${dir}`;
555
+ const lines = [daemonHead(state), lastTick(state)];
556
+ if (state.alive && state.next_tick_at !== null) lines.push(`next tick: ${state.next_tick_at}`);
557
+ lines.push(`${String(state.ticks)} ticks, ${String(state.checked)} checks, ${String(state.changes)} changes`);
558
+ if (state.next_due.length === 0) lines.push("next due: no watch");
559
+ else {
560
+ lines.push("next due:");
561
+ for (const due of state.next_due) lines.push(` ${due.next_at} ${due.url} (every ${everyText(due.every_ms)}, last ${due.last_kind ?? "none"})`);
562
+ }
563
+ if (state.mcp !== null) {
564
+ const where = state.mcp.token_file === null ? "" : ` (the token is in ${state.mcp.token_file})`;
565
+ lines.push(`MCP: ${state.mcp.url}${where}`);
566
+ lines.push(state.tasks.length === 0 ? "tasks: none running" : `tasks: ${String(state.tasks.length)} running`);
567
+ for (const task of state.tasks) lines.push(` ${task.id} ${task.tool} ${task.status}${task.message === null ? "" : `: ${task.message}`}`);
568
+ }
569
+ lines.push(state.last_error === null ? "last error: none" : `last error: ${state.last_error.at}: ${state.last_error.message}`);
570
+ return lines.join("\n");
571
+ };
572
+ /** These are the kinds of a field (`FIELD_KINDS` of `extract.ts`). The command line knows them before it loads the crawler. */
573
+ const KINDS = [
574
+ "text",
575
+ "number",
576
+ "money",
577
+ "date",
578
+ "url",
579
+ "email",
580
+ "phone"
581
+ ];
582
+ /** This function reads the fields of the command line: `name=value`, with `:kind` at the end. The name and the value cannot be empty. */
583
+ const fieldsOf = (given) => {
584
+ if (given === void 0 || given.length === 0) throw new UsageError("extract wants at least one --field name=value[:kind]");
585
+ const fields = {};
586
+ for (const text of given) {
587
+ const equals = text.indexOf("=");
588
+ if (equals <= 0) throw new UsageError(`--field wants name=value[:kind], not "${text}"`);
589
+ const name = text.slice(0, equals);
590
+ let value = text.slice(equals + 1);
591
+ let kind;
592
+ const jsonLd = value.lastIndexOf(":json-ld:");
593
+ const colon = value.lastIndexOf(":");
594
+ if (jsonLd !== -1) {
595
+ kind = value.slice(jsonLd + 1);
596
+ value = value.slice(0, jsonLd);
597
+ 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`);
598
+ } else if (colon !== -1 && KINDS.includes(value.slice(colon + 1))) {
599
+ kind = value.slice(colon + 1);
600
+ value = value.slice(0, colon);
601
+ }
602
+ if (value.trim() === "") throw new UsageError(`--field ${name} wants a value`);
603
+ if (fields[name] !== void 0) throw new UsageError(`--field ${name} is given two times`);
604
+ fields[name] = kind === void 0 ? { value } : {
605
+ value,
606
+ kind
607
+ };
608
+ }
609
+ return fields;
610
+ };
611
+ /**
612
+ * This function reads the embedder of the command line, or of the environment for the commands
613
+ * that use one. `--embed-model` and `--ollama-url` need `--embedder`.
614
+ */
615
+ const embedderOf = (values, env, command) => {
616
+ const uses = OWNER["embedder"].split(" ").includes(command);
617
+ const fromEnv = (name) => uses ? env[name] || void 0 : void 0;
618
+ const name = values.embedder ?? fromEnv("POLITE_CRAWLER_EMBEDDER");
619
+ const model = values["embed-model"] ?? fromEnv("POLITE_CRAWLER_EMBED_MODEL");
620
+ const url = values["ollama-url"] ?? fromEnv("POLITE_CRAWLER_OLLAMA_URL");
621
+ const allowRemote = values["allow-remote-ollama"] === true || uses && envFlag(env, "POLITE_CRAWLER_ALLOW_REMOTE_OLLAMA");
622
+ if (name === void 0) {
623
+ if (values["embed-model"] !== void 0 || values["ollama-url"] !== void 0) throw new UsageError("--embed-model and --ollama-url go with --embedder transformers or ollama");
624
+ return;
625
+ }
626
+ if (name !== "transformers" && name !== "ollama") throw new UsageError(`--embedder is transformers or ollama, not "${name}"`);
627
+ if (model !== void 0 && model.trim() === "") throw new UsageError("--embed-model wants a model");
628
+ if (values["ollama-url"] !== void 0 && name !== "ollama") throw new UsageError("--ollama-url goes with --embedder ollama");
629
+ return {
630
+ kind: name,
631
+ model,
632
+ url: name === "ollama" ? url : void 0,
633
+ allowRemote
634
+ };
635
+ };
636
+ /** This function gives the text of `embed`: the counts and the reason of the stop. */
637
+ const describeEmbed = (result) => [`${String(result.embedded)} texts got vectors (${String(result.chunks)} chunks); ${String(result.reused)} documents had vectors; ${String(result.held_back)} held back, so not sent to the model`, `${String(result.docs)} documents examined in ${String(result.ms)} ms with ${result.embedder}. Stopped (${result.stopped})${result.stopped === "done" ? "" : ": start embed again for the rest"}.`].join("\n");
638
+ /** 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. */
639
+ const fetchedPages = (urls, settled, renderOff) => settled.map((result, at) => {
640
+ const url = urls[at];
641
+ if (result.status === "rejected") {
642
+ const reason = result.reason;
643
+ return failedPage(url, `${url}: ${reason instanceof Error ? reason.message : String(reason)}`);
644
+ }
645
+ const page = result.value;
646
+ return renderOff !== void 0 && page.note !== null && /no browser/i.test(page.note) ? {
647
+ ...page,
648
+ note: `this page needs JavaScript, and rendering is off: ${renderOff}`
649
+ } : page;
650
+ });
651
+ /** This function gives the text of the results of `find`. An archived copy says so. */
652
+ 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}]`}${hit.passage === void 0 ? "" : ` [passage ${String(hit.passage.start)}–${String(hit.passage.end)}]`}\n ${hit.snippet.replace(/\s+/g, " ")}`).join("\n");
653
+ /** This function gives the text of `read`. With `--at`, the head names the capture and its fetch time. */
654
+ const describeRead = (read, at) => {
655
+ const version = at === void 0 ? "" : ` ${read.capture} (${read.fetched_at})`;
656
+ 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}`;
657
+ };
658
+ /** This function gives the text of `verify`. An archived copy and an earlier capture say so. */
659
+ 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";
660
+ /** This function gives the lines of `discover`: each URL with its date, then the count of the files. */
661
+ const describeDiscover = (found) => {
662
+ const read = found.sources.filter((source) => source.kind !== null).length;
663
+ 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}`})`];
664
+ };
665
+ /** This function gives the text of `reindex`: the counts, the documents that stay, and the captures that it skipped. */
666
+ const describeReindex = (counts) => [
667
+ `${String(counts.captures)} captures replayed, ${String(counts.docs)} documents, ${String(counts.urls)} URLs`,
668
+ ...counts.carried > 0 ? [`${String(counts.carried)} documents of other recipes kept: ${counts.unknown_recipes.join(", ")}`] : [],
669
+ ...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`] : []
670
+ ].join("\n");
671
+ /** This function gives the lines of `extract`: a line for each field, its candidates and its note, then the counts. */
672
+ const describeExtract = (result) => {
673
+ const lines = [];
674
+ for (const [name, field] of Object.entries(result.fields)) {
675
+ const mark = field.status === "verified" || field.status === "normalized" ? "✓" : "✗";
676
+ 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}` : "";
677
+ const how = field.normalization === null ? "" : ` (${field.normalization})`;
678
+ const stored = field.raw === null ? "" : `: ${JSON.stringify(field.raw)}`;
679
+ lines.push(`${mark} ${name}: ${field.status}${how}${place}${stored}`);
680
+ for (const candidate of field.candidates) {
681
+ const at = candidate.span !== null ? `${String(candidate.span.start)}–${String(candidate.span.end)}` : candidate.path ?? "";
682
+ lines.push(` ${at}: ${JSON.stringify(candidate.raw)} can be ${candidate.readings.join(" or ")}`);
683
+ }
684
+ if (field.note !== null && field.status !== "held-back") lines.push(` ${field.note}`);
227
685
  }
686
+ const summary = result.summary;
687
+ 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}`}`);
688
+ return lines;
689
+ };
690
+ /** This function gives the exit code of `extract`: 0 when each field is verified or normalized. */
691
+ const extractCode = (result) => {
692
+ const done = result.summary.verified + result.summary.normalized;
693
+ return done === result.summary.fields && done > 0 ? 0 : 1;
694
+ };
695
+ /** This function checks the options of `export`, and gives its format and its options. */
696
+ const exportPlan = (values) => {
697
+ const out = values.out;
698
+ if (out === void 0 || out.trim() === "") throw new UsageError("export wants --out <file>");
699
+ const format = values.format ?? (/\.wacz$/i.test(out) ? "wacz" : /\.warc\.gz$/i.test(out) ? "warc" : void 0);
700
+ 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}"`);
701
+ for (const name of ["since", "until"]) {
702
+ const value = values[name];
703
+ if (value !== void 0 && !Number.isFinite(Date.parse(value))) throw new UsageError(`--${name} wants a time, such as 2026-10-01, not "${value}"`);
704
+ }
705
+ 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}"`);
706
+ return {
707
+ format,
708
+ options: {
709
+ out: out === "~" ? homedir() : /^~[\\/]/.test(out) ? resolve(homedir(), out.slice(2)) : resolve(out),
710
+ since: values.since,
711
+ until: values.until,
712
+ host: values.host,
713
+ urlPrefix: values.prefix,
714
+ ids: values.id,
715
+ includeRefusals: values["include-refusals"] === true,
716
+ overwrite: values.overwrite === true
717
+ }
718
+ };
719
+ };
720
+ /** This function gives the text of an export: the file, the counts, and what the archive left out. */
721
+ const describeExport = (result, includeRefusals) => {
722
+ const left = [
723
+ result.bodiless > 0 && !includeRefusals ? `${String(result.bodiless)} captures with no body (add --include-refusals to keep them as metadata)` : null,
724
+ result.missing > 0 ? `${String(result.missing)} captures with no stored body` : null,
725
+ result.damaged > 0 ? `${String(result.damaged)} damaged lines of the log` : null
726
+ ].filter((line) => line !== null);
727
+ return [
728
+ `wrote ${result.out} (${String(result.bytes)} bytes)`,
729
+ ` ${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` : ""}`,
730
+ ...left.length > 0 ? [` left out: ${left.join(", ")}`] : []
731
+ ].join("\n");
732
+ };
733
+ /**
734
+ * This function reads the whole command line before anything opens the store. A wrong command line
735
+ * throws a `UsageError`, so the command exits with code 2 and makes no store.
736
+ */
737
+ const readLine = (args, env) => {
738
+ const { values, positionals } = parse(args);
739
+ if (values.version === true) return { kind: "version" };
740
+ const [command, ...rest] = positionals;
741
+ if (values.help === true || command === void 0) return { kind: "help" };
228
742
  const spec = COMMANDS[command];
229
743
  if (spec === void 0) throw new UsageError(`no command "${command}"`);
230
744
  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}`);
745
+ for (const [option, owner] of Object.entries(OWNER)) {
746
+ const owners = owner.split(" ");
747
+ if (values[option] !== void 0 && !owners.includes(command)) throw new UsageError(`--${option} is an option of ${owners.join(" or ")}`);
748
+ }
232
749
  const render = values.render ?? "auto";
233
750
  if (render !== "auto" && render !== "never" && render !== "always") throw new UsageError(`--render is auto, never or always, not "${render}"`);
234
751
  const rank = values.rank ?? "exact";
235
- if (rank !== "exact" && rank !== "fast") throw new UsageError(`--rank is exact or fast, not "${rank}"`);
752
+ if (rank !== "exact" && rank !== "fast" && rank !== "hybrid") throw new UsageError(`--rank is exact, fast or hybrid, not "${rank}"`);
753
+ const embedder = embedderOf(values, env, command);
754
+ if (rank === "hybrid" && embedder === void 0) throw new UsageError("--rank hybrid wants --embedder transformers or ollama");
755
+ if (command === "embed" && embedder === void 0) throw new UsageError("embed wants --embedder transformers or ollama");
756
+ const maxDocs = values["max-docs"] === void 0 ? void 0 : whole("max-docs", values["max-docs"], 1, 1);
236
757
  const limit = whole("limit", values.limit, 10, 1);
237
- const from = whole("from", values.from, 0, 0);
758
+ const from = command === "read" ? whole("from", values.from, 0, 0) : 0;
238
759
  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");
760
+ const crawlLimits = crawlOptionsOf(values);
761
+ const at = timeOf("at", values.at);
762
+ const diffFrom = command === "diff" ? versionOf("from", values.from) : void 0;
763
+ const diffTo = versionOf("to", values.to);
764
+ const watchLine = command === "watch" ? watchLineOf(values, rest) : void 0;
765
+ const extractFields = command === "extract" ? fieldsOf(values.field) : void 0;
766
+ const daemonLine = command === "daemon" ? daemonLineOf(values, rest) : void 0;
767
+ const dir = storeDir(values.dir ?? (env["POLITE_CRAWLER_DIR"] || void 0));
768
+ const allowPrivate = values["allow-private"] === true || envFlag(env, "POLITE_CRAWLER_ALLOW_PRIVATE");
769
+ const allowUserProvided = values["allow-user-provided"] === true || (command === "mcp" || daemonLine?.action === "run") && envFlag(env, "POLITE_CRAWLER_ALLOW_USER_PROVIDED");
770
+ if (values["archive-despite-robots"] === true && values["archive-fallback"] !== true) throw new UsageError("--archive-despite-robots works only with --archive-fallback");
771
+ const signs = OWNER["sign-key"].split(" ").includes(command) && (watchLine === void 0 || watchLine.action === "run") && (daemonLine === void 0 || daemonLine.action === "run");
772
+ const signKey = values["sign-key"] ?? (signs ? env["POLITE_CRAWLER_SIGN_KEY"] || void 0 : void 0);
773
+ const signatureAgent = values["signature-agent"] ?? (signs ? env["POLITE_CRAWLER_SIGNATURE_AGENT"] || void 0 : void 0);
774
+ if (command === "key-directory") {
775
+ if (signKey === void 0) throw new UsageError("key-directory wants --sign-key <file>");
776
+ } 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");
777
+ let maxAge;
778
+ try {
779
+ maxAge = durationMs(values["max-age"] ?? "24h");
780
+ } catch (error) {
781
+ throw new UsageError(`--max-age: ${error.message}`, { cause: error });
782
+ }
783
+ return {
784
+ kind: "command",
785
+ command,
786
+ rest,
787
+ values,
788
+ render,
789
+ rank,
790
+ limit,
791
+ from,
792
+ length,
793
+ maxAge,
794
+ crawlLimits,
795
+ at,
796
+ diffFrom,
797
+ diffTo,
798
+ watchLine,
799
+ daemonLine,
800
+ extractFields,
801
+ exportPlan: command === "export" ? exportPlan(values) : void 0,
802
+ dir,
803
+ contact: values.contact ?? (env["POLITE_CRAWLER_CONTACT"] || void 0),
804
+ allowPrivate,
805
+ allowUserProvided,
806
+ signKey,
807
+ signatureAgent,
808
+ embedder,
809
+ maxDocs
810
+ };
811
+ };
812
+ //#endregion
813
+ //#region src/cli.ts
814
+ const emitWarning = process.emitWarning.bind(process);
815
+ process.emitWarning = ((warning, ...rest) => {
816
+ const first = rest[0];
817
+ const kind = typeof first === "string" ? first : typeof first === "object" && first !== null && "type" in first ? first.type : warning instanceof Error ? warning.name : void 0;
818
+ const text = typeof warning === "string" ? warning : warning.message;
819
+ if (kind === "ExperimentalWarning" && text.startsWith("SQLite is an experimental feature")) return;
820
+ emitWarning(warning, ...rest);
821
+ });
822
+ const HELP = `polite-crawler ${VERSION}: fetch a source once, keep it, read it many times.
823
+
824
+ Usage:
825
+ polite-crawler fetch <url...> fetch pages into the store (robots.txt obeyed)
826
+ polite-crawler find <query> search the stored pages, with no request
827
+ polite-crawler read <doc> print a range of a stored document
828
+ polite-crawler verify <doc> <quote> check that a quote is in a stored document
829
+ polite-crawler reindex rebuild the search index from the capture log
830
+ polite-crawler mcp serve the tools over MCP (standard input and output)
831
+ polite-crawler export --out <file> write the stored captures as a WARC or WACZ archive
832
+ polite-crawler discover <site> list the pages of a site from its sitemaps and feeds
833
+ polite-crawler crawl <url...> fetch a site and follow its links, within a budget
834
+ polite-crawler diff <doc> show what changed between two versions of a document
835
+ polite-crawler watch add <url...> check these pages again on a schedule
836
+ polite-crawler watch remove <url...> stop the watch of these pages
837
+ polite-crawler watch list print the watch list
838
+ polite-crawler watch run check the pages that are due, within a budget
839
+ polite-crawler daemon check the watch list on a schedule, until SIGINT or SIGTERM;
840
+ with --mcp-http, serve the MCP tools over local HTTP too
841
+ polite-crawler daemon status print the state of the daemon of the store
842
+ polite-crawler key-directory print the key directory (JWKS) of --sign-key, to host
843
+ polite-crawler extract <doc> --field name=value[:kind]...
844
+ check proposed field values against a stored page
845
+ polite-crawler embed --embedder <name> give vectors to the stored pages, for find --rank hybrid
846
+
847
+ Options:
848
+ --dir <path> the store (default $POLITE_CRAWLER_DIR, or ~/.polite-crawler)
849
+ --contact <text> an email (From and User-Agent) or a URL (User-Agent only)
850
+ ($POLITE_CRAWLER_CONTACT)
851
+ --allow-private fetch loopback and private addresses too ($POLITE_CRAWLER_ALLOW_PRIVATE=1)
852
+ --no-browser never open the headless browser
853
+ --sign-key <file> sign each request (Web Bot Auth) with this Ed25519 key: PKCS#8 PEM or JWK
854
+ ($POLITE_CRAWLER_SIGN_KEY)
855
+ --signature-agent <url> the https origin of your key directory, or the URL of a
856
+ JSON Web Key Set on a path, for --sign-key ($POLITE_CRAWLER_SIGNATURE_AGENT)
857
+ --embedder <name> the local model of hybrid search, for find, embed and mcp: transformers
858
+ (transformers.js in this process) or ollama ($POLITE_CRAWLER_EMBEDDER)
859
+ --embed-model <m> the model: Xenova/bge-small-en-v1.5 (transformers) or nomic-embed-text
860
+ (ollama) by default ($POLITE_CRAWLER_EMBED_MODEL)
861
+ --ollama-url <url> the Ollama server, http://127.0.0.1:11434 by default ($POLITE_CRAWLER_OLLAMA_URL)
862
+ --allow-remote-ollama permit an Ollama server on a different computer: the page text goes
863
+ to it ($POLITE_CRAWLER_ALLOW_REMOTE_OLLAMA=1)
864
+ --json print JSON
865
+ -v, --version print the version
866
+ -h, --help print this help
867
+
868
+ fetch --render <mode> auto (default), never or always
869
+ --max-age <age> reuse a stored copy this young: 30m, 24h (default), 7d; 0 fetches again
870
+ --archive-fallback when the site refuses or is down, give a copy from the Wayback
871
+ Machine or Common Crawl, marked archived (not a fresh fetch)
872
+ --archive-despite-robots with --archive-fallback: also when the robots.txt of the
873
+ site disallows the page now
874
+ find --limit <n> how many results (default 10)
875
+ --kind <kind> only this kind of document, such as page or pdf
876
+ --rank <mode> exact (default): BM25 over each match; fast: leave common words
877
+ out of the one-word part, faster for a long question; hybrid: BM25
878
+ and the meaning of the text, fused (needs --embedder)
879
+ read --from <n> where the range starts (default 0)
880
+ --length <n> how many characters (default 4000)
881
+ --user-provided you gave this URL yourself: read a page that refuses AI input (logged)
882
+ read, verify, extract --at <time> use the version that was current then, such as
883
+ 2026-10-01T12:00Z
884
+ reindex rebuild; the documents of recipes that this command cannot read stay
885
+ mcp --allow-user-provided let user_provided work when the client cannot ask you
886
+ ($POLITE_CRAWLER_ALLOW_USER_PROVIDED=1)
887
+ export --out <file> the archive to write: a name that ends in .warc.gz or .wacz
888
+ --format <f> warc or wacz (default: from the name of the file)
889
+ --since <time> only the captures from this time on, such as 2026-10-01
890
+ --until <time> only the captures before this time
891
+ --host <host> only the captures of this host and its subdomains
892
+ --prefix <url> only the captures whose URL starts with this text
893
+ --id <capture> only this capture; give --id again for more captures
894
+ --include-refusals a metadata record for each capture with no body too
895
+ --overwrite replace the file when it exists
896
+ discover --max-urls <n> how many URLs (default 1000)
897
+ --feed <url> read this RSS or Atom feed too (repeat it)
898
+ --max-age <age> as for fetch
899
+ crawl --max-pages <n> how many pages (default 100)
900
+ --max-depth <n> how many links from a seed (default 3; 0 fetches the seeds only)
901
+ --budget <time> the longest time: 30s, 2m, 10m (default); no request starts after it
902
+ --include <pattern> follow only the URLs that match, for example /blog/* (repeat it)
903
+ --exclude <pattern> follow no URL that matches, for example *.pdf$ (repeat it)
904
+ --sitemaps add the pages of the sitemaps of the site
905
+ --render, --max-age as for fetch
906
+ discover, crawl --allow-host <host> keep the URLs on this host too (repeat it)
907
+ diff --from <version> the old version: a capture id or a time (default: the one before --to)
908
+ --to <version> the new version: a capture id or a time (default: the newest)
909
+ watch add --every <time> the time between two checks: 1h or more (default 24h)
910
+ --confirm <n> 2 (default): a change counts when two checks see it; 1: at once
911
+ --scope <text>, --label <name=value> marks for the captures (repeat --label)
912
+ --render <mode> as for fetch
913
+ watch run --budget <time> the longest time (default 10m); no request starts after it
914
+ --max-pages <n> how many pages to check (default 100)
915
+ daemon --tick <time> the longest wait between two ticks: 1s or more (default 1m)
916
+ --jitter <time> a random wait from 0 to this after each tick (default 10s)
917
+ --tick-budget <time> the longest time of the checks of one tick (default 10m)
918
+ --tick-pages <n> how many pages one tick checks at most (default 100)
919
+ --grace <time> at a stop, the checks in progress get this time to end (default 10s)
920
+ --events <file> write each change as one JSON line to this file (default: the
921
+ standard output)
922
+ --on-change <command> start this command of yours for each change: it gets the
923
+ event as one JSON line on its standard input
924
+ --mcp-http serve the MCP tools over HTTP; each request needs the bearer token
925
+ --mcp-port <n> the port (default 9307)
926
+ --mcp-host <host> the host to bind (default 127.0.0.1)
927
+ --mcp-token-file <file> read the token from this file (default: a new token at each
928
+ start, in <store>/daemon/mcp-token)
929
+ --allow-remote permit a --mcp-host that is not loopback: read the README first
930
+ --allow-user-provided as for mcp
931
+ daemon status exit code 0 when a daemon runs on the store, 1 when none runs
932
+ extract --field <name=value[:kind]> a value to check, never made up by the crawler (repeat it);
933
+ kind: text (default), number, money, date, url, email, phone,
934
+ or json-ld:<path> such as json-ld:Product.offers.price
935
+ --capture <id> check the text of this capture, not the current text
936
+ --user-provided as for read
937
+ embed --budget <time> the longest time (default 10m); no embedding starts after it
938
+ --max-docs <n> how many texts get vectors at most
939
+ --kind <kind> only this kind of document
940
+
941
+ Exit codes:
942
+ 0 done
943
+ 1 not done: a page not fetched, no match, a quote not found, a document unknown or
944
+ held back, a watch check that failed or that is still due, a field value that extract
945
+ did not find or that is not clear, embed that stopped before each page had vectors,
946
+ or an error
947
+ (fetch: a page with an archived copy counts as done)
948
+ 2 the command line is wrong
949
+ `;
950
+ /**
951
+ * This function does the command `export`, and gives the exit code. `readLine` checked the whole
952
+ * command line first. The export only reads the store: it makes no store, and it sends no request.
953
+ */
954
+ const exportCommand = async (plan, dir, json) => {
955
+ const result = plan.format === "wacz" ? await (await import("./wacz-BFNG42Kt.mjs").then((n) => n.n)).exportWacz(dir, plan.options) : await (await import("./warc-CVS0t1fW.mjs").then((n) => n.a)).exportWarc(dir, plan.options);
956
+ process.stdout.write(`${json ? JSON.stringify(result, null, 2) : describeExport(result, plan.options.includeRefusals)}\n`);
957
+ return 0;
958
+ };
959
+ /** This function prints the key directory of a key file, and tells where to host it. */
960
+ const keyDirectoryCommand = async (key, agent) => {
961
+ const { KEY_DIRECTORY_PATH, KEY_DIRECTORY_TYPE, keyDirectory } = await import("./webbotauth-CjJdTAfE.mjs").then((n) => n.s);
962
+ let directory;
963
+ try {
964
+ directory = keyDirectory([key]);
965
+ } catch (error) {
966
+ throw new UsageError(`--sign-key: ${error.message}`, { cause: error });
967
+ }
968
+ process.stdout.write(`${JSON.stringify(directory, null, 2)}\n`);
969
+ 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}`;
970
+ process.stderr.write(`polite-crawler: serve this JSON at ${where}\n`);
971
+ return 0;
972
+ };
973
+ /** This function lists the pages of a site. The exit code is 0 when it found a URL. */
974
+ const runDiscover = async (crawler, site, limits, maxAge, json, out) => {
975
+ const found = await crawler.discover(site, {
976
+ maxUrls: limits.maxUrls,
977
+ feeds: limits.feeds,
978
+ maxAge,
979
+ ...limits.allowHosts === void 0 ? {} : { allowHosts: limits.allowHosts }
980
+ });
981
+ if (json) out(JSON.stringify(found, null, 2));
982
+ else for (const line of describeDiscover(found)) out(line);
983
+ return found.urls.length > 0 ? 0 : 1;
984
+ };
985
+ /** This function crawls from the seeds. It prints each page when it ends. The exit code is 0 when the crawl got a page. */
986
+ const runCrawl = async (crawler, seeds, limits, page, json, out) => {
987
+ const controller = new AbortController();
988
+ const stop = () => controller.abort(/* @__PURE__ */ new Error("interrupted"));
989
+ process.once("SIGINT", stop);
990
+ try {
991
+ const run = crawler.crawl([...new Set(seeds)], {
992
+ maxPages: limits.maxPages,
993
+ maxDepth: limits.maxDepth,
994
+ budgetMs: limits.budgetMs,
995
+ include: limits.include,
996
+ exclude: limits.exclude,
997
+ useSitemaps: limits.sitemaps,
998
+ render: page.render,
999
+ maxAge: page.maxAge,
1000
+ signal: controller.signal,
1001
+ ...limits.allowHosts === void 0 ? {} : { allowHosts: limits.allowHosts }
1002
+ });
1003
+ const pages = [];
1004
+ for await (const result of run) {
1005
+ pages.push(result);
1006
+ if (!json) out(`${describePage(result)}\n depth ${String(result.depth)}, ${result.source}`);
1007
+ }
1008
+ const summary = await run.summary;
1009
+ out(json ? JSON.stringify({
1010
+ pages,
1011
+ summary
1012
+ }, null, 2) : describeSummary(summary));
1013
+ return summary.fetched > 0 ? 0 : 1;
1014
+ } finally {
1015
+ process.removeListener("SIGINT", stop);
1016
+ }
1017
+ };
1018
+ /** This function does the command `watch`, and gives the exit code. */
1019
+ const runWatchCommand = async (crawler, line, values, limits, json, out) => {
1020
+ const print = (value, text) => out(json ? JSON.stringify(value, null, 2) : text());
1021
+ switch (line.action) {
1022
+ case "add": {
1023
+ const added = [];
1024
+ for (const url of line.urls) added.push(await crawler.watch.add(url, {
1025
+ ...line.every === void 0 ? {} : { every: line.every },
1026
+ ...line.confirm === void 0 ? {} : { confirm: line.confirm },
1027
+ ...values.scope === void 0 ? {} : { scope: values.scope },
1028
+ ...line.labels === void 0 ? {} : { labels: line.labels },
1029
+ ...values.render === void 0 ? {} : { render: values.render }
1030
+ }));
1031
+ print(added, () => added.map(describeEntry).join("\n"));
1032
+ return 0;
1033
+ }
1034
+ case "remove": {
1035
+ const removed = [];
1036
+ for (const url of line.urls) removed.push({
1037
+ url,
1038
+ removed: await crawler.watch.remove(url)
1039
+ });
1040
+ print(removed, () => removed.map((one) => `${one.removed ? "removed" : "not on the list"} ${one.url}`).join("\n"));
1041
+ return removed.every((one) => one.removed) ? 0 : 1;
1042
+ }
1043
+ case "list": {
1044
+ const entries = await crawler.watch.list();
1045
+ print(entries, () => entries.length === 0 ? "the watch list is empty" : entries.map(describeEntry).join("\n"));
1046
+ return 0;
1047
+ }
1048
+ default: {
1049
+ const controller = new AbortController();
1050
+ const stop = () => controller.abort(/* @__PURE__ */ new Error("interrupted"));
1051
+ process.once("SIGINT", stop);
1052
+ try {
1053
+ const run = crawler.watch.run({
1054
+ budgetMs: values.budget === void 0 ? void 0 : limits.budgetMs,
1055
+ maxPages: values["max-pages"] === void 0 ? void 0 : limits.maxPages,
1056
+ signal: controller.signal
1057
+ });
1058
+ const events = [];
1059
+ for await (const event of run) {
1060
+ events.push(event);
1061
+ if (!json) out(describeEvent(event));
1062
+ }
1063
+ const summary = await run.summary;
1064
+ out(json ? JSON.stringify({
1065
+ events,
1066
+ summary
1067
+ }, null, 2) : describeWatchSummary(summary));
1068
+ return summary.refused + summary.error === 0 && summary.due === 0 ? 0 : 1;
1069
+ } finally {
1070
+ process.removeListener("SIGINT", stop);
1071
+ }
1072
+ }
1073
+ }
1074
+ };
1075
+ /** This function gives the text of a diff for a person: the two captures, then the text of `formatDiff`. */
1076
+ const describeDiff = async (diff) => {
1077
+ const { formatDiff } = await import("./diff-NQcQqKe9.mjs").then((n) => n.i);
1078
+ return [
1079
+ `${diff.doc} ${diff.from.capture} (${diff.from.fetched_at}) -> ${diff.to.capture} (${diff.to.fetched_at})`,
1080
+ formatDiff(diff),
1081
+ ...diff.same === true ? ["the two versions are the same"] : [],
1082
+ ...diff.note === null ? [] : [diff.note]
1083
+ ].join("\n");
1084
+ };
1085
+ /** 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. */
1086
+ const runExtract = async (crawler, doc, fields, options, json, out) => {
1087
+ const { extractDocument } = await import("./extract-ByFfc8Zk.mjs").then((n) => n.i);
1088
+ const claims = fields;
1089
+ const result = crawler.extract === void 0 ? await extractDocument(crawler.runtime, doc, claims, options) : await crawler.extract(doc, claims, options);
1090
+ if (json) out(JSON.stringify(result, null, 2));
1091
+ else for (const line of describeExtract(result)) out(line);
1092
+ return extractCode(result);
1093
+ };
1094
+ /**
1095
+ * This function makes the embedder of the command line. It loads no model: the model loads at the
1096
+ * first text. The transformers embedder keeps the files of its model in `<store>/models`.
1097
+ */
1098
+ const embedderFor = async (choice, dir) => {
1099
+ try {
1100
+ if (choice.kind === "ollama") {
1101
+ const { ollamaEmbedder } = await import("./ollama-BJxkjny4.mjs").then((n) => n.a);
1102
+ return ollamaEmbedder({
1103
+ ...choice.model === void 0 ? {} : { model: choice.model },
1104
+ ...choice.url === void 0 ? {} : { baseUrl: choice.url },
1105
+ allowRemote: choice.allowRemote
1106
+ });
1107
+ }
1108
+ const { transformersEmbedder } = await import("./transformers-BQMlaGj4.mjs").then((n) => n.i);
1109
+ return transformersEmbedder({
1110
+ ...choice.model === void 0 ? {} : { model: choice.model },
1111
+ cacheDir: join(dir, "models")
1112
+ });
1113
+ } catch (error) {
1114
+ throw new UsageError(`--embedder ${choice.kind}: ${error.message}`, { cause: error });
1115
+ }
1116
+ };
1117
+ /** This function gives vectors to the stored pages within the budget. The exit code is 0 when each page has vectors. */
1118
+ const runEmbed = async (crawler, limits, options, json, out) => {
1119
+ const controller = new AbortController();
1120
+ const stop = () => controller.abort(/* @__PURE__ */ new Error("interrupted"));
1121
+ process.once("SIGINT", stop);
1122
+ try {
1123
+ const result = await crawler.embed({
1124
+ budget: limits.budgetMs,
1125
+ signal: controller.signal,
1126
+ ...options.maxDocs === void 0 ? {} : { maxDocs: options.maxDocs },
1127
+ ...options.kind === void 0 ? {} : { kind: options.kind }
1128
+ });
1129
+ out(json ? JSON.stringify(result, null, 2) : describeEmbed(result));
1130
+ return result.stopped === "done" ? 0 : 1;
1131
+ } finally {
1132
+ process.removeListener("SIGINT", stop);
1133
+ }
1134
+ };
1135
+ /** This function does the command of the command line, and gives the exit code. */
1136
+ const main = async (args) => {
1137
+ const out = (text) => void process.stdout.write(`${text}\n`);
1138
+ const line = readLine(args, process.env);
1139
+ if (line.kind === "version") {
1140
+ out(VERSION);
1141
+ return 0;
1142
+ }
1143
+ if (line.kind === "help") {
1144
+ process.stdout.write(HELP);
1145
+ return 0;
1146
+ }
1147
+ const { command, rest, values, render, rank, limit, from, length, maxAge, crawlLimits, at, diffFrom, diffTo, watchLine, extractFields, dir, signKey, signatureAgent } = line;
1148
+ const embedder = line.embedder === void 0 ? void 0 : await embedderFor(line.embedder, dir);
1149
+ if (command === "key-directory") return keyDirectoryCommand(keyFile(signKey), signatureAgent);
1150
+ let signer;
1151
+ if (signKey !== void 0 && signatureAgent !== void 0) {
1152
+ const { createWebBotAuthSigner } = await import("./webbotauth-CjJdTAfE.mjs").then((n) => n.s);
1153
+ const privateKey = keyFile(signKey);
1154
+ try {
1155
+ signer = createWebBotAuthSigner({
1156
+ privateKey,
1157
+ signatureAgent,
1158
+ agentType: agentTypeOf(signatureAgent)
1159
+ });
1160
+ } catch (error) {
1161
+ throw new UsageError(`--sign-key: ${error.message}`, { cause: error });
1162
+ }
1163
+ }
242
1164
  const options = {
243
1165
  dir,
244
- contact: values.contact ?? (process.env["POLITE_CRAWLER_CONTACT"] || void 0),
1166
+ contact: line.contact,
245
1167
  ...values["no-browser"] === true ? { renderer: false } : {},
246
- ...allowPrivate ? { egress: { allowPrivate: true } } : {}
1168
+ ...line.allowPrivate ? { egress: { allowPrivate: true } } : {},
1169
+ ...signer === void 0 ? {} : { signer },
1170
+ ...embedder === void 0 ? {} : { embedder }
247
1171
  };
248
1172
  if (command === "mcp") {
249
1173
  const { startMcpServer } = await import("./mcp.mjs");
250
1174
  await startMcpServer({
251
1175
  ...options,
252
- allowUserProvided
1176
+ allowUserProvided: line.allowUserProvided
253
1177
  });
254
1178
  return 0;
255
1179
  }
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 });
1180
+ if (command === "daemon") {
1181
+ const { daemonCommand } = await import("./cli-daemon--cTmq0Z5.mjs");
1182
+ return daemonCommand(line.daemonLine, options, {
1183
+ allowUserProvided: line.allowUserProvided,
1184
+ json: values.json === true,
1185
+ out
1186
+ });
262
1187
  }
1188
+ if (line.exportPlan !== void 0) return exportCommand(line.exportPlan, dir, values.json === true);
1189
+ const { openCrawler } = await import("./open-C4fhXJgD.mjs").then((n) => n.a);
263
1190
  let renderOff;
264
1191
  let renderer = options.renderer;
265
- if (command !== "fetch" || render === "never") renderer = false;
1192
+ if (!(command === "fetch" || command === "crawl" || watchLine?.action === "run") || render === "never") renderer = false;
266
1193
  else if (renderer === void 0) {
267
- const { browserMissing } = await import("./browser-gJ_h4AhB.mjs").then((n) => n.n);
1194
+ const { browserMissing } = await import("./browser-CKRNbIDT.mjs").then((n) => n.n);
268
1195
  renderOff = await browserMissing();
269
1196
  if (renderOff !== void 0) renderer = false;
270
1197
  }
@@ -284,23 +1211,17 @@ const main = async (args) => {
284
1211
  switch (command) {
285
1212
  case "fetch": {
286
1213
  const urls = [...new Set(rest)];
287
- const pages = (await Promise.allSettled(urls.map((url) => crawler.fetch(url, {
1214
+ const fallback = values["archive-fallback"] === true ? "archive" : void 0;
1215
+ const archiveDespiteRobots = values["archive-despite-robots"] === true;
1216
+ const settled = await Promise.allSettled(urls.map((url) => crawler.fetch(url, {
288
1217
  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
- });
1218
+ maxAge,
1219
+ fallback,
1220
+ archiveDespiteRobots
1221
+ })));
1222
+ const pages = fetchedPages(urls, settled, renderOff);
302
1223
  print(pages, () => pages.map(describePage).join("\n"));
303
- return pages.every(ok) ? 0 : 1;
1224
+ return pages.every(done) ? 0 : 1;
304
1225
  }
305
1226
  case "find": {
306
1227
  const found = await crawler.find(rest.join(" "), {
@@ -308,7 +1229,7 @@ const main = async (args) => {
308
1229
  kind: values.kind ?? null,
309
1230
  rank
310
1231
  });
311
- print(found, () => found.length === 0 ? "nothing stored matches" : found.map((hit) => `${hit.doc} ${hit.title}\n ${hit.snippet.replace(/\s+/g, " ")}`).join("\n"));
1232
+ print(found, () => describeFound(found));
312
1233
  return found.length === 0 ? 1 : 0;
313
1234
  }
314
1235
  case "read": {
@@ -316,24 +1237,45 @@ const main = async (args) => {
316
1237
  const read = await crawler.read(rest[0], {
317
1238
  from,
318
1239
  length,
319
- userProvided
1240
+ userProvided,
1241
+ at
320
1242
  });
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;
1243
+ print(read, () => describeRead(read, at));
1244
+ return read.held_back || read.before_first === true ? 1 : 0;
323
1245
  }
324
1246
  case "verify": {
325
1247
  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");
1248
+ const verified = await crawler.verify(doc, quote.join(" "), { at });
1249
+ print(verified, () => describeVerify(verified));
328
1250
  return verified.found ? 0 : 1;
329
1251
  }
1252
+ case "discover": return await runDiscover(crawler, rest[0], crawlLimits, maxAge, values.json === true, out);
1253
+ case "crawl": return await runCrawl(crawler, rest, crawlLimits, {
1254
+ render,
1255
+ maxAge
1256
+ }, values.json === true, out);
1257
+ case "diff": {
1258
+ const diff = await crawler.diff(rest[0], {
1259
+ ...diffFrom === void 0 ? {} : { from: diffFrom },
1260
+ ...diffTo === void 0 ? {} : { to: diffTo }
1261
+ });
1262
+ const text = values.json === true ? "" : await describeDiff(diff);
1263
+ print(diff, () => text);
1264
+ return diff.held_back ? 1 : 0;
1265
+ }
1266
+ case "watch": return await runWatchCommand(crawler, watchLine, values, crawlLimits, values.json === true, out);
1267
+ case "extract": return await runExtract(crawler, rest[0], extractFields, {
1268
+ capture: values.capture,
1269
+ userProvided: values["user-provided"] === true,
1270
+ at
1271
+ }, values.json === true, out);
1272
+ case "embed": return await runEmbed(crawler, crawlLimits, {
1273
+ maxDocs: line.maxDocs,
1274
+ kind: values.kind
1275
+ }, values.json === true, out);
330
1276
  default: {
331
1277
  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"));
1278
+ print(counts, () => describeReindex(counts));
337
1279
  return 0;
338
1280
  }
339
1281
  }
@@ -350,6 +1292,6 @@ main(process.argv.slice(2)).then((code) => {
350
1292
  process.exitCode = usage ? 2 : 1;
351
1293
  });
352
1294
  //#endregion
353
- export {};
1295
+ export { describeDaemonStatus as t };
354
1296
 
355
1297
  //# sourceMappingURL=cli.mjs.map