polite-crawler 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +555 -8
  2. package/build/cli-daemon-ZYRdy6EW.mjs +38 -0
  3. package/build/cli-daemon-ZYRdy6EW.mjs.map +1 -0
  4. package/build/cli-recipes-Bci6HREA.mjs +59 -0
  5. package/build/cli-recipes-Bci6HREA.mjs.map +1 -0
  6. package/build/cli.mjs +673 -26
  7. package/build/cli.mjs.map +1 -1
  8. package/build/daemon-DK6U51_v.mjs +515 -0
  9. package/build/daemon-DK6U51_v.mjs.map +1 -0
  10. package/build/{open-Ca1lcWR3.d.mts → daemon-wwIWsUmU.d.mts} +919 -17
  11. package/build/{diff-Dk2eSvi-.mjs → diff-BUM91Rev.mjs} +2 -2
  12. package/build/{diff-Dk2eSvi-.mjs.map → diff-BUM91Rev.mjs.map} +1 -1
  13. package/build/{extract-DcwNrTK7.mjs → extract-CefB5Vfn.mjs} +2 -2
  14. package/build/{extract-DcwNrTK7.mjs.map → extract-CefB5Vfn.mjs.map} +1 -1
  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/index.d.mts +491 -11
  19. package/build/index.mjs +14 -9
  20. package/build/loopback-C_QYowSf.mjs +22 -0
  21. package/build/loopback-C_QYowSf.mjs.map +1 -0
  22. package/build/mcp.d.mts +158 -2
  23. package/build/mcp.mjs +567 -18
  24. package/build/mcp.mjs.map +1 -1
  25. package/build/ollama-BJxkjny4.mjs +160 -0
  26. package/build/ollama-BJxkjny4.mjs.map +1 -0
  27. package/build/{open-DnrWgkC0.mjs → open-CbpSCEZc.mjs} +1127 -16
  28. package/build/open-CbpSCEZc.mjs.map +1 -0
  29. package/build/recipe-GiFQojmo.mjs +279 -0
  30. package/build/recipe-GiFQojmo.mjs.map +1 -0
  31. package/build/recipe-product-Dellqudu.mjs +892 -0
  32. package/build/recipe-product-Dellqudu.mjs.map +1 -0
  33. package/build/{runtime-k8mo1_7U.mjs → runtime-D_r2SYge.mjs} +3 -3
  34. package/build/{runtime-k8mo1_7U.mjs.map → runtime-D_r2SYge.mjs.map} +1 -1
  35. package/build/transformers-BQMlaGj4.mjs +133 -0
  36. package/build/transformers-BQMlaGj4.mjs.map +1 -0
  37. package/build/{duration-iIuMMofi.mjs → version-BPem-XZd.mjs} +10 -10
  38. package/build/version-BPem-XZd.mjs.map +1 -0
  39. package/build/{wacz-sfOLT8e9.mjs → wacz-QyzLp4mC.mjs} +3 -3
  40. package/build/{wacz-sfOLT8e9.mjs.map → wacz-QyzLp4mC.mjs.map} +1 -1
  41. package/build/{warc-VIPscWxY.mjs → warc-BHcaRKHw.mjs} +2 -2
  42. package/build/{warc-VIPscWxY.mjs.map → warc-BHcaRKHw.mjs.map} +1 -1
  43. package/build/webbotauth-CjJdTAfE.mjs.map +1 -1
  44. package/package.json +10 -2
  45. package/build/duration-iIuMMofi.mjs.map +0 -1
  46. package/build/open-DnrWgkC0.mjs.map +0 -1
package/build/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { i as VERSION, t as durationMs } from "./duration-iIuMMofi.mjs";
2
+ import { i as durationMs, r as VERSION } from "./version-BPem-XZd.mjs";
3
+ import { t as isLoopbackHost } from "./loopback-C_QYowSf.mjs";
3
4
  import { join, resolve } from "node:path";
4
5
  import { readFileSync } from "node:fs";
5
6
  import { parseArgs } from "node:util";
@@ -80,30 +81,85 @@ const OPTIONS = {
80
81
  "archive-despite-robots": { type: "boolean" },
81
82
  "sign-key": { type: "string" },
82
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" },
83
96
  field: {
84
97
  type: "string",
85
98
  multiple: true
86
99
  },
87
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
+ name: { type: "string" },
107
+ "require-name": { type: "boolean" },
108
+ address: { type: "string" },
109
+ lat: { type: "string" },
110
+ lon: { type: "string" },
111
+ osm: { type: "string" },
112
+ pages: { type: "string" },
113
+ compare: { type: "string" },
114
+ "on-behalf-of": {
115
+ type: "string",
116
+ multiple: true
117
+ },
88
118
  ...EXPORT_OPTIONS
89
119
  };
120
+ /** These options belong to `daemon`, and not to `daemon status`. */
121
+ const DAEMON_RUN_OPTIONS = [
122
+ "tick",
123
+ "jitter",
124
+ "tick-budget",
125
+ "tick-pages",
126
+ "grace",
127
+ "events",
128
+ "on-change",
129
+ "mcp-http",
130
+ "mcp-port",
131
+ "mcp-host",
132
+ "mcp-token-file",
133
+ "allow-remote"
134
+ ];
135
+ /** These options belong to `venue` only. */
136
+ const VENUE_OPTIONS = [
137
+ "name",
138
+ "require-name",
139
+ "address",
140
+ "lat",
141
+ "lon",
142
+ "osm",
143
+ "pages",
144
+ "compare"
145
+ ];
90
146
  /** These options belong to one command, or to the commands that the text names. */
