crawldna 0.1.0
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/LICENSE +689 -0
- package/README.md +525 -0
- package/bin/cli.mjs +607 -0
- package/index.d.ts +376 -0
- package/package.json +64 -0
- package/src/engine/actions.mjs +57 -0
- package/src/engine/consent.mjs +125 -0
- package/src/engine/crawl-page.mjs +511 -0
- package/src/engine/decide.mjs +555 -0
- package/src/engine/perceive.mjs +647 -0
- package/src/engine/reveal.mjs +799 -0
- package/src/eval/metrics.mjs +236 -0
- package/src/eval/report.mjs +149 -0
- package/src/extract.mjs +1208 -0
- package/src/index.mjs +1115 -0
- package/src/lib/browser.mjs +242 -0
- package/src/lib/challenge.mjs +110 -0
- package/src/lib/faithful.mjs +167 -0
- package/src/lib/fetcher.mjs +151 -0
- package/src/lib/incremental.mjs +75 -0
- package/src/lib/layout.mjs +279 -0
- package/src/lib/llm.mjs +534 -0
- package/src/lib/output.mjs +66 -0
- package/src/lib/pool.mjs +30 -0
- package/src/lib/relevance.mjs +139 -0
- package/src/lib/retrieve.mjs +165 -0
- package/src/lib/robots.mjs +125 -0
- package/src/lib/runs.mjs +562 -0
- package/src/lib/semantic.mjs +172 -0
- package/src/lib/settle.mjs +69 -0
- package/src/lib/simhash.mjs +112 -0
- package/src/lib/task.mjs +65 -0
- package/src/lib/url.mjs +155 -0
- package/src/profiles/docs/llms.mjs +77 -0
- package/src/profiles/docs/sitemap.mjs +121 -0
- package/src/profiles/docs.mjs +96 -0
- package/src/reshape.mjs +204 -0
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
4
|
+
// crawldna CLI — a thin face over the core (§7). All logic lives in src/index.mjs.
|
|
5
|
+
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { crawlDocs, resumeCrawl, DEFAULT_OPTIONS } from '../src/index.mjs';
|
|
10
|
+
import { listRuns, deleteRun, deleteAllRuns, cacheRoot } from '../src/lib/runs.mjs';
|
|
11
|
+
|
|
12
|
+
const C = {
|
|
13
|
+
reset: '\x1b[0m',
|
|
14
|
+
dim: '\x1b[2m',
|
|
15
|
+
bold: '\x1b[1m',
|
|
16
|
+
red: '\x1b[31m',
|
|
17
|
+
green: '\x1b[32m',
|
|
18
|
+
yellow: '\x1b[33m',
|
|
19
|
+
blue: '\x1b[34m',
|
|
20
|
+
cyan: '\x1b[36m',
|
|
21
|
+
};
|
|
22
|
+
const useColor = process.stdout.isTTY;
|
|
23
|
+
const c = (color, s) => (useColor ? color + s + C.reset : s);
|
|
24
|
+
|
|
25
|
+
const HELP = `${C.bold}crawldna${C.reset} — general, task-driven web crawler → clean Markdown
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
crawldna <url> [--task "..."] crawl one site
|
|
29
|
+
crawldna crawl <url> [options] crawl one or more sites
|
|
30
|
+
crawldna resume <runId> [options] complete an interrupted run (crash/stop)
|
|
31
|
+
crawldna reshape <runId> --ask "..." reshape a saved extraction (Phase 2)
|
|
32
|
+
crawldna serve [--port 4000] start the optional Web UI (source repo only)
|
|
33
|
+
crawldna runs [list|rm <id…>|clear|path] manage cached runs
|
|
34
|
+
crawldna --help · --version
|
|
35
|
+
|
|
36
|
+
The crawler is CLI- and library-first; the Web UI is an optional frontend that
|
|
37
|
+
ships only with the source repository (not the npm package), so it never weighs
|
|
38
|
+
down a \`crawldna\` dependency. \`serve\` explains how to get it if it isn't present.
|
|
39
|
+
|
|
40
|
+
Two phases. The CRAWL extracts what your task asks for, VERBATIM — one faithful
|
|
41
|
+
.md per link (+ manifest.json). Every run is saved automatically to the runs cache
|
|
42
|
+
(${cacheRoot()}), and every kept page is journaled to disk AS IT IS CAPTURED — a
|
|
43
|
+
crash or Ctrl-C never loses extracted content: \`crawldna resume <runId>\` completes
|
|
44
|
+
the run from where it stopped (flags override the saved options; an API key is
|
|
45
|
+
never stored, so pass --api-key or set the env var again when resuming).
|
|
46
|
+
RESHAPE is separate and optional: turn that extraction into tables, splits or
|
|
47
|
+
filtered subsets with \`crawldna reshape <runId> --ask "…"\` (or Reshape in the Web
|
|
48
|
+
UI). It works over the saved files, as many times as you like — crawl once,
|
|
49
|
+
reshape many times.
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--task <text> extraction task (repeatable, pairs with --url; speaks to
|
|
53
|
+
the AI, so it is refused with --no-ai)
|
|
54
|
+
default: "${DEFAULT_OPTIONS.task}"
|
|
55
|
+
--url <url> a target URL (repeatable; pair with --task for per-link tasks)
|
|
56
|
+
--targets <file.json> JSON file: a targets array ([{ "url", "task" }, ...])
|
|
57
|
+
--model <name> model id for the engine (REQUIRED unless --no-ai — e.g.
|
|
58
|
+
qwen3-coder:30b for Ollama, or gpt-4o-mini for an API)
|
|
59
|
+
--provider <name> ollama (default) | openai (any OpenAI-compatible API)
|
|
60
|
+
--no-ai crawl without any model: the reveal engine still runs
|
|
61
|
+
(heuristic-triaged clicks), but pages are kept whole and
|
|
62
|
+
EVERY in-scope link is followed. Zero tokens, no model
|
|
63
|
+
needed. The task speaks only to the AI, so here it has NO
|
|
64
|
+
role: --task (and --min-relevance, which reads it) is
|
|
65
|
+
refused, output files are named from the site. Big sites
|
|
66
|
+
can take longer — contain the crawl with
|
|
67
|
+
--include/--exclude or --max-pages
|
|
68
|
+
(incompatible with --mode targeted, which IS the AI)
|
|
69
|
+
--mode <m> what to extract — an explicit switch, not guessed from
|
|
70
|
+
the task text (default: ${DEFAULT_OPTIONS.mode}):
|
|
71
|
+
complete everything reachable: llms-full.txt/sitemap
|
|
72
|
+
shortcuts tried first, pages kept WHOLE, no
|
|
73
|
+
AI link-gate/scoping calls (cheapest; works
|
|
74
|
+
with --no-ai too)
|
|
75
|
+
targeted only what the task asks: AI link gate +
|
|
76
|
+
section scoping, in any language (needs AI)
|
|
77
|
+
auto legacy: the task wording decides — never the
|
|
78
|
+
default; only for old saved runs/scripts
|
|
79
|
+
--base-url <url> API base URL for --provider openai
|
|
80
|
+
(e.g. https://api.openai.com/v1, https://openrouter.ai/api/v1)
|
|
81
|
+
--api-key <key> API key for --provider openai
|
|
82
|
+
(or set CRAWLDNA_API_KEY / OPENAI_API_KEY in the environment)
|
|
83
|
+
--browser <mode> never | auto | always (default: ${DEFAULT_OPTIONS.browser})
|
|
84
|
+
--concurrency <n> parallel page fetches (default: ${DEFAULT_OPTIONS.concurrency})
|
|
85
|
+
--max-pages <n> safety cap, 0 = unlimited (default: ${DEFAULT_OPTIONS.maxPages})
|
|
86
|
+
--max-actions <n> per-page engine action cap (default: ${DEFAULT_OPTIONS.maxActions})
|
|
87
|
+
--include <regex> only crawl URLs matching
|
|
88
|
+
--exclude <regex> skip URLs matching
|
|
89
|
+
--delay <ms> politeness (opt-in): minimum gap between requests to the
|
|
90
|
+
SAME host (default: ${DEFAULT_OPTIONS.delay} = off)
|
|
91
|
+
--respect-robots politeness (opt-in): read robots.txt — disallowed URLs are
|
|
92
|
+
SKIPPED with a warning (never silently); Crawl-delay is
|
|
93
|
+
honoured (the larger of it and --delay wins)
|
|
94
|
+
--min-relevance <0-1> focus on task: skip links below this task-relevance
|
|
95
|
+
(default: ${DEFAULT_OPTIONS.minRelevance} = off; reads the task → refused with --no-ai)
|
|
96
|
+
--max-routes <n> cap the JS-mined route candidates sent to the AI link gate,
|
|
97
|
+
top-ranked by task relevance (default: ${DEFAULT_OPTIONS.maxRoutes}; 0 = unlimited;
|
|
98
|
+
only cuts when the task discriminates — DOM links are never capped)
|
|
99
|
+
--embed-model <name> OPTIONAL embedding model (e.g. nomic-embed-text on Ollama,
|
|
100
|
+
text-embedding-3-small on an API; same provider as --model).
|
|
101
|
+
Makes task→link relevance SEMANTIC — multilingual, synonym-
|
|
102
|
+
aware — for frontier ordering, --max-routes and
|
|
103
|
+
--min-relevance, and for reshape's context retrieval.
|
|
104
|
+
Orders only, never drops by itself; unset = lexical scoring.
|
|
105
|
+
Ignored with --no-ai (zero model calls of any kind)
|
|
106
|
+
--ollama-host <url> Ollama server URL (default: http://127.0.0.1:11434)
|
|
107
|
+
--cache-dir <dir> override the runs-cache location
|
|
108
|
+
--per-document also emit one identifiable .md per page + index.md + a JSONL
|
|
109
|
+
(for programmatic use); the consolidated .md is still written
|
|
110
|
+
--mirror-hamming <n> collapse mirror/variant re-servings of a kept page — same
|
|
111
|
+
locale-stripped path (mirror host, ?ui-state variant, locale
|
|
112
|
+
twin) AND content SimHash within <n> (default: ${DEFAULT_OPTIONS.mirrorHamming}; 0 = off)
|
|
113
|
+
--near-dup-hamming <n> collapse near-duplicate pages ACROSS different paths within
|
|
114
|
+
this SimHash Hamming distance (default: ${DEFAULT_OPTIONS.nearDupHamming} = off).
|
|
115
|
+
Opt-in: content similarity alone can drop a real page
|
|
116
|
+
--incremental re-crawl only what changed: reuse pages whose sitemap
|
|
117
|
+
<lastmod> is unchanged since the last --incremental run of the
|
|
118
|
+
same target (skips render+reveal). Conservative — any doubt
|
|
119
|
+
re-crawls, so a change is never skipped. Implies saving; the
|
|
120
|
+
first run establishes the baseline
|
|
121
|
+
--port <n> port for \`serve\` (default: 4000)
|
|
122
|
+
|
|
123
|
+
Reshape (Phase 2 — over a saved run):
|
|
124
|
+
--ask <text> the reshape request, e.g. "make a table of the prices"
|
|
125
|
+
--scan <id> which link of the run to reshape (default: the only/first)
|
|
126
|
+
--no-verify skip the fidelity check (default: every produced file's values
|
|
127
|
+
— numbers, URLs, code, quoted strings — are verified against
|
|
128
|
+
the crawled sources; unverifiable ones are flagged in the file)
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
crawldna https://docusaurus.io/docs --task "Extract all documentation"
|
|
132
|
+
crawldna https://pizzeria.example/menu --task "Extract the full menu"
|
|
133
|
+
crawldna https://site.dev --no-ai --max-pages 50 # classic crawl + reveal, zero tokens
|
|
134
|
+
crawldna https://docs.dev --mode complete --model qwen3-coder:30b # whole site, pages whole,
|
|
135
|
+
# zero link-gate/scope calls
|
|
136
|
+
crawldna https://hotel.example --mode targeted --task "room prices" --model qwen3-coder:30b
|
|
137
|
+
crawldna --url https://a.dev --task "Get pricing" --url https://b.dev --task "Get API docs"
|
|
138
|
+
OPENAI_API_KEY=sk-… crawldna https://docs.dev --provider openai \\
|
|
139
|
+
--base-url https://api.openai.com/v1 --model gpt-4o-mini
|
|
140
|
+
crawldna reshape 20260615-084021-3f9c1a --ask "make a table of the prices"
|
|
141
|
+
crawldna runs # list cached runs
|
|
142
|
+
crawldna runs rm 20260615-084021-3f9c1a
|
|
143
|
+
crawldna serve --port 4000
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
const OPTION_CONFIG = {
|
|
147
|
+
task: { type: 'string', multiple: true },
|
|
148
|
+
url: { type: 'string', multiple: true },
|
|
149
|
+
targets: { type: 'string' },
|
|
150
|
+
model: { type: 'string' },
|
|
151
|
+
provider: { type: 'string' },
|
|
152
|
+
'no-ai': { type: 'boolean' },
|
|
153
|
+
mode: { type: 'string' },
|
|
154
|
+
'base-url': { type: 'string' },
|
|
155
|
+
'api-key': { type: 'string' },
|
|
156
|
+
browser: { type: 'string' },
|
|
157
|
+
concurrency: { type: 'string' },
|
|
158
|
+
'max-pages': { type: 'string' },
|
|
159
|
+
'max-actions': { type: 'string' },
|
|
160
|
+
include: { type: 'string' },
|
|
161
|
+
exclude: { type: 'string' },
|
|
162
|
+
delay: { type: 'string' },
|
|
163
|
+
'respect-robots': { type: 'boolean' },
|
|
164
|
+
'min-relevance': { type: 'string' },
|
|
165
|
+
'max-routes': { type: 'string' },
|
|
166
|
+
'embed-model': { type: 'string' },
|
|
167
|
+
'ollama-host': { type: 'string' },
|
|
168
|
+
'cache-dir': { type: 'string' },
|
|
169
|
+
'per-document': { type: 'boolean' },
|
|
170
|
+
'near-dup-hamming': { type: 'string' },
|
|
171
|
+
'mirror-hamming': { type: 'string' },
|
|
172
|
+
incremental: { type: 'boolean' },
|
|
173
|
+
port: { type: 'string' },
|
|
174
|
+
ask: { type: 'string' },
|
|
175
|
+
scan: { type: 'string' },
|
|
176
|
+
'no-verify': { type: 'boolean' },
|
|
177
|
+
help: { type: 'boolean', short: 'h' },
|
|
178
|
+
version: { type: 'boolean', short: 'v' },
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
async function buildTargets(values, positionals) {
|
|
182
|
+
// 1) explicit JSON file
|
|
183
|
+
if (values.targets) {
|
|
184
|
+
const raw = await readFile(values.targets, 'utf8');
|
|
185
|
+
const parsed = JSON.parse(raw.replace(/^/, '')); // tolerate a UTF-8 BOM
|
|
186
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const tasks = values.task || [];
|
|
190
|
+
|
|
191
|
+
// 2) repeated --url (optionally paired with --task)
|
|
192
|
+
if (values.url && values.url.length) {
|
|
193
|
+
return values.url.map((url, i) => {
|
|
194
|
+
const task = tasks.length === 1 ? tasks[0] : tasks[i];
|
|
195
|
+
return task ? { url, task } : { url };
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 3) positional URLs (after an optional `crawl` subcommand)
|
|
200
|
+
const urls = positionals.filter((p) => p !== 'crawl');
|
|
201
|
+
const shared = tasks[0];
|
|
202
|
+
return urls.map((url) => (shared ? { url, task: shared } : { url }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function optionsFromFlags(values) {
|
|
206
|
+
// The CLI is an app, not a library call: it always persists the run to the cache
|
|
207
|
+
// (rooted at the current working directory) so `crawldna runs` and `crawldna reshape`
|
|
208
|
+
// can find it afterwards. Library callers of crawlDocs save only on opt-in.
|
|
209
|
+
const o = { save: true };
|
|
210
|
+
if (values.model) o.model = values.model;
|
|
211
|
+
if (values.provider) o.provider = values.provider;
|
|
212
|
+
if (values['no-ai']) o.noAi = true;
|
|
213
|
+
if (values.mode) o.mode = values.mode;
|
|
214
|
+
if (values['base-url']) o.baseUrl = values['base-url'];
|
|
215
|
+
if (values['api-key']) o.apiKey = values['api-key'];
|
|
216
|
+
if (values.browser) o.browser = values.browser;
|
|
217
|
+
if (values.concurrency) o.concurrency = Number(values.concurrency);
|
|
218
|
+
if (values['max-pages'] != null) o.maxPages = Number(values['max-pages']);
|
|
219
|
+
if (values['max-actions'] != null) o.maxActions = Number(values['max-actions']);
|
|
220
|
+
if (values.include) o.include = values.include;
|
|
221
|
+
if (values.exclude) o.exclude = values.exclude;
|
|
222
|
+
if (values.delay != null) o.delay = Number(values.delay);
|
|
223
|
+
if (values['respect-robots']) o.respectRobots = true;
|
|
224
|
+
if (values['min-relevance'] != null) o.minRelevance = Number(values['min-relevance']);
|
|
225
|
+
if (values['max-routes'] != null) o.maxRoutes = Number(values['max-routes']);
|
|
226
|
+
if (values['embed-model']) o.embedModel = values['embed-model'];
|
|
227
|
+
if (values['ollama-host']) o.ollamaHost = values['ollama-host'];
|
|
228
|
+
if (values['cache-dir']) o.cacheDir = values['cache-dir'];
|
|
229
|
+
if (values['per-document']) o.perDocument = true;
|
|
230
|
+
if (values['near-dup-hamming'] != null) o.nearDupHamming = Number(values['near-dup-hamming']);
|
|
231
|
+
if (values['mirror-hamming'] != null) o.mirrorHamming = Number(values['mirror-hamming']);
|
|
232
|
+
if (values.incremental) o.incremental = true;
|
|
233
|
+
if (values.task && values.task.length === 1) o.task = values.task[0];
|
|
234
|
+
return o;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// One in-place progress line on a TTY (thousands of pages must not mean thousands
|
|
238
|
+
// of printed lines); a throttled plain line when piped to a file/CI log.
|
|
239
|
+
let progressOpen = false;
|
|
240
|
+
function endProgressLine() {
|
|
241
|
+
if (progressOpen) {
|
|
242
|
+
process.stdout.write('\n');
|
|
243
|
+
progressOpen = false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function renderEvent(ev) {
|
|
248
|
+
if (ev.type !== 'progress') endProgressLine();
|
|
249
|
+
switch (ev.type) {
|
|
250
|
+
case 'site':
|
|
251
|
+
process.stdout.write(`\n${c(C.bold, '▶ site')} ${ev.url} ${c(C.dim, '— ' + ev.task)}\n`);
|
|
252
|
+
break;
|
|
253
|
+
case 'strategy':
|
|
254
|
+
process.stdout.write(
|
|
255
|
+
` ${c(C.cyan, 'strategy')} ${ev.strategy}${ev.framework ? c(C.dim, ' [' + ev.framework + ']') : ''}\n`,
|
|
256
|
+
);
|
|
257
|
+
break;
|
|
258
|
+
case 'discover':
|
|
259
|
+
process.stdout.write(` ${c(C.cyan, 'discover')} ${ev.count} page(s)\n`);
|
|
260
|
+
break;
|
|
261
|
+
case 'action':
|
|
262
|
+
process.stdout.write(` ${c(C.blue, ev.action)} ${c(C.dim, ev.detail || '')}\n`);
|
|
263
|
+
break;
|
|
264
|
+
case 'extracted':
|
|
265
|
+
process.stdout.write(
|
|
266
|
+
` ${c(C.green, '✓')} ${ev.title || '(untitled)'} ${c(C.dim, `(${ev.bytes}b) ${ev.url}`)}\n`,
|
|
267
|
+
);
|
|
268
|
+
break;
|
|
269
|
+
case 'resume':
|
|
270
|
+
process.stdout.write(
|
|
271
|
+
` ${c(C.cyan, 'resume')} ${c(C.dim, `${ev.restored} page(s) restored from the journal — not re-crawled`)}\n`,
|
|
272
|
+
);
|
|
273
|
+
break;
|
|
274
|
+
case 'saved': {
|
|
275
|
+
const nFiles = (ev.scans || []).reduce((n, s) => n + (s.files || []).length, 0);
|
|
276
|
+
process.stdout.write(
|
|
277
|
+
` ${c(C.cyan, 'saved')} ${c(C.dim, `run ${ev.runId} · ${(ev.scans || []).length} link(s) · ${nFiles} file(s)`)}\n`,
|
|
278
|
+
);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case 'progress':
|
|
282
|
+
if (!ev.total) break;
|
|
283
|
+
if (useColor) {
|
|
284
|
+
process.stdout.write(`\r ${c(C.dim, `progress ${ev.done}/${ev.total}`)}\x1b[K`);
|
|
285
|
+
progressOpen = true;
|
|
286
|
+
} else if (ev.done % 25 === 0 || ev.done === ev.total) {
|
|
287
|
+
process.stdout.write(` progress ${ev.done}/${ev.total}\n`);
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
case 'dedup':
|
|
291
|
+
process.stdout.write(
|
|
292
|
+
` ${c(C.dim, `≡ dup[${ev.kind}] ${ev.url}${ev.of ? ' ≈ ' + ev.of : ''}`)}\n`,
|
|
293
|
+
);
|
|
294
|
+
break;
|
|
295
|
+
case 'warn':
|
|
296
|
+
process.stdout.write(` ${c(C.yellow, '⚠ warn')} ${ev.reason ? '[' + ev.reason + '] ' : ''}${ev.message}\n`);
|
|
297
|
+
break;
|
|
298
|
+
case 'error':
|
|
299
|
+
process.stdout.write(` ${c(C.red, '✗ error')} ${ev.message}\n`);
|
|
300
|
+
break;
|
|
301
|
+
case 'done':
|
|
302
|
+
break;
|
|
303
|
+
default:
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Drive a live run (fresh or resumed): render its events, handle Ctrl-C
|
|
309
|
+
// gracefully, print the final summary. Shared by `crawl` and `resume`.
|
|
310
|
+
async function driveRun(run) {
|
|
311
|
+
const onSigint = () => {
|
|
312
|
+
process.stdout.write(c(C.yellow, '\nStopping (graceful)… the run stays resumable: crawldna resume <runId>\n'));
|
|
313
|
+
run.stop();
|
|
314
|
+
};
|
|
315
|
+
process.on('SIGINT', onSigint);
|
|
316
|
+
|
|
317
|
+
for await (const ev of run) renderEvent(ev);
|
|
318
|
+
endProgressLine();
|
|
319
|
+
|
|
320
|
+
const result = await run.result;
|
|
321
|
+
process.off('SIGINT', onSigint);
|
|
322
|
+
|
|
323
|
+
const sc = result.stats.strategyCounts;
|
|
324
|
+
const parts = Object.entries(sc)
|
|
325
|
+
.filter(([, n]) => n > 0)
|
|
326
|
+
.map(([k, n]) => `${k}=${n}`)
|
|
327
|
+
.join(' ');
|
|
328
|
+
process.stdout.write(
|
|
329
|
+
`\n${c(C.bold, 'Summary')} ${result.stats.pages} page(s) in ${result.stats.durationMs}ms\n`,
|
|
330
|
+
);
|
|
331
|
+
if (parts) process.stdout.write(` ${c(C.dim, parts)}\n`);
|
|
332
|
+
const dd = result.stats.deduped || {};
|
|
333
|
+
const ddTotal = (dd.exact || 0) + (dd.mirror || 0) + (dd.near || 0);
|
|
334
|
+
if (ddTotal) {
|
|
335
|
+
process.stdout.write(
|
|
336
|
+
` ${c(C.dim, `${ddTotal} duplicate(s) skipped (exact=${dd.exact || 0} mirror=${dd.mirror || 0} near=${dd.near || 0})`)}\n`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (result.warnings.length) {
|
|
340
|
+
process.stdout.write(` ${c(C.yellow, result.warnings.length + ' warning(s)')}\n`);
|
|
341
|
+
}
|
|
342
|
+
if (result.run) {
|
|
343
|
+
process.stdout.write(` ${c(C.dim, 'saved to ' + result.run.dir)}\n`);
|
|
344
|
+
for (const s of result.run.scans || []) {
|
|
345
|
+
const names = (s.files || []).map((f) => f.filename).join(', ');
|
|
346
|
+
process.stdout.write(
|
|
347
|
+
` ${c(C.cyan, s.title || s.url)} ${c(
|
|
348
|
+
C.dim,
|
|
349
|
+
`${s.pages} page(s) → ${(s.files || []).length} file(s)${names ? ': ' + names : ''}`,
|
|
350
|
+
)}\n`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function runCrawl(values, positionals) {
|
|
357
|
+
const targets = await buildTargets(values, positionals);
|
|
358
|
+
if (!targets.length) {
|
|
359
|
+
process.stderr.write(c(C.red, 'No targets given.\n\n'));
|
|
360
|
+
process.stdout.write(HELP);
|
|
361
|
+
process.exitCode = 1;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// crawlDocs rejects contract violations synchronously (unknown --mode,
|
|
366
|
+
// --mode targeted + --no-ai): show the reason, not a stack trace.
|
|
367
|
+
let run;
|
|
368
|
+
try {
|
|
369
|
+
run = crawlDocs(targets, optionsFromFlags(values));
|
|
370
|
+
} catch (err) {
|
|
371
|
+
process.stderr.write(c(C.red, 'crawl failed: ' + (err && err.message ? err.message : err) + '\n'));
|
|
372
|
+
process.exitCode = 1;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
await driveRun(run);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Complete an interrupted run (#13): restore its journaled pages, re-seed the
|
|
379
|
+
// frontier and crawl only what is missing — into the SAME run folder.
|
|
380
|
+
async function resumeCommand(args, values) {
|
|
381
|
+
const runId = args[0];
|
|
382
|
+
if (!runId) {
|
|
383
|
+
process.stderr.write(c(C.red, 'Usage: crawldna resume <runId> [options]\n'));
|
|
384
|
+
process.exitCode = 1;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
let run;
|
|
388
|
+
try {
|
|
389
|
+
run = await resumeCrawl(runId, optionsFromFlags(values));
|
|
390
|
+
} catch (err) {
|
|
391
|
+
process.stderr.write(c(C.red, 'resume failed: ' + (err && err.message ? err.message : err) + '\n'));
|
|
392
|
+
process.exitCode = 1;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
await driveRun(run);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Phase 2 — reshape a saved extraction into new files, on demand.
|
|
399
|
+
async function reshapeCommand(args, values) {
|
|
400
|
+
const runId = args[0];
|
|
401
|
+
const message = values.ask || (values.task && values.task[0]);
|
|
402
|
+
if (!runId || !message) {
|
|
403
|
+
process.stderr.write(c(C.red, 'Usage: crawldna reshape <runId> --ask "<request>" [--scan <id>]\n'));
|
|
404
|
+
process.exitCode = 1;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const { reshape } = await import('../src/reshape.mjs');
|
|
408
|
+
try {
|
|
409
|
+
const out = await reshape({
|
|
410
|
+
runId,
|
|
411
|
+
scanId: values.scan || '',
|
|
412
|
+
message,
|
|
413
|
+
model: values.model || DEFAULT_OPTIONS.model,
|
|
414
|
+
provider: values.provider,
|
|
415
|
+
host: values['ollama-host'],
|
|
416
|
+
baseUrl: values['base-url'],
|
|
417
|
+
apiKey: values['api-key'],
|
|
418
|
+
embedModel: values['embed-model'],
|
|
419
|
+
cacheDir: values['cache-dir'],
|
|
420
|
+
verify: !values['no-verify'],
|
|
421
|
+
});
|
|
422
|
+
if (out.reply) process.stdout.write('\n' + out.reply + '\n');
|
|
423
|
+
if (out.files.length) {
|
|
424
|
+
process.stdout.write(`\n${c(C.bold, 'Files')} ${c(C.dim, '(saved under the run’s chat/ folder)')}\n`);
|
|
425
|
+
for (const f of out.files) {
|
|
426
|
+
process.stdout.write(` ${c(C.green, '✓')} ${f.filename} ${c(C.dim, `(${f.bytes}b)`)}\n`);
|
|
427
|
+
const fid = f.fidelity;
|
|
428
|
+
if (fid && fid.unverified && fid.unverified.length) {
|
|
429
|
+
process.stdout.write(
|
|
430
|
+
` ${c(C.yellow, `⚠ ${fid.unverified.length}/${fid.checked} value(s) not found in the crawled sources`)} ` +
|
|
431
|
+
c(C.dim, '— possibly invented; see the warning inside the file\n'),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
process.stdout.write(c(C.dim, '\n(no files produced — the model answered without emitting any)\n'));
|
|
437
|
+
}
|
|
438
|
+
if (out.truncated) {
|
|
439
|
+
process.stdout.write(
|
|
440
|
+
c(
|
|
441
|
+
C.yellow,
|
|
442
|
+
out.contextMode === 'retrieval'
|
|
443
|
+
? '\n⚠ the sources exceed the model budget — only the sections relevant to your request were sent\n'
|
|
444
|
+
: '\n⚠ only the first part of a large extraction was used (nothing in the request narrows it down)\n',
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
} catch (err) {
|
|
449
|
+
process.stderr.write(c(C.red, 'reshape failed: ' + (err && err.message ? err.message : err) + '\n'));
|
|
450
|
+
process.exitCode = 1;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function fmtDate(iso) {
|
|
455
|
+
try {
|
|
456
|
+
return new Date(iso).toLocaleString();
|
|
457
|
+
} catch {
|
|
458
|
+
return iso || '';
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function runsCommand(args, values) {
|
|
463
|
+
const opts = values['cache-dir'] ? { cacheDir: values['cache-dir'] } : {};
|
|
464
|
+
const sub = args[0] || 'list';
|
|
465
|
+
|
|
466
|
+
if (sub === 'path') {
|
|
467
|
+
process.stdout.write(cacheRoot(opts) + '\n');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
472
|
+
const ids = args.slice(1);
|
|
473
|
+
if (!ids.length) {
|
|
474
|
+
process.stderr.write(c(C.red, 'Usage: crawldna runs rm <id> [<id> …]\n'));
|
|
475
|
+
process.exitCode = 1;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
for (const id of ids) {
|
|
479
|
+
try {
|
|
480
|
+
await deleteRun(id, opts);
|
|
481
|
+
process.stdout.write(`${c(C.green, '✓')} deleted ${id}\n`);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
process.stderr.write(c(C.red, `✗ ${id}: ${err && err.message}\n`));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (sub === 'clear' || sub === 'prune') {
|
|
490
|
+
const n = await deleteAllRuns(opts);
|
|
491
|
+
process.stdout.write(`${c(C.green, '✓')} deleted ${n} run(s)\n`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// default: list
|
|
496
|
+
const runs = await listRuns(opts);
|
|
497
|
+
if (!runs.length) {
|
|
498
|
+
process.stdout.write(c(C.dim, `No cached runs in ${cacheRoot(opts)}\n`));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
process.stdout.write(`${c(C.bold, 'Cached runs')} ${c(C.dim, '(' + cacheRoot(opts) + ')')}\n\n`);
|
|
502
|
+
for (const r of runs) {
|
|
503
|
+
const scans = r.scans || [];
|
|
504
|
+
const nFiles = scans.reduce((n, s) => n + (s.files || []).length, 0);
|
|
505
|
+
// 'running' = crashed mid-crawl (or still crawling elsewhere); 'stopped' =
|
|
506
|
+
// voluntary Stop. Both keep their journal and can be completed with resume.
|
|
507
|
+
const status =
|
|
508
|
+
r.status === 'running'
|
|
509
|
+
? ' ' + c(C.yellow, `⏸ interrupted — resume: crawldna resume ${r.id}`)
|
|
510
|
+
: r.status === 'stopped'
|
|
511
|
+
? ' ' + c(C.yellow, `⏸ stopped — resume: crawldna resume ${r.id}`)
|
|
512
|
+
: '';
|
|
513
|
+
process.stdout.write(` ${c(C.cyan, r.id)} ${c(C.dim, fmtDate(r.createdAt))}${status}\n`);
|
|
514
|
+
process.stdout.write(` ${r.pages} page(s) · ${scans.length} link(s) → ${nFiles} file(s)\n`);
|
|
515
|
+
for (const s of scans) {
|
|
516
|
+
const files = (s.files || []).map((f) => f.filename).join(', ');
|
|
517
|
+
process.stdout.write(
|
|
518
|
+
` ${c(C.dim, '• ' + (s.url || s.title || s.scanId))}` +
|
|
519
|
+
`${s.task ? c(C.dim, ' — ' + s.task) : ''}${files ? c(C.dim, ' [' + files + ']') : ''}\n`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
process.stdout.write(`\n${c(C.dim, 'Delete: crawldna runs rm <id> · clear all: crawldna runs clear')}\n`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function main() {
|
|
527
|
+
let parsed;
|
|
528
|
+
try {
|
|
529
|
+
parsed = parseArgs({
|
|
530
|
+
args: process.argv.slice(2),
|
|
531
|
+
options: OPTION_CONFIG,
|
|
532
|
+
allowPositionals: true,
|
|
533
|
+
});
|
|
534
|
+
} catch (err) {
|
|
535
|
+
process.stderr.write(c(C.red, 'Argument error: ' + err.message + '\n\n'));
|
|
536
|
+
process.stdout.write(HELP);
|
|
537
|
+
process.exitCode = 1;
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const { values, positionals } = parsed;
|
|
542
|
+
|
|
543
|
+
if (values.help || (positionals[0] === 'help')) {
|
|
544
|
+
process.stdout.write(HELP);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (values.version || positionals[0] === 'version') {
|
|
549
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
550
|
+
process.stdout.write(`crawldna ${pkg.version}\n`);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (positionals[0] === 'serve') {
|
|
555
|
+
// The Web UI is OPTIONAL and ships only with the repository, not the npm
|
|
556
|
+
// package (it would be dead weight for library/CLI users). Detect whether the
|
|
557
|
+
// UI is present and, if not, explain how to get it instead of crashing — the
|
|
558
|
+
// crawler itself works fully without it.
|
|
559
|
+
const uiEntry = new URL('../ui/server.mjs', import.meta.url);
|
|
560
|
+
let hasUI = true;
|
|
561
|
+
try {
|
|
562
|
+
await stat(uiEntry);
|
|
563
|
+
} catch {
|
|
564
|
+
hasUI = false;
|
|
565
|
+
}
|
|
566
|
+
if (!hasUI) {
|
|
567
|
+
process.stderr.write(
|
|
568
|
+
c(C.yellow, '\nThe Web UI is optional and not bundled with the npm package') +
|
|
569
|
+
' (to keep it lightweight).\n\n' +
|
|
570
|
+
'The crawler works fully without it — use the CLI or the library API:\n' +
|
|
571
|
+
c(C.cyan, ' crawldna <url> --task "…" --model qwen3-coder:30b\n') +
|
|
572
|
+
c(C.cyan, " import { crawlDocs } from 'crawldna'\n") +
|
|
573
|
+
'\nTo use the Web UI, run it from the source repository:\n' +
|
|
574
|
+
c(C.cyan, ' git clone https://github.com/BogdanVasaiu/crawlDNA && cd crawlDNA\n') +
|
|
575
|
+
c(C.cyan, ' npm install && npm run serve\n'),
|
|
576
|
+
);
|
|
577
|
+
process.exitCode = 1;
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const { startServer } = await import('../ui/server.mjs');
|
|
581
|
+
const port = Number(values.port) || 4000;
|
|
582
|
+
await startServer({ port });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (positionals[0] === 'runs') {
|
|
587
|
+
await runsCommand(positionals.slice(1), values);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (positionals[0] === 'reshape') {
|
|
592
|
+
await reshapeCommand(positionals.slice(1), values);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (positionals[0] === 'resume') {
|
|
597
|
+
await resumeCommand(positionals.slice(1), values);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
await runCrawl(values, positionals);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
main().catch((err) => {
|
|
605
|
+
process.stderr.write('Fatal: ' + (err && err.stack ? err.stack : err) + '\n');
|
|
606
|
+
process.exitCode = 1;
|
|
607
|
+
});
|