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