91
147
  const OWNER = {
92
- render: "fetch crawl watch",
93
- "max-age": "fetch crawl discover",
148
+ render: "fetch crawl watch venue product",
149
+ "max-age": "fetch crawl discover venue product",
94
150
  limit: "find",
95
- kind: "find",
151
+ kind: "find embed",
96
152
  rank: "find",
97
153
  from: "read diff",
98
154
  length: "read",
99
- "user-provided": "read extract",
100
- "allow-user-provided": "mcp",
155
+ "user-provided": "read extract venue product",
156
+ "allow-user-provided": "mcp daemon",
101
157
  ...Object.fromEntries(Object.keys(EXPORT_OPTIONS).map((option) => [option, "export"])),
102
158
  "max-urls": "discover",
103
159
  feed: "discover",
104
160
  "max-pages": "crawl watch",
105
161
  "max-depth": "crawl",
106
- budget: "crawl watch",
162
+ budget: "crawl watch embed venue product",
107
163
  include: "crawl",
108
164
  exclude: "crawl",
109
165
  sitemaps: "crawl",
@@ -114,12 +170,20 @@ const OWNER = {
114
170
  confirm: "watch",
115
171
  scope: "watch",
116
172
  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",
173
+ "archive-fallback": "fetch venue product",
174
+ "archive-despite-robots": "fetch venue product",
175
+ "sign-key": "fetch discover crawl watch mcp key-directory daemon venue product",
176
+ "signature-agent": "fetch discover crawl watch mcp key-directory daemon venue product",
177
+ ...Object.fromEntries(DAEMON_RUN_OPTIONS.map((option) => [option, "daemon"])),
121
178
  field: "extract",
122
- capture: "extract"
179
+ capture: "extract",
180
+ embedder: "find embed mcp",
181
+ "embed-model": "find embed mcp",
182
+ "ollama-url": "find embed mcp",
183
+ "allow-remote-ollama": "find embed mcp",
184
+ "max-docs": "embed",
185
+ ...Object.fromEntries(VENUE_OPTIONS.map((option) => [option, "venue"])),
186
+ "on-behalf-of": "venue product"
123
187
  };
124
188
  /** These are the commands, with the smallest and the largest count of arguments. */
125
189
  const COMMANDS = {
@@ -178,6 +242,11 @@ const COMMANDS = {
178
242
  max: Infinity,
179
243
  wants: "watch wants add, remove, list or run"
180
244
  },
245
+ daemon: {
246
+ min: 0,
247
+ max: 1,
248
+ wants: "daemon takes no argument, or status"
249
+ },
181
250
  "key-directory": {
182
251
  min: 0,
183
252
  max: 0,
@@ -187,6 +256,21 @@ const COMMANDS = {
187
256
  min: 1,
188
257
  max: 1,
189
258
  wants: "extract wants one document id and --field name=value"
259
+ },
260
+ embed: {
261
+ min: 0,
262
+ max: 0,
263
+ wants: "embed takes no argument: give the model with --embedder"
264
+ },
265
+ venue: {
266
+ min: 1,
267
+ max: 1,
268
+ wants: "venue wants the URL of the site of one venue"
269
+ },
270
+ product: {
271
+ min: 1,
272
+ max: 1,
273
+ wants: "product wants the URL of one product page"
190
274
  }
191
275
  };
192
276
  const parse = (args) => {
@@ -424,6 +508,102 @@ const describeEvent = (event) => {
424
508
  ].join("\n");
425
509
  };
426
510
  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}.`;
511
+ /** This function reads a file name of the command line: a leading `~` is the home folder. */
512
+ const fileOf = (text) => text === "~" ? homedir() : /^~[\\/]/.test(text) ? resolve(homedir(), text.slice(2)) : resolve(text);
513
+ /** This function reads a duration of `daemon`, with a floor in milliseconds. */
514
+ const daemonDuration = (name, text, floor) => {
515
+ if (text === void 0) return void 0;
516
+ let ms;
517
+ try {
518
+ ms = durationMs(text);
519
+ } catch (error) {
520
+ throw new UsageError(`--${name}: ${error.message}`, { cause: error });
521
+ }
522
+ if (ms < floor) throw new UsageError(floor === 0 ? `--${name} wants 0 or more` : `--${name} wants ${String(floor / 1e3)}s or more`);
523
+ return ms;
524
+ };
525
+ /** These options of `daemon` belong to its MCP server, so they need `--mcp-http`. */
526
+ const MCP_OPTIONS = [
527
+ "mcp-port",
528
+ "mcp-host",
529
+ "mcp-token-file",
530
+ "allow-remote"
531
+ ];
532
+ /** This function checks the options of `daemon` and of `daemon status`. */
533
+ const daemonLineOf = (values, rest) => {
534
+ const [action] = rest;
535
+ if (action !== void 0 && action !== "status") throw new UsageError(`daemon takes no argument, or status, not "${action}"`);
536
+ if (action === "status") {
537
+ const run = [
538
+ ...DAEMON_RUN_OPTIONS,
539
+ "allow-user-provided",
540
+ "sign-key",
541
+ "signature-agent"
542
+ ];
543
+ for (const option of run) if (values[option] !== void 0) throw new UsageError(`--${option} is an option of daemon, not of daemon status`);
544
+ }
545
+ if (values["mcp-http"] !== true) {
546
+ for (const option of MCP_OPTIONS) if (values[option] !== void 0) throw new UsageError(`--${option} goes with --mcp-http`);
547
+ }
548
+ const host = (values["mcp-host"] ?? "127.0.0.1").trim();
549
+ if (host === "") throw new UsageError("--mcp-host wants a host");
550
+ const allowRemote = values["allow-remote"] === true;
551
+ 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`);
552
+ const port = whole("mcp-port", values["mcp-port"], 9307, 0);
553
+ if (port > 65535) throw new UsageError("--mcp-port wants a port from 0 to 65535");
554
+ const onChange = values["on-change"];
555
+ if (onChange !== void 0 && onChange.trim() === "") throw new UsageError("--on-change wants a command");
556
+ const events = values.events;
557
+ if (events !== void 0 && events.trim() === "") throw new UsageError("--events wants a file");
558
+ const tokenFile = values["mcp-token-file"];
559
+ if (tokenFile !== void 0 && tokenFile.trim() === "") throw new UsageError("--mcp-token-file wants a file");
560
+ return {
561
+ action: action === "status" ? "status" : "run",
562
+ tickMs: daemonDuration("tick", values.tick, 1e3),
563
+ jitterMs: daemonDuration("jitter", values.jitter, 0),
564
+ budgetMs: daemonDuration("tick-budget", values["tick-budget"], 1e3),
565
+ maxPages: values["tick-pages"] === void 0 ? void 0 : whole("tick-pages", values["tick-pages"], 1, 1),
566
+ graceMs: daemonDuration("grace", values.grace, 0),
567
+ events: events === void 0 ? void 0 : fileOf(events),
568
+ onChange,
569
+ mcp: values["mcp-http"] === true ? {
570
+ port,
571
+ host,
572
+ allowRemote,
573
+ tokenFile: tokenFile === void 0 ? void 0 : fileOf(tokenFile)
574
+ } : void 0
575
+ };
576
+ };
577
+ /** This function gives the first line of `daemon status`: the daemon runs, stopped, or stopped with no clean stop. */
578
+ 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}`;
579
+ /** This function gives the line of the last tick of `daemon status`. */
580
+ const lastTick = (state) => {
581
+ const tick = state.last_tick;
582
+ if (tick === null) return "last tick: none yet";
583
+ if (tick.summary === null) return `last tick: ${tick.ended_at}, failed: ${tick.error ?? "error"}`;
584
+ const summary = tick.summary;
585
+ return `last tick: ${tick.ended_at}, ${String(summary.checked)} checked, ${String(summary.confirmed)} changes, ${String(summary.refused)} refused, ${String(summary.error)} errors (${summary.stopped})`;
586
+ };
587
+ /** This function gives the text of `daemon status`. */
588
+ const describeDaemonStatus = (state, dir) => {
589
+ if (state === void 0) return `no daemon has run on the store ${dir}`;
590
+ const lines = [daemonHead(state), lastTick(state)];
591
+ if (state.alive && state.next_tick_at !== null) lines.push(`next tick: ${state.next_tick_at}`);
592
+ lines.push(`${String(state.ticks)} ticks, ${String(state.checked)} checks, ${String(state.changes)} changes`);
593
+ if (state.next_due.length === 0) lines.push("next due: no watch");
594
+ else {
595
+ lines.push("next due:");
596
+ for (const due of state.next_due) lines.push(` ${due.next_at} ${due.url} (every ${everyText(due.every_ms)}, last ${due.last_kind ?? "none"})`);
597
+ }
598
+ if (state.mcp !== null) {
599
+ const where = state.mcp.token_file === null ? "" : ` (the token is in ${state.mcp.token_file})`;
600
+ lines.push(`MCP: ${state.mcp.url}${where}`);
601
+ lines.push(state.tasks.length === 0 ? "tasks: none running" : `tasks: ${String(state.tasks.length)} running`);
602
+ for (const task of state.tasks) lines.push(` ${task.id} ${task.tool} ${task.status}${task.message === null ? "" : `: ${task.message}`}`);
603
+ }
604
+ lines.push(state.last_error === null ? "last error: none" : `last error: ${state.last_error.at}: ${state.last_error.message}`);
605
+ return lines.join("\n");
606
+ };
427
607
  /** These are the kinds of a field (`FIELD_KINDS` of `extract.ts`). The command line knows them before it loads the crawler. */
428
608
  const KINDS = [
429
609
  "text",
@@ -463,6 +643,33 @@ const fieldsOf = (given) => {
463
643
  }
464
644
  return fields;
465
645
  };
646
+ /**
647
+ * This function reads the embedder of the command line, or of the environment for the commands
648
+ * that use one. `--embed-model` and `--ollama-url` need `--embedder`.
649
+ */
650
+ const embedderOf = (values, env, command) => {
651
+ const uses = OWNER["embedder"].split(" ").includes(command);
652
+ const fromEnv = (name) => uses ? env[name] || void 0 : void 0;
653
+ const name = values.embedder ?? fromEnv("POLITE_CRAWLER_EMBEDDER");
654
+ const model = values["embed-model"] ?? fromEnv("POLITE_CRAWLER_EMBED_MODEL");
655
+ const url = values["ollama-url"] ?? fromEnv("POLITE_CRAWLER_OLLAMA_URL");
656
+ const allowRemote = values["allow-remote-ollama"] === true || uses && envFlag(env, "POLITE_CRAWLER_ALLOW_REMOTE_OLLAMA");
657
+ if (name === void 0) {
658
+ 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");
659
+ return;
660
+ }
661
+ if (name !== "transformers" && name !== "ollama") throw new UsageError(`--embedder is transformers or ollama, not "${name}"`);
662
+ if (model !== void 0 && model.trim() === "") throw new UsageError("--embed-model wants a model");
663
+ if (values["ollama-url"] !== void 0 && name !== "ollama") throw new UsageError("--ollama-url goes with --embedder ollama");
664
+ return {
665
+ kind: name,
666
+ model,
667
+ url: name === "ollama" ? url : void 0,
668
+ allowRemote
669
+ };
670
+ };
671
+ /** This function gives the text of `embed`: the counts and the reason of the stop. */
672
+ 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");
466
673
  /** 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
674
  const fetchedPages = (urls, settled, renderOff) => settled.map((result, at) => {
468
675
  const url = urls[at];
@@ -477,7 +684,7 @@ const fetchedPages = (urls, settled, renderOff) => settled.map((result, at) => {
477
684
  } : page;
478
685
  });
479
686
  /** 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");
687
+ 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");
481
688
  /** This function gives the text of `read`. With `--at`, the head names the capture and its fetch time. */
482
689
  const describeRead = (read, at) => {
483
690
  const version = at === void 0 ? "" : ` ${read.capture} (${read.fetched_at})`;
@@ -520,6 +727,311 @@ const extractCode = (result) => {
520
727
  const done = result.summary.verified + result.summary.normalized;
521
728
  return done === result.summary.fields && done > 0 ? 0 : 1;
522
729
  };
730
+ /** This function reads a latitude or a longitude of the command line. */
731
+ const coordinateOf = (name, text, limit) => {
732
+ const value = /^\s*-?\d+(?:\.\d+)?\s*$/.test(text) ? Number(text) : NaN;
733
+ if (!Number.isFinite(value) || Math.abs(value) > limit) throw new UsageError(`--${name} wants a number from -${String(limit)} to ${String(limit)}, not "${text}"`);
734
+ return value;
735
+ };
736
+ /** This function checks the options of `venue` and `product`, and gives the options of the recipe. */
737
+ const recipeLineOf = (recipe, values, rest, page) => {
738
+ const url = rest[0] ?? "";
739
+ if (!/^https?:\/\//i.test(url) || !URL.canParse(url)) throw new UsageError(`${recipe} wants an http or https URL, not "${url}"`);
740
+ for (const agent of values["on-behalf-of"] ?? []) if (!/^[A-Za-z0-9_-]+$/.test(agent)) throw new UsageError(`--on-behalf-of wants the robots.txt token of an agent, such as Claude-User, not "${agent}"`);
741
+ const name = values.name;
742
+ if (name !== void 0 && name.trim() === "") throw new UsageError("--name wants the name of the venue");
743
+ if (values["require-name"] === true && name === void 0) throw new UsageError("--require-name goes with --name");
744
+ const address = values.address;
745
+ if (address !== void 0 && address.trim() === "") throw new UsageError("--address wants a street address");
746
+ if (values.lat === void 0 !== (values.lon === void 0)) throw new UsageError("--lat and --lon go together");
747
+ const lat = values.lat === void 0 ? void 0 : coordinateOf("lat", values.lat, 90);
748
+ const lon = values.lon === void 0 ? void 0 : coordinateOf("lon", values.lon, 180);
749
+ const osm = values.osm;
750
+ if (osm !== void 0 && osm.trim() === "") throw new UsageError("--osm wants an opening_hours value, such as \"Mo-Fr 08:00-18:00\"");
751
+ const pages = values.pages === void 0 ? void 0 : whole("pages", values.pages, 0, 0);
752
+ if (pages !== void 0 && pages > 8) throw new UsageError(`--pages wants a whole number from 0 to ${String(8)}`);
753
+ const compare = values.compare;
754
+ if (compare !== void 0 && compare.trim() === "") throw new UsageError("--compare wants a file");
755
+ return {
756
+ recipe,
757
+ url,
758
+ render: page.render,
759
+ maxAge: page.maxAge,
760
+ budget: page.budget,
761
+ onBehalfOf: values["on-behalf-of"],
762
+ userProvided: values["user-provided"] === true,
763
+ fallback: values["archive-fallback"] === true ? "archive" : void 0,
764
+ archiveDespiteRobots: values["archive-despite-robots"] === true,
765
+ name,
766
+ requireName: values["require-name"] === true,
767
+ location: address === void 0 && lat === void 0 ? void 0 : {
768
+ ...address === void 0 ? {} : { address },
769
+ ...lat === void 0 ? {} : {
770
+ lat,
771
+ lon
772
+ }
773
+ },
774
+ osm,
775
+ pages,
776
+ compare
777
+ };
778
+ };
779
+ /** The days of a week in their order, as `DAYS` of `hours.ts`. */
780
+ const WEEK_DAYS = [
781
+ "mon",
782
+ "tue",
783
+ "wed",
784
+ "thu",
785
+ "fri",
786
+ "sat",
787
+ "sun"
788
+ ];
789
+ const DAY_LABELS = {
790
+ mon: "Mon",
791
+ tue: "Tue",
792
+ wed: "Wed",
793
+ thu: "Thu",
794
+ fri: "Fri",
795
+ sat: "Sat",
796
+ sun: "Sun"
797
+ };
798
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
799
+ /** This function tells if a value is a time of a kept week: `HH:MM` or `H:MM`, to `24:00`. */
800
+ const isClock = (value) => {
801
+ if (typeof value !== "string") return false;
802
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value);
803
+ return match !== null && Number(match[1]) <= 24 && Number(match[2]) < 60;
804
+ };
805
+ const intervalOf = (value) => Array.isArray(value) && value.length === 2 && isClock(value[0]) && isClock(value[1]) ? [value[0], value[1]] : void 0;
806
+ /**
807
+ * This function reads the kept week of `--compare`, a JSON file. The file is a week, or an object
808
+ * with the field `week` (a reading of `venue --json`) or `hours_week`. Each day gives a list of
809
+ * intervals (`[]` for closed), one interval, or null for closed. An error names the file.
810
+ */
811
+ const keptWeekOf = (path) => {
812
+ const file = fileOf(path);
813
+ let text;
814
+ try {
815
+ text = readFileSync(file, "utf8");
816
+ } catch (error) {
817
+ const code = error.code;
818
+ throw new UsageError(`--compare: the file ${file} does not open (${typeof code === "string" ? code : "error"})`, { cause: error });
819
+ }
820
+ let data;
821
+ try {
822
+ data = JSON.parse(text.charCodeAt(0) === 65279 ? text.slice(1) : text);
823
+ } catch (error) {
824
+ throw new UsageError(`--compare: the file ${file} is not JSON`, { cause: error });
825
+ }
826
+ if (isRecord(data) && (isRecord(data["week"]) || isRecord(data["hours_week"]))) data = isRecord(data["week"]) ? data["week"] : data["hours_week"];
827
+ const wants = `--compare: the file ${file} is not a week. Write a list of intervals, one interval or null for each day, such as {"mon": [["09:00", "17:00"]], "sun": []}`;
828
+ if (!isRecord(data)) throw new UsageError(wants);
829
+ const week = {};
830
+ for (const [key, value] of Object.entries(data)) {
831
+ const day = WEEK_DAYS.find((one) => one === key);
832
+ if (day === void 0) throw new UsageError(`--compare: the file ${file} names "${key}", not a day: use mon, tue, wed, thu, fri, sat or sun`);
833
+ const one = intervalOf(value);
834
+ const intervals = value === null ? [] : one !== void 0 ? [one] : Array.isArray(value) ? value.map(intervalOf) : [void 0];
835
+ if (intervals.some((interval) => interval === void 0)) throw new UsageError(`${wants} (the value of ${key} is not)`);
836
+ week[day] = intervals;
837
+ }
838
+ return {
839
+ file,
840
+ week
841
+ };
842
+ };
843
+ /** This function writes the intervals of one day, as `describeWeek` of `hours.ts` does. */
844
+ const dayText = (intervals) => intervals === void 0 ? "not stated" : intervals.length === 0 ? "closed" : intervals.map(([open, close]) => open === "00:00" && close === "00:00" ? "open 24 hours" : `${open}–${close}`).join(", ");
845
+ /** This function gives the days of a comparison in their order. A day that no week states is not there. */
846
+ const compareDays = (kept, read, comparison) => {
847
+ const days = [];
848
+ for (const day of WEEK_DAYS) {
849
+ const one = {
850
+ day,
851
+ kept: kept[day],
852
+ read: read[day]
853
+ };
854
+ if (one.kept === void 0 && one.read === void 0) continue;
855
+ const status = comparison.status === "new" || comparison.added.includes(day) ? "new" : comparison.days.includes(day) ? "changed" : comparison.skipped.includes(day) ? "skipped" : one.read === void 0 ? "not read" : "same";
856
+ if (comparison.status === "new" && one.read === void 0) continue;
857
+ days.push({
858
+ ...one,
859
+ status
860
+ });
861
+ }
862
+ return days;
863
+ };
864
+ const QUOTE_CHARS = 200;
865
+ const shortText = (text) => text.length > QUOTE_CHARS ? `${text.slice(0, QUOTE_CHARS)}…` : text;
866
+ /** This function gives the lines of one reading: its rank, its source, its week and its quote. */
867
+ const readingLines = (one) => {
868
+ const marks = [
869
+ one.schedule === "other" ? "not the opening hours" : null,
870
+ one.inferred ? "am or pm guessed" : null,
871
+ one.partial ? "a rule left out" : null,
872
+ one.season === null ? null : `season ${one.season}`
873
+ ].filter((mark) => mark !== null);
874
+ const where = [
875
+ one.url,
876
+ one.path,
877
+ one.capture === null ? null : `capture ${one.capture}`,
878
+ one.archived === void 0 ? null : `archived copy (${one.archived.source}, captured ${one.archived.timestamp})`
879
+ ].filter((part) => part !== null);
880
+ return [
881
+ ` rank ${String(one.rank)} ${one.from}${one.label === null ? "" : ` "${one.label}"`} ${one.hours === "" ? "no day" : one.hours}${marks.length > 0 ? ` (${marks.join(", ")})` : ""}`,
882
+ ...where.length > 0 ? [` ${where.join(" · ")}`] : [],
883
+ one.from === "text" ? ` quote: ${JSON.stringify(shortText(one.raw))}` : ` value: ${shortText(one.raw.replace(/\s+/g, " "))}`
884
+ ];
885
+ };
886
+ const pageLine = (page) => {
887
+ const facts = [
888
+ `${page.outcome} ${String(page.status)}`,
889
+ page.from_cache ? "from the store" : null,
890
+ page.rendered ? "rendered" : null,
891
+ page.held_back ? "held back" : null,
892
+ page.archived === void 0 ? null : `archived copy (${page.archived.source})`
893
+ ].filter((fact) => fact !== null);
894
+ return ` ${page.outcome === "ok" || page.outcome === "not-modified" || page.archived !== void 0 ? "✓" : "✗"} ${page.url} ${facts.join(" · ")}`;
895
+ };
896
+ const WALL_OUTCOMES = /* @__PURE__ */ new Set([
897
+ "blocked",
898
+ "challenge",
899
+ "payment-required"
900
+ ]);
901
+ /**
902
+ * This function tells if a site refused the crawler, with the rule of `wall` of `readProduct`. The
903
+ * causes are a 401, a 402 or a 403, a challenge, a bot check, robots.txt or a stop of the host.
904
+ */
905
+ const refusedPage = (page) => WALL_OUTCOMES.has(page.outcome) || page.outcome === "skipped" && /^(?:robots\.txt disallows|host stopped)/.test(page.note ?? "") || page.doc === null && /\bbot check\b/.test(page.note ?? "");
906
+ /**
907
+ * This function gives the exit code of `venue`. It is 0 when the site gave a reading of the opening
908
+ * hours. It is `WALL` when the home page refused the crawler, and `NOTHING_READ` for a different
909
+ * result. An OpenStreetMap value alone does not count.
910
+ */
911
+ const venueCode = (result) => {
912
+ if (result.readings.some((one) => one.from !== "osm" && one.schedule === "opening")) return 0;
913
+ const home = result.pages[0];
914
+ return home !== void 0 && refusedPage(home) ? 4 : 3;
915
+ };
916
+ /** This function gives the exit code of `product`: 0 for Product data, `WALL` for a refusal, else `NOTHING_READ`. */
917
+ const productCode = (result) => result.found ? 0 : result.wall ? 4 : 3;
918
+ /** This line tells how to read a held-back page. */
919
+ const HELD_BACK_LINE = "Add --user-provided if you gave this URL yourself. The crawler logs that read.";
920
+ /** This function gives the line of a page that needs a browser, when rendering is off. */
921
+ const renderLine = (notes, renderOff) => renderOff !== void 0 && notes.some((note) => note !== null && /no browser/i.test(note)) ? [`rendering is off: ${renderOff}`] : [];
922
+ /**
923
+ * This function gives the lines of `venue`: the week of the site, and each reading with its source.
924
+ * Then come the days where the readings disagree, the menus, the phone, the pages and the notes.
925
+ * With `--compare`, the lines give each day of the comparison.
926
+ */
927
+ const describeVenue = (result, compare, renderOff) => {
928
+ const code = venueCode(result);
929
+ const lines = [`${code === 0 ? "✓" : "✗"} ${result.home ?? result.url}`];
930
+ const site = result.from_site;
931
+ const home = result.pages[0];
932
+ if (site !== null) {
933
+ const source = [
934
+ `${site.from}, rank ${String(site.rank)}`,
935
+ site.url,
936
+ site.capture === null ? null : `capture ${site.capture}`
937
+ ].filter((part) => part !== null);
938
+ lines.push(` the site: ${site.hours}`, ` from ${source.join(", ")}`);
939
+ } else if (result.best !== null) lines.push(` no reading of the site with no guess. The best reading (${result.best.from}, rank ${String(result.best.rank)}): ${result.best.hours}`);
940
+ else if (code === 4 && home !== void 0) lines.push(` wall: the site refused the crawler (${home.outcome} ${String(home.status)}). The crawler does not try a different identity.`);
941
+ else lines.push(" no opening hours found");
942
+ if (result.readings.length > 0) {
943
+ lines.push("readings, the best first:");
944
+ for (const one of result.readings) lines.push(...readingLines(one));
945
+ }
946
+ for (const day of result.disagree) {
947
+ const said = result.readings.filter((one) => one.rank <= 2 && one.week[day] !== void 0).map((one) => `${dayText(one.week[day])} (rank ${String(one.rank)} ${one.from})`);
948
+ lines.push(`the readings disagree on ${DAY_LABELS[day]}: ${said.join(" · ")}`);
949
+ }
950
+ if (result.menus.length > 0) lines.push("menus:", ...result.menus.map((menu) => ` ${menu.url}${menu.text === "" ? "" : ` ${menu.text}`}`));
951
+ if (result.phone !== null) lines.push(`phone: ${result.phone}`);
952
+ if (result.moved !== null) lines.push(`moved: the site now goes to ${result.moved}`);
953
+ if (result.names_venue !== null) lines.push(result.names_venue ? "names the venue: yes" : "names the venue: no. The domain can belong to a different business now.");
954
+ if (result.held_back) lines.push(`held back: a page asked not to be AI input, so the recipe read nothing from it. ${HELD_BACK_LINE}`);
955
+ if (result.pages.length > 0) lines.push(`pages (${String(result.pages.length)}):`, ...result.pages.map(pageLine));
956
+ if (compare !== void 0) {
957
+ const comparison = compare.comparison;
958
+ if (comparison === null || site === null) lines.push(`compare with ${compare.file}: no reading of the site with no guess, so nothing to compare`);
959
+ else {
960
+ const changed = comparison.days.length > 0 ? ` (${comparison.days.map((day) => DAY_LABELS[day]).join(", ")})` : "";
961
+ lines.push(`compare with ${compare.file}: ${comparison.status}${changed}`);
962
+ for (const one of compareDays(compare.kept, site.week, comparison)) {
963
+ const text = one.status === "changed" || one.status === "skipped" ? `${dayText(one.kept)} → ${dayText(one.read)}` : one.status === "not read" ? `kept ${dayText(one.kept)}` : dayText(one.read);
964
+ lines.push(` ${DAY_LABELS[one.day]} ${one.status.padEnd(8)} ${text}`);
965
+ }
966
+ }
967
+ }
968
+ if (result.note !== null) lines.push(`note: ${result.note}`);
969
+ lines.push(...renderLine([result.note], renderOff));
970
+ return lines;
971
+ };
972
+ /** This function gives the number of digits after the decimal point of a currency: 2 for EUR, 0 for JPY. */
973
+ const fractionOf = (currency) => {
974
+ try {
975
+ return new Intl.NumberFormat("en", {
976
+ style: "currency",
977
+ currency
978
+ }).resolvedOptions().maximumFractionDigits ?? 2;
979
+ } catch {
980
+ return 2;
981
+ }
982
+ };
983
+ /** This function writes an amount of money with its ISO 4217 code: `1,299.00 EUR`, `980 JPY`. */
984
+ const moneyText = (money) => {
985
+ const digits = fractionOf(money.currency);
986
+ return `${new Intl.NumberFormat("en", {
987
+ minimumFractionDigits: digits,
988
+ maximumFractionDigits: Math.max(digits, 4)
989
+ }).format(money.amount)} ${money.currency}`;
990
+ };
991
+ /**
992
+ * This function gives the lines of `product`. They are the name, the brand and the codes, the price
993
+ * as money, the availability, the variant, the seller, the page and the notes. A wall and a
994
+ * held-back page say so.
995
+ */
996
+ const describeProduct = (result, renderOff) => {
997
+ const code = productCode(result);
998
+ const lines = [`${code === 0 ? "✓" : "✗"} ${result.name ?? result.url}`];
999
+ if (result.name !== null) lines.push(` ${result.url}`);
1000
+ if (code === 4) lines.push(` wall: the shop refused the crawler (${result.outcome} ${String(result.status)}). The crawler does not try a different identity.`);
1001
+ if (result.held_back) lines.push(` held back: the page asked not to be AI input. ${HELD_BACK_LINE}`);
1002
+ const codes = [
1003
+ result.brand === null ? null : `brand ${result.brand}`,
1004
+ result.sku === null ? null : `sku ${result.sku}`,
1005
+ result.gtin === null ? null : `gtin ${result.gtin}`
1006
+ ].filter((part) => part !== null);
1007
+ if (codes.length > 0) lines.push(` ${codes.join(" · ")}`);
1008
+ if (result.price !== null) lines.push(result.price_to === null ? ` price ${moneyText(result.price)}` : ` price ${moneyText(result.price)} to ${moneyText(result.price_to)}, the range of the offers`);
1009
+ if (result.availability !== null) lines.push(` availability ${result.availability}${result.availabilities.length > 1 ? ` (the offers: ${result.availabilities.join(", ")})` : ""}`);
1010
+ const variant = result.variant;
1011
+ if (variant !== null) {
1012
+ const parts = [
1013
+ variant.name,
1014
+ variant.sku === null ? null : `sku ${variant.sku}`,
1015
+ variant.price === null ? null : moneyText(variant.price),
1016
+ variant.availability
1017
+ ].filter((part) => part !== null);
1018
+ lines.push(` variant ${parts.length > 0 ? parts.join(" · ") : "with no data"}`);
1019
+ }
1020
+ if (result.seller !== null) lines.push(` seller ${result.seller}`);
1021
+ if (result.image !== null) lines.push(` image ${result.image}`);
1022
+ if (result.archived !== void 0) lines.push(` archived copy (${result.archived.source}, captured ${result.archived.timestamp}): ${result.archived.url}`);
1023
+ const facts = [
1024
+ `${result.outcome} ${String(result.status)}`,
1025
+ result.doc === null ? null : `doc ${result.doc}`,
1026
+ result.capture === null ? null : `capture ${result.capture}`,
1027
+ result.rendered ? "rendered" : null,
1028
+ result.from_cache ? "from the store" : null
1029
+ ].filter((fact) => fact !== null);
1030
+ lines.push(` ${facts.join(" · ")}`);
1031
+ if (result.note !== null) lines.push(` note: ${result.note}`);
1032
+ lines.push(...renderLine([result.note], renderOff).map((line) => ` ${line}`));
1033
+ return lines;
1034
+ };
523
1035
  /** This function checks the options of `export`, and gives its format and its options. */
524
1036
  const exportPlan = (values) => {
525
1037
  const out = values.out;
@@ -577,7 +1089,11 @@ const readLine = (args, env) => {
577
1089
  const render = values.render ?? "auto";
578
1090
  if (render !== "auto" && render !== "never" && render !== "always") throw new UsageError(`--render is auto, never or always, not "${render}"`);
579
1091
  const rank = values.rank ?? "exact";
580
- if (rank !== "exact" && rank !== "fast") throw new UsageError(`--rank is exact or fast, not "${rank}"`);
1092
+ if (rank !== "exact" && rank !== "fast" && rank !== "hybrid") throw new UsageError(`--rank is exact, fast or hybrid, not "${rank}"`);
1093
+ const embedder = embedderOf(values, env, command);
1094
+ if (rank === "hybrid" && embedder === void 0) throw new UsageError("--rank hybrid wants --embedder transformers or ollama");
1095
+ if (command === "embed" && embedder === void 0) throw new UsageError("embed wants --embedder transformers or ollama");
1096
+ const maxDocs = values["max-docs"] === void 0 ? void 0 : whole("max-docs", values["max-docs"], 1, 1);
581
1097
  const limit = whole("limit", values.limit, 10, 1);
582
1098
  const from = command === "read" ? whole("from", values.from, 0, 0) : 0;
583
1099
  const length = whole("length", values.length, 4e3, 1);
@@ -587,11 +1103,12 @@ const readLine = (args, env) => {
587
1103
  const diffTo = versionOf("to", values.to);
588
1104
  const watchLine = command === "watch" ? watchLineOf(values, rest) : void 0;
589
1105
  const extractFields = command === "extract" ? fieldsOf(values.field) : void 0;
1106
+ const daemonLine = command === "daemon" ? daemonLineOf(values, rest) : void 0;
590
1107
  const dir = storeDir(values.dir ?? (env["POLITE_CRAWLER_DIR"] || void 0));
591
1108
  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");
1109
+ const allowUserProvided = values["allow-user-provided"] === true || (command === "mcp" || daemonLine?.action === "run") && envFlag(env, "POLITE_CRAWLER_ALLOW_USER_PROVIDED");
593
1110
  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");
1111
+ const signs = OWNER["sign-key"].split(" ").includes(command) && (watchLine === void 0 || watchLine.action === "run") && (daemonLine === void 0 || daemonLine.action === "run");
595
1112
  const signKey = values["sign-key"] ?? (signs ? env["POLITE_CRAWLER_SIGN_KEY"] || void 0 : void 0);
596
1113
  const signatureAgent = values["signature-agent"] ?? (signs ? env["POLITE_CRAWLER_SIGNATURE_AGENT"] || void 0 : void 0);
597
1114
  if (command === "key-directory") {
@@ -603,6 +1120,11 @@ const readLine = (args, env) => {
603
1120
  } catch (error) {
604
1121
  throw new UsageError(`--max-age: ${error.message}`, { cause: error });
605
1122
  }
1123
+ const recipeLine = command === "venue" || command === "product" ? recipeLineOf(command, values, rest, {
1124
+ render,
1125
+ maxAge,
1126
+ budget: values.budget === void 0 ? void 0 : crawlLimits.budgetMs
1127
+ }) : void 0;
606
1128
  return {
607
1129
  kind: "command",
608
1130
  command,
@@ -619,6 +1141,7 @@ const readLine = (args, env) => {
619
1141
  diffFrom,
620
1142
  diffTo,
621
1143
  watchLine,
1144
+ daemonLine,
622
1145
  extractFields,
623
1146
  exportPlan: command === "export" ? exportPlan(values) : void 0,
624
1147
  dir,
@@ -626,7 +1149,10 @@ const readLine = (args, env) => {
626
1149
  allowPrivate,
627
1150
  allowUserProvided,
628
1151
  signKey,
629
- signatureAgent
1152
+ signatureAgent,
1153
+ embedder,
1154
+ maxDocs,
1155
+ recipeLine
630
1156
  };
631
1157
  };
632
1158
  //#endregion
@@ -656,9 +1182,15 @@ Usage:
656
1182
  polite-crawler watch remove <url...> stop the watch of these pages
657
1183
  polite-crawler watch list print the watch list
658
1184
  polite-crawler watch run check the pages that are due, within a budget
1185
+ polite-crawler daemon check the watch list on a schedule, until SIGINT or SIGTERM;
1186
+ with --mcp-http, serve the MCP tools over local HTTP too
1187
+ polite-crawler daemon status print the state of the daemon of the store
659
1188
  polite-crawler key-directory print the key directory (JWKS) of --sign-key, to host
660
1189
  polite-crawler extract <doc> --field name=value[:kind]...
661
1190
  check proposed field values against a stored page
1191
+ polite-crawler embed --embedder <name> give vectors to the stored pages, for find --rank hybrid
1192
+ polite-crawler venue <url> read the opening hours of a venue from its own site
1193
+ polite-crawler product <url> read the schema.org Product data of a product page
662
1194
 
663
1195
  Options:
664
1196
  --dir <path> the store (default $POLITE_CRAWLER_DIR, or ~/.polite-crawler)
@@ -670,6 +1202,13 @@ Options:
670
1202
  ($POLITE_CRAWLER_SIGN_KEY)
671
1203
  --signature-agent <url> the https origin of your key directory, or the URL of a
672
1204
  JSON Web Key Set on a path, for --sign-key ($POLITE_CRAWLER_SIGNATURE_AGENT)
1205
+ --embedder <name> the local model of hybrid search, for find, embed and mcp: transformers
1206
+ (transformers.js in this process) or ollama ($POLITE_CRAWLER_EMBEDDER)
1207
+ --embed-model <m> the model: Xenova/bge-small-en-v1.5 (transformers) or nomic-embed-text
1208
+ (ollama) by default ($POLITE_CRAWLER_EMBED_MODEL)
1209
+ --ollama-url <url> the Ollama server, http://127.0.0.1:11434 by default ($POLITE_CRAWLER_OLLAMA_URL)
1210
+ --allow-remote-ollama permit an Ollama server on a different computer: the page text goes
1211
+ to it ($POLITE_CRAWLER_ALLOW_REMOTE_OLLAMA=1)
673
1212
  --json print JSON
674
1213
  -v, --version print the version
675
1214
  -h, --help print this help
@@ -683,7 +1222,8 @@ Options:
683
1222
  find --limit <n> how many results (default 10)
684
1223
  --kind <kind> only this kind of document, such as page or pdf
685
1224
  --rank <mode> exact (default): BM25 over each match; fast: leave common words
686
- out of the one-word part, faster for a long question
1225
+ out of the one-word part, faster for a long question; hybrid: BM25
1226
+ and the meaning of the text, fused (needs --embedder)
687
1227
  read --from <n> where the range starts (default 0)
688
1228
  --length <n> how many characters (default 4000)
689
1229
  --user-provided you gave this URL yourself: read a page that refuses AI input (logged)
@@ -720,26 +1260,66 @@ Options:
720
1260
  --render <mode> as for fetch
721
1261
  watch run --budget <time> the longest time (default 10m); no request starts after it
722
1262
  --max-pages <n> how many pages to check (default 100)
1263
+ daemon --tick <time> the longest wait between two ticks: 1s or more (default 1m)
1264
+ --jitter <time> a random wait from 0 to this after each tick (default 10s)
1265
+ --tick-budget <time> the longest time of the checks of one tick (default 10m)
1266
+ --tick-pages <n> how many pages one tick checks at most (default 100)
1267
+ --grace <time> at a stop, the checks in progress get this time to end (default 10s)
1268
+ --events <file> write each change as one JSON line to this file (default: the
1269
+ standard output)
1270
+ --on-change <command> start this command of yours for each change: it gets the
1271
+ event as one JSON line on its standard input
1272
+ --mcp-http serve the MCP tools over HTTP; each request needs the bearer token
1273
+ --mcp-port <n> the port (default 9307)
1274
+ --mcp-host <host> the host to bind (default 127.0.0.1)
1275
+ --mcp-token-file <file> read the token from this file (default: a new token at each
1276
+ start, in <store>/daemon/mcp-token)
1277
+ --allow-remote permit a --mcp-host that is not loopback: read the README first
1278
+ --allow-user-provided as for mcp
1279
+ daemon status exit code 0 when a daemon runs on the store, 1 when none runs
723
1280
  extract --field <name=value[:kind]> a value to check, never made up by the crawler (repeat it);
724
1281
  kind: text (default), number, money, date, url, email, phone,
725
1282
  or json-ld:<path> such as json-ld:Product.offers.price
726
1283
  --capture <id> check the text of this capture, not the current text
727
1284
  --user-provided as for read
1285
+ embed --budget <time> the longest time (default 10m); no embedding starts after it
1286
+ --max-docs <n> how many texts get vectors at most
1287
+ --kind <kind> only this kind of document
1288
+ venue --name <text> the name of the venue: the result tells if the home page names it
1289
+ --require-name with --name: read no hours from a site that does not name the venue
1290
+ --address <text> the street address, for a page that lists more than one location
1291
+ --lat <n> --lon <n> the position of the venue, for the same use (within 150 m);
1292
+ write a value below 0 after =, such as --lon=-20.5
1293
+ --osm <value> an OpenStreetMap opening_hours value, read as one more reading;
1294
+ no request goes to OpenStreetMap
1295
+ --pages <n> the pages after the home page: 0 to 8 (default 3)
1296
+ --compare <file> compare the week of the site with a kept week (JSON), day by day
1297
+ venue, product --render, --max-age, --archive-fallback, --archive-despite-robots as for fetch
1298
+ --budget <time> no request starts after this time, such as 2m (default: no limit)
1299
+ --on-behalf-of <agent> the robots.txt rules of this agent apply too, such as
1300
+ Claude-User (repeat it)
1301
+ --user-provided as for read
728
1302
 
729
1303
  Exit codes:
730
1304
  0 done
731
1305
  1 not done: a page not fetched, no match, a quote not found, a document unknown or
732
1306
  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
1307
+ did not find or that is not clear, embed that stopped before each page had vectors,
1308
+ or an error
734
1309
  (fetch: a page with an archived copy counts as done)
735
1310
  2 the command line is wrong
1311
+ 3 venue, product: nothing read: no opening hours of the site or no Product data, a page
1312
+ held back, or a page that did not come
1313
+ 4 venue, product: the site refused the crawler (a wall): a 401, a 402 or a 403, a
1314
+ challenge, a bot check, robots.txt or a stopped host. The crawler does not try a
1315
+ different identity
736
1316
  `;
737
1317
  /**
738
1318
  * This function does the command `export`, and gives the exit code. `readLine` checked the whole
739
1319
  * command line first. The export only reads the store: it makes no store, and it sends no request.
740
1320
  */
741
1321
  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);
1322
+ const result = plan.format === "wacz" ? await (await import("./wacz-QyzLp4mC.mjs").then((n) => n.n)).exportWacz(dir, plan.options) : await (await import("./warc-BHcaRKHw.mjs").then((n) => n.a)).exportWarc(dir, plan.options);
743
1323
  process.stdout.write(`${json ? JSON.stringify(result, null, 2) : describeExport(result, plan.options.includeRefusals)}\n`);
744
1324
  return 0;
745
1325
  };
@@ -861,7 +1441,7 @@ const runWatchCommand = async (crawler, line, values, limits, json, out) => {
861
1441
  };
862
1442
  /** This function gives the text of a diff for a person: the two captures, then the text of `formatDiff`. */
863
1443
  const describeDiff = async (diff) => {
864
- const { formatDiff } = await import("./diff-Dk2eSvi-.mjs").then((n) => n.i);
1444
+ const { formatDiff } = await import("./diff-BUM91Rev.mjs").then((n) => n.i);
865
1445
  return [
866
1446
  `${diff.doc} ${diff.from.capture} (${diff.from.fetched_at}) -> ${diff.to.capture} (${diff.to.fetched_at})`,
867
1447
  formatDiff(diff),
@@ -871,13 +1451,54 @@ const describeDiff = async (diff) => {
871
1451
  };
872
1452
  /** 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
1453
  const runExtract = async (crawler, doc, fields, options, json, out) => {
874
- const { extractDocument } = await import("./extract-DcwNrTK7.mjs").then((n) => n.i);
1454
+ const { extractDocument } = await import("./extract-CefB5Vfn.mjs").then((n) => n.i);
875
1455
  const claims = fields;
876
1456
  const result = crawler.extract === void 0 ? await extractDocument(crawler.runtime, doc, claims, options) : await crawler.extract(doc, claims, options);
877
1457
  if (json) out(JSON.stringify(result, null, 2));
878
1458
  else for (const line of describeExtract(result)) out(line);
879
1459
  return extractCode(result);
880
1460
  };
1461
+ /**
1462
+ * This function makes the embedder of the command line. It loads no model: the model loads at the
1463
+ * first text. The transformers embedder keeps the files of its model in `<store>/models`.
1464
+ */
1465
+ const embedderFor = async (choice, dir) => {
1466
+ try {
1467
+ if (choice.kind === "ollama") {
1468
+ const { ollamaEmbedder } = await import("./ollama-BJxkjny4.mjs").then((n) => n.a);
1469
+ return ollamaEmbedder({
1470
+ ...choice.model === void 0 ? {} : { model: choice.model },
1471
+ ...choice.url === void 0 ? {} : { baseUrl: choice.url },
1472
+ allowRemote: choice.allowRemote
1473
+ });
1474
+ }
1475
+ const { transformersEmbedder } = await import("./transformers-BQMlaGj4.mjs").then((n) => n.i);
1476
+ return transformersEmbedder({
1477
+ ...choice.model === void 0 ? {} : { model: choice.model },
1478
+ cacheDir: join(dir, "models")
1479
+ });
1480
+ } catch (error) {
1481
+ throw new UsageError(`--embedder ${choice.kind}: ${error.message}`, { cause: error });
1482
+ }
1483
+ };
1484
+ /** This function gives vectors to the stored pages within the budget. The exit code is 0 when each page has vectors. */
1485
+ const runEmbed = async (crawler, limits, options, json, out) => {
1486
+ const controller = new AbortController();
1487
+ const stop = () => controller.abort(/* @__PURE__ */ new Error("interrupted"));
1488
+ process.once("SIGINT", stop);
1489
+ try {
1490
+ const result = await crawler.embed({
1491
+ budget: limits.budgetMs,
1492
+ signal: controller.signal,
1493
+ ...options.maxDocs === void 0 ? {} : { maxDocs: options.maxDocs },
1494
+ ...options.kind === void 0 ? {} : { kind: options.kind }
1495
+ });
1496
+ out(json ? JSON.stringify(result, null, 2) : describeEmbed(result));
1497
+ return result.stopped === "done" ? 0 : 1;
1498
+ } finally {
1499
+ process.removeListener("SIGINT", stop);
1500
+ }
1501
+ };
881
1502
  /** This function does the command of the command line, and gives the exit code. */
882
1503
  const main = async (args) => {
883
1504
  const out = (text) => void process.stdout.write(`${text}\n`);
@@ -891,6 +1512,7 @@ const main = async (args) => {
891
1512
  return 0;
892
1513
  }
893
1514
  const { command, rest, values, render, rank, limit, from, length, maxAge, crawlLimits, at, diffFrom, diffTo, watchLine, extractFields, dir, signKey, signatureAgent } = line;
1515
+ const embedder = line.embedder === void 0 ? void 0 : await embedderFor(line.embedder, dir);
894
1516
  if (command === "key-directory") return keyDirectoryCommand(keyFile(signKey), signatureAgent);
895
1517
  let signer;
896
1518
  if (signKey !== void 0 && signatureAgent !== void 0) {
@@ -911,7 +1533,8 @@ const main = async (args) => {
911
1533
  contact: line.contact,
912
1534
  ...values["no-browser"] === true ? { renderer: false } : {},
913
1535
  ...line.allowPrivate ? { egress: { allowPrivate: true } } : {},
914
- ...signer === void 0 ? {} : { signer }
1536
+ ...signer === void 0 ? {} : { signer },
1537
+ ...embedder === void 0 ? {} : { embedder }
915
1538
  };
916
1539
  if (command === "mcp") {
917
1540
  const { startMcpServer } = await import("./mcp.mjs");
@@ -921,11 +1544,22 @@ const main = async (args) => {
921
1544
  });
922
1545
  return 0;
923
1546
  }
1547
+ if (command === "daemon") {
1548
+ const { daemonCommand } = await import("./cli-daemon-ZYRdy6EW.mjs");
1549
+ return daemonCommand(line.daemonLine, options, {
1550
+ allowUserProvided: line.allowUserProvided,
1551
+ json: values.json === true,
1552
+ out
1553
+ });
1554
+ }
924
1555
  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);
1556
+ const recipeLine = line.recipeLine;
1557
+ const recipes = recipeLine === void 0 ? void 0 : await import("./cli-recipes-Bci6HREA.mjs");
1558
+ const recipeInput = recipes === void 0 || recipeLine === void 0 ? void 0 : recipes.prepareRecipe(recipeLine);
1559
+ const { openCrawler } = await import("./open-CbpSCEZc.mjs").then((n) => n.a);
926
1560
  let renderOff;
927
1561
  let renderer = options.renderer;
928
- if (!(command === "fetch" || command === "crawl" || watchLine?.action === "run") || render === "never") renderer = false;
1562
+ if (!(command === "fetch" || command === "crawl" || recipeLine !== void 0 || watchLine?.action === "run") || render === "never") renderer = false;
929
1563
  else if (renderer === void 0) {
930
1564
  const { browserMissing } = await import("./browser-CKRNbIDT.mjs").then((n) => n.n);
931
1565
  renderOff = await browserMissing();
@@ -1005,6 +1639,19 @@ const main = async (args) => {
1005
1639
  userProvided: values["user-provided"] === true,
1006
1640
  at
1007
1641
  }, values.json === true, out);
1642
+ case "embed": return await runEmbed(crawler, crawlLimits, {
1643
+ maxDocs: line.maxDocs,
1644
+ kind: values.kind
1645
+ }, values.json === true, out);
1646
+ case "venue":
1647
+ case "product": {
1648
+ const io = {
1649
+ json: values.json === true,
1650
+ out,
1651
+ renderOff
1652
+ };
1653
+ return command === "venue" ? await recipes.venueCommand(crawler, recipeLine, recipeInput, io) : await recipes.productCommand(crawler, recipeLine, io);
1654
+ }
1008
1655
  default: {
1009
1656
  const counts = await crawler.reindex();
1010
1657
  print(counts, () => describeReindex(counts));
@@ -1024,6 +1671,6 @@ main(process.argv.slice(2)).then((code) => {
1024
1671
  process.exitCode = usage ? 2 : 1;
1025
1672
  });
1026
1673
  //#endregion
1027
- export {};
1674
+ export { keptWeekOf as a, describeVenue as i, describeDaemonStatus as n, productCode as o, describeProduct as r, venueCode as s, UsageError as t };
1028
1675
 
1029
1676
  //# sourceMappingURL=cli.mjs.map