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.
- package/README.md +555 -8
- package/build/cli-daemon-ZYRdy6EW.mjs +38 -0
- package/build/cli-daemon-ZYRdy6EW.mjs.map +1 -0
- package/build/cli-recipes-Bci6HREA.mjs +59 -0
- package/build/cli-recipes-Bci6HREA.mjs.map +1 -0
- package/build/cli.mjs +673 -26
- package/build/cli.mjs.map +1 -1
- package/build/daemon-DK6U51_v.mjs +515 -0
- package/build/daemon-DK6U51_v.mjs.map +1 -0
- package/build/{open-Ca1lcWR3.d.mts → daemon-wwIWsUmU.d.mts} +919 -17
- package/build/{diff-Dk2eSvi-.mjs → diff-BUM91Rev.mjs} +2 -2
- package/build/{diff-Dk2eSvi-.mjs.map → diff-BUM91Rev.mjs.map} +1 -1
- package/build/{extract-DcwNrTK7.mjs → extract-CefB5Vfn.mjs} +2 -2
- package/build/{extract-DcwNrTK7.mjs.map → extract-CefB5Vfn.mjs.map} +1 -1
- package/build/hours.d.mts +26 -0
- package/build/hours.mjs +48 -1
- package/build/hours.mjs.map +1 -1
- package/build/index.d.mts +491 -11
- package/build/index.mjs +14 -9
- package/build/loopback-C_QYowSf.mjs +22 -0
- package/build/loopback-C_QYowSf.mjs.map +1 -0
- package/build/mcp.d.mts +158 -2
- package/build/mcp.mjs +567 -18
- package/build/mcp.mjs.map +1 -1
- package/build/ollama-BJxkjny4.mjs +160 -0
- package/build/ollama-BJxkjny4.mjs.map +1 -0
- package/build/{open-DnrWgkC0.mjs → open-CbpSCEZc.mjs} +1127 -16
- package/build/open-CbpSCEZc.mjs.map +1 -0
- package/build/recipe-GiFQojmo.mjs +279 -0
- package/build/recipe-GiFQojmo.mjs.map +1 -0
- package/build/recipe-product-Dellqudu.mjs +892 -0
- package/build/recipe-product-Dellqudu.mjs.map +1 -0
- package/build/{runtime-k8mo1_7U.mjs → runtime-D_r2SYge.mjs} +3 -3
- package/build/{runtime-k8mo1_7U.mjs.map → runtime-D_r2SYge.mjs.map} +1 -1
- package/build/transformers-BQMlaGj4.mjs +133 -0
- package/build/transformers-BQMlaGj4.mjs.map +1 -0
- package/build/{duration-iIuMMofi.mjs → version-BPem-XZd.mjs} +10 -10
- package/build/version-BPem-XZd.mjs.map +1 -0
- package/build/{wacz-sfOLT8e9.mjs → wacz-QyzLp4mC.mjs} +3 -3
- package/build/{wacz-sfOLT8e9.mjs.map → wacz-QyzLp4mC.mjs.map} +1 -1
- package/build/{warc-VIPscWxY.mjs → warc-BHcaRKHw.mjs} +2 -2
- package/build/{warc-VIPscWxY.mjs.map → warc-BHcaRKHw.mjs.map} +1 -1
- package/build/webbotauth-CjJdTAfE.mjs.map +1 -1
- package/package.json +10 -2
- package/build/duration-iIuMMofi.mjs.map +0 -1
- 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
|
|
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
|
|
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,
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|