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/src/index.mjs
ADDED
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// crawldna — public API + core orchestration.
|
|
4
|
+
//
|
|
5
|
+
// crawlDocs(targets, options) returns a `run` that is:
|
|
6
|
+
// - async-iterable (yields events, §6)
|
|
7
|
+
// - exposes `run.result` (Promise<Result>, §5)
|
|
8
|
+
// - exposes `run.stop()` (graceful stop)
|
|
9
|
+
//
|
|
10
|
+
// The CLI, UI, and refdna are all just consumers of this. No crawling or
|
|
11
|
+
// strategy logic lives outside the core.
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { runDocsProfile } from './profiles/docs.mjs';
|
|
15
|
+
import { crawlPageWithEngine } from './engine/crawl-page.mjs';
|
|
16
|
+
import { assembleScan, assemblePerDocument, assembleStates } from './lib/layout.mjs';
|
|
17
|
+
import { saveRun, scanIdFor, initRun, appendJournal, loadRunForResume, findBaselineRun, cacheRoot } from './lib/runs.mjs';
|
|
18
|
+
import { sitemapLastmodMap } from './profiles/docs/sitemap.mjs';
|
|
19
|
+
import { planIncremental, planConditional, httpValidators } from './lib/incremental.mjs';
|
|
20
|
+
import { retainBrowser, releaseBrowser, configureContextPool } from './lib/browser.mjs';
|
|
21
|
+
import { normalizeUrl, inScope, pathOf, originOf, hostOf, siblingKey } from './lib/url.mjs';
|
|
22
|
+
import { modeBehavior, MODES } from './lib/task.mjs';
|
|
23
|
+
import { createHostGate, parseRobots, isAllowed } from './lib/robots.mjs';
|
|
24
|
+
import { fetchText, conditionalGet } from './lib/fetcher.mjs';
|
|
25
|
+
import { resolveLlm, checkModel, abortPendingLlm, llmDisabled } from './lib/llm.mjs';
|
|
26
|
+
import { simhash, hamming } from './lib/simhash.mjs';
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_OPTIONS = {
|
|
29
|
+
task: 'Extract the complete documentation.',
|
|
30
|
+
model: '', // REQUIRED — no fake default. The engine needs a real model: a local
|
|
31
|
+
// Ollama model (e.g. 'qwen3-coder:30b') or an OpenAI-compatible model id. There is
|
|
32
|
+
// no model that is universally present, so pretending one exists only produces a
|
|
33
|
+
// silent failure. Pick one explicitly (see `provider`).
|
|
34
|
+
provider: 'ollama', // 'ollama' (local) | 'openai' (any OpenAI-compatible API: URL + key)
|
|
35
|
+
embedModel: undefined, // #22 — OPTIONAL embedding model id (e.g. 'nomic-embed-text' on
|
|
36
|
+
// Ollama, 'text-embedding-3-small' on OpenAI; same provider/baseUrl as `model`).
|
|
37
|
+
// When set, task→link relevance becomes SEMANTIC (multilingual, synonym-aware):
|
|
38
|
+
// it feeds the best-first frontier ordering, the route budget (#16), the opt-in
|
|
39
|
+
// minRelevance pruning and the reshape retrieval. Embeddings ORDER, they never
|
|
40
|
+
// drop anything by themselves; unset (default) or unreachable (one loud warning)
|
|
41
|
+
// → the lexical scorer as the floor. With `noAi` the semantic tier is OFF —
|
|
42
|
+
// zero calls to ANY model, embeddings included (rule #6).
|
|
43
|
+
noAi: false, // CRAWL WITHOUT AI. The engine keeps its full mechanics — render, reveal
|
|
44
|
+
// (heuristic-triaged clicks), extract, dedup — but makes ZERO model calls: pages are
|
|
45
|
+
// kept whole (no section scoping) and EVERY in-scope link is followed (no link gate).
|
|
46
|
+
// Costs no tokens and needs no model; trade-off: the output is not task-filtered and
|
|
47
|
+
// a large site may take LONGER overall (the AI link gate is what keeps a crawl small).
|
|
48
|
+
// Pair with include/exclude or maxPages to contain it. #23 — the task has NO role
|
|
49
|
+
// here (rule #6 to its end: the task speaks only to the AI, and there is no AI): an
|
|
50
|
+
// explicit task — or minRelevance, which reads it — is refused loudly, the default
|
|
51
|
+
// task is dropped, and output files are named from the site instead.
|
|
52
|
+
// Incompatible with mode 'targeted' (task-filtering IS the AI) — refused loudly.
|
|
53
|
+
mode: 'complete', // #20/#23 — WHAT to extract, as an EXPLICIT choice (free text never
|
|
54
|
+
// drives the engine — rule #6):
|
|
55
|
+
// 'complete' (default): everything reachable — completeness shortcuts (llms-full.txt /
|
|
56
|
+
// sitemap) always tried, pages kept WHOLE, and ZERO link-gate/scoping calls
|
|
57
|
+
// even with AI on (keep/drop is meaningless when the user asked for all; the
|
|
58
|
+
// default-on mirror dedup contains follow-everything). AI still drives
|
|
59
|
+
// reveal + nav-plan. Works with or without noAi.
|
|
60
|
+
// 'targeted': only what the task asks — AI link gate + per-page section scoping,
|
|
61
|
+
// whatever language/wording the task uses. Requires AI (noAi is refused).
|
|
62
|
+
// 'auto': the historical behaviour — a multilingual regex on the task (isDocsTask)
|
|
63
|
+
// picks the docs path. NEVER the default anymore (#23): kept ONLY for saved runs
|
|
64
|
+
// and callers that ask for it by name; the CLI and UI never send it.
|
|
65
|
+
ollamaHost: undefined, // override the Ollama server URL (default: http://127.0.0.1:11434)
|
|
66
|
+
baseUrl: undefined, // OpenAI-compatible API base URL (provider 'openai')
|
|
67
|
+
apiKey: undefined, // API key (provider 'openai'); falls back to CRAWLDNA_API_KEY / OPENAI_API_KEY
|
|
68
|
+
browser: 'auto', // 'never' | 'auto' | 'always'
|
|
69
|
+
concurrency: 4,
|
|
70
|
+
maxPages: 0, // 0 = unlimited
|
|
71
|
+
maxActions: 40, // per-page reveal action cap. A ceiling, not a target: simple pages
|
|
72
|
+
// stop early when no controls remain; stateful pages (paginators, calendar months,
|
|
73
|
+
// many tabs) need the headroom. Disabled controls are skipped so it isn't wasted.
|
|
74
|
+
include: undefined,
|
|
75
|
+
exclude: undefined,
|
|
76
|
+
delay: 0, // #14 — POLITENESS, opt-in: minimum milliseconds between requests to the
|
|
77
|
+
// SAME host (frontier page loads). 0 (default) = off, today's behaviour. Concurrent
|
|
78
|
+
// workers queue behind each other on one host but never across hosts.
|
|
79
|
+
respectRobots: false, // #14 — POLITENESS, opt-in: read each origin's robots.txt and
|
|
80
|
+
// SKIP disallowed URLs — each skip is a loud `robots` warning, never silent (the
|
|
81
|
+
// warning is the contract). Also honours Crawl-delay (combined with `delay`, the
|
|
82
|
+
// larger wins). Off (default) = the tool stays user-directed, like wget.
|
|
83
|
+
maxRoutes: 200, // #16 — cap on the SPECULATIVE JS-mined routes (perceive digs up to
|
|
84
|
+
// 800 same-site paths per page out of script/JSON blobs) that reach the AI link
|
|
85
|
+
// gate, ranked by task relevance first. 0 = unlimited. Conservative: the cut only
|
|
86
|
+
// happens when the scores discriminate among the routes — a generic task (all 1)
|
|
87
|
+
// or an off-vocabulary one (all 0) cuts NOTHING. Real DOM links are never capped.
|
|
88
|
+
minRelevance: 0, // FOCUSED MODE (0 = off, precision-first default). When > 0 (0..1),
|
|
89
|
+
// links whose task-relevance score is below this are pruned BEFORE the AI gate — a
|
|
90
|
+
// universal, task-driven way to keep the crawl on-topic without per-site rules. Only
|
|
91
|
+
// applies when the task discriminates among a page's links, so a generic task is never
|
|
92
|
+
// over-pruned. Trades some recall for speed/scope, so it stays opt-in. See lib/relevance.mjs.
|
|
93
|
+
// Persistence is OPT-IN. As a library, crawldna writes NOTHING by default: the full
|
|
94
|
+
// result (scans[].files[].markdown) is returned in memory for the caller to save
|
|
95
|
+
// wherever they like. A run is written to the cache ONLY when the caller opts in —
|
|
96
|
+
// by setting `save: true`, or by giving an explicit `cacheDir` (or CRAWLDNA_CACHE_DIR).
|
|
97
|
+
// The CLI and Web UI are apps, so they pass `save: true` (cache rooted at cwd).
|
|
98
|
+
save: false,
|
|
99
|
+
cacheDir: undefined, // where to save when saving is on (default: <cwd>/.crawldna/runs)
|
|
100
|
+
perDocument: false, // ALSO package one identifiable .md per page (+ index.md + JSONL)
|
|
101
|
+
// for programmatic consumers, alongside the consolidated .md. Off by default (the
|
|
102
|
+
// consolidated file is friendlier for a human). Pure repackaging — content stays
|
|
103
|
+
// verbatim, nothing is filtered or transformed. See lib/layout.mjs assemblePerDocument.
|
|
104
|
+
nearDupHamming: 0, // CROSS-PATH NEAR-DUP DEDUP (0 = off, exact-only — the safe default).
|
|
105
|
+
// When > 0, a page whose 64-bit SimHash is within this Hamming distance of ANY already-kept
|
|
106
|
+
// page is collapsed. OPT-IN because content similarity alone cannot tell a duplicate from a
|
|
107
|
+
// sibling: measured on a real run (vuetify, 1491 pages), 36 pairs of GENUINELY DISTINCT API
|
|
108
|
+
// pages sat at distance ≤3 (two at 0 — templated pages whose distinguishing tokens are link
|
|
109
|
+
// text, which pageSignature strips). Any global threshold would drop real content.
|
|
110
|
+
mirrorHamming: 8, // MIRROR/VARIANT DEDUP (default ON). Collapse a page only when BOTH
|
|
111
|
+
// signals agree it is a re-serving of a kept page: (1) its URL is a SIBLING of the kept
|
|
112
|
+
// page's — same path once a leading locale segment is stripped, so mirror hosts
|
|
113
|
+
// (dev./staging./v2.), UI-state query variants (?panel=settings) and locale twins
|
|
114
|
+
// (/en/x vs /x) qualify — and (2) its content SimHash is within this Hamming distance.
|
|
115
|
+
// The two-signal AND is what makes a default-on setting safe: measured on the same run,
|
|
116
|
+
// TRUE sibling duplicates cluster at distance ≤8 (72% of 657 pairs; median 4) while
|
|
117
|
+
// sibling-SHAPED pages with real content differences (release-notes?version=A vs B,
|
|
118
|
+
// same path on an unrelated product subdomain) start at 10 and sit mostly ≥23. Without
|
|
119
|
+
// this gate, 57% of that run's pages (and ~35 min of its hour) were mirror re-crawls.
|
|
120
|
+
// 0 = off. Cross-PATH near-dups are never touched by this tier (see nearDupHamming).
|
|
121
|
+
incremental: false, // #6 — INCREMENTAL RE-CRAWL (opt-in). On a re-crawl of the same
|
|
122
|
+
// target(s), REUSE the pages whose sitemap <lastmod> is unchanged since the last
|
|
123
|
+
// incremental run (skipping render + reveal) and re-crawl only what changed. CONSERVATIVE
|
|
124
|
+
// by construction: a page is reused ONLY when its stored and current lastmod are both
|
|
125
|
+
// present and equal — any uncertainty re-crawls, so a real change is never skipped (rule
|
|
126
|
+
// #1). Implies save, and RETAINS this run's journal so it becomes the next run's baseline.
|
|
127
|
+
// No sitemap, or no prior incremental baseline → a normal full crawl that establishes one.
|
|
128
|
+
onEvent: undefined,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The content signature addPage dedups on: links/URLs/whitespace stripped, then
|
|
133
|
+
* lowercased. Shared with the resume replay (#13) so a restored run recognises
|
|
134
|
+
* its own pages — the two MUST stay byte-identical or resume would re-keep them.
|
|
135
|
+
*/
|
|
136
|
+
export function pageSignature(markdown) {
|
|
137
|
+
return String(markdown || '')
|
|
138
|
+
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '') // drop links/images entirely
|
|
139
|
+
.replace(/https?:\/\/\S+/g, '')
|
|
140
|
+
.replace(/\s+/g, ' ')
|
|
141
|
+
.trim()
|
|
142
|
+
.toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Normalise the `targets` argument (§5) into `[{ url, task }]`. */
|
|
146
|
+
export function normalizeTargets(targets, defaultTask) {
|
|
147
|
+
const one = (t) => {
|
|
148
|
+
if (typeof t === 'string') return { url: t, task: defaultTask };
|
|
149
|
+
if (t && typeof t === 'object' && t.url) return { url: t.url, task: t.task || defaultTask };
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
const list = Array.isArray(targets) ? targets : [targets];
|
|
153
|
+
return list.map(one).filter(Boolean);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** An async-iterable event stream backed by a buffer (no backpressure loss). */
|
|
157
|
+
function createEventStream() {
|
|
158
|
+
const queue = [];
|
|
159
|
+
let pending = null;
|
|
160
|
+
let closed = false;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
push(ev) {
|
|
164
|
+
if (closed) return;
|
|
165
|
+
if (pending) {
|
|
166
|
+
const resolve = pending;
|
|
167
|
+
pending = null;
|
|
168
|
+
resolve({ value: ev, done: false });
|
|
169
|
+
} else {
|
|
170
|
+
queue.push(ev);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
close() {
|
|
174
|
+
closed = true;
|
|
175
|
+
if (pending) {
|
|
176
|
+
const resolve = pending;
|
|
177
|
+
pending = null;
|
|
178
|
+
resolve({ value: undefined, done: true });
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[Symbol.asyncIterator]() {
|
|
182
|
+
return {
|
|
183
|
+
next() {
|
|
184
|
+
if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
|
|
185
|
+
if (closed) return Promise.resolve({ value: undefined, done: true });
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
pending = resolve;
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
return() {
|
|
191
|
+
closed = true;
|
|
192
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The defining capability lives behind this single entry point.
|
|
201
|
+
* @param {string|string[]|{url,task?}|Array<{url,task?}>} targets
|
|
202
|
+
* @param {Partial<typeof DEFAULT_OPTIONS>} [options]
|
|
203
|
+
*/
|
|
204
|
+
/**
|
|
205
|
+
* Replay journal records into a scan: restored pages join scan.pages and the dedup
|
|
206
|
+
* indexes (exactly as addPage would build them), so a later crawl recognises their
|
|
207
|
+
* mirrors/near-dups. Returns { visited, seeds } for the frontier — restored URLs are
|
|
208
|
+
* pre-visited (never re-rendered) and their links re-seed discovery.
|
|
209
|
+
*
|
|
210
|
+
* Shared by resume (#13) and incremental (#6): the SAME restore, just fed all records
|
|
211
|
+
* for a resume and only the still-fresh records for an incremental crawl.
|
|
212
|
+
*/
|
|
213
|
+
function restoreRecords(scan, records, opts) {
|
|
214
|
+
const visited = new Set();
|
|
215
|
+
const seeds = new Set();
|
|
216
|
+
for (const rec of records) {
|
|
217
|
+
const page = rec && rec.page;
|
|
218
|
+
for (const l of (rec && rec.links) || []) seeds.add(l);
|
|
219
|
+
if (!page || !page.url || typeof page.markdown !== 'string') continue;
|
|
220
|
+
const sig = pageSignature(page.markdown);
|
|
221
|
+
const hash = createHash('sha1').update(sig).digest('hex');
|
|
222
|
+
visited.add(normalizeUrl(page.url) || page.url);
|
|
223
|
+
if (scan._hashes.has(hash)) continue; // defensive: a journal shouldn't hold dupes
|
|
224
|
+
scan._hashes.add(hash);
|
|
225
|
+
// Rebuild the same dedup indexes addPage maintains, so the crawl recognises
|
|
226
|
+
// mirrors/near-dups of RESTORED pages too.
|
|
227
|
+
if (opts.nearDupHamming > 0 || opts.mirrorHamming > 0) {
|
|
228
|
+
const sh = simhash(sig);
|
|
229
|
+
if (opts.nearDupHamming > 0) {
|
|
230
|
+
if (!scan._simhashes) scan._simhashes = [];
|
|
231
|
+
scan._simhashes.push(sh);
|
|
232
|
+
}
|
|
233
|
+
if (opts.mirrorHamming > 0) {
|
|
234
|
+
const key = siblingKey(page.url);
|
|
235
|
+
const kin = scan._siblings.get(key) || [];
|
|
236
|
+
kin.push({ sh, url: page.url });
|
|
237
|
+
scan._siblings.set(key, kin);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
scan.pages.push(page);
|
|
241
|
+
const s = (page.meta && page.meta.strategy) || 'agent';
|
|
242
|
+
scan.stats.strategyCounts[s] = (scan.stats.strategyCounts[s] || 0) + 1;
|
|
243
|
+
const residual = (page.meta && page.meta.revealResidualChars) || 0;
|
|
244
|
+
if (residual > 0) {
|
|
245
|
+
scan.stats.revealResidual.pages += 1;
|
|
246
|
+
scan.stats.revealResidual.chars += residual;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { visited, seeds };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* #6 — confirm which eligible baseline pages are still unchanged via an HTTP 304.
|
|
254
|
+
* Bounded-parallel conditional GETs (never renders); a 304 marks the record for
|
|
255
|
+
* reuse and refreshes its stored validators. Returns the confirmed-unchanged records.
|
|
256
|
+
*/
|
|
257
|
+
async function conditionalReuse(records, concurrency, shouldStop) {
|
|
258
|
+
const confirmed = [];
|
|
259
|
+
const limit = Math.max(1, Math.min(Number(concurrency) || 1, 16));
|
|
260
|
+
let i = 0;
|
|
261
|
+
async function worker() {
|
|
262
|
+
while (i < records.length) {
|
|
263
|
+
if (shouldStop && shouldStop()) return;
|
|
264
|
+
const rec = records[i++];
|
|
265
|
+
const { etag, lastModified } = httpValidators(rec.page);
|
|
266
|
+
let r;
|
|
267
|
+
try {
|
|
268
|
+
r = await conditionalGet(rec.page.url, { etag, lastModified });
|
|
269
|
+
} catch {
|
|
270
|
+
r = { notModified: false };
|
|
271
|
+
}
|
|
272
|
+
if (r.notModified) {
|
|
273
|
+
if (r.etag) rec.page.meta.httpEtag = r.etag;
|
|
274
|
+
if (r.lastModified) rec.page.meta.httpLastModified = r.lastModified;
|
|
275
|
+
confirmed.push(rec);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await Promise.all(Array.from({ length: Math.min(limit, records.length) }, worker));
|
|
280
|
+
return confirmed;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function crawlDocs(targets, options = {}) {
|
|
284
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
285
|
+
// #20 — `mode` is an explicit contract, so misuse fails FAST and LOUD (a silent
|
|
286
|
+
// coercion would be exactly the invisible behaviour switch rule #6 forbids).
|
|
287
|
+
opts.mode = String(opts.mode || 'auto').toLowerCase();
|
|
288
|
+
if (!MODES.includes(opts.mode)) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Unknown mode '${opts.mode}'. Valid modes: 'complete' (everything reachable, pages whole, ` +
|
|
291
|
+
`no link-gate/scoping), 'targeted' (only what the task asks — needs AI), ` +
|
|
292
|
+
`'auto' (legacy: the task text decides).`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (opts.mode === 'targeted' && opts.noAi) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
"mode 'targeted' needs AI: deciding which links and sections match the task IS the model's " +
|
|
298
|
+
"job, so it cannot run with noAi. Use mode 'complete' (full crawl, zero model calls) or " +
|
|
299
|
+
'enable AI.',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
// #23 — with noAi the task has NO role at all (rule #6 to its end: the task speaks
|
|
303
|
+
// only to the AI, and there is no AI). Accepting one would let free text silently
|
|
304
|
+
// steer link ordering and file naming — exactly the invisible behaviour rule #6
|
|
305
|
+
// forbids — so an explicit task is refused loudly, and so is minRelevance (its
|
|
306
|
+
// score IS task-relevance). Resumed runs are exempt (their saved options may
|
|
307
|
+
// predate this contract); their tasks are stripped below instead.
|
|
308
|
+
if (opts.noAi && !opts.__resume) {
|
|
309
|
+
const explicitTask =
|
|
310
|
+
options.task !== undefined ||
|
|
311
|
+
(Array.isArray(targets) ? targets : [targets]).some(
|
|
312
|
+
(t) => t && typeof t === 'object' && t.task,
|
|
313
|
+
);
|
|
314
|
+
if (explicitTask) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
'noAi means zero model calls — and the task speaks only to the model, so it can have ' +
|
|
317
|
+
'no effect. Remove the task (output files are named from the site), or enable AI.',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (Number(opts.minRelevance) > 0) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
'minRelevance scores links against the task, and with noAi the task has no role. ' +
|
|
323
|
+
'Remove it — contain the crawl with include/exclude or maxPages instead.',
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (opts.noAi) opts.task = ''; // #23 — even the default task: no role means none
|
|
328
|
+
opts.concurrency = Math.max(1, Number(opts.concurrency) || 1);
|
|
329
|
+
opts.delay = Math.max(0, Number(opts.delay) || 0);
|
|
330
|
+
opts.maxPages = Math.max(0, Number(opts.maxPages) || 0);
|
|
331
|
+
opts.maxActions = Math.max(1, Number(opts.maxActions) || 1);
|
|
332
|
+
opts.minRelevance = Math.min(1, Math.max(0, Number(opts.minRelevance) || 0));
|
|
333
|
+
opts.maxRoutes = Math.max(0, Math.floor(Number(opts.maxRoutes) || 0));
|
|
334
|
+
opts.nearDupHamming = Math.min(64, Math.max(0, Math.floor(Number(opts.nearDupHamming) || 0)));
|
|
335
|
+
opts.mirrorHamming = Math.min(64, Math.max(0, Math.floor(Number(opts.mirrorHamming) || 0)));
|
|
336
|
+
// Size the browser-context pool to the concurrency so each worker keeps (and reuses)
|
|
337
|
+
// its own context — the site's shared CSS/JS is then cached across pages instead of
|
|
338
|
+
// re-downloaded per page. See src/lib/browser.mjs.
|
|
339
|
+
configureContextPool(opts.concurrency);
|
|
340
|
+
// Resolve the model provider once; the engine reads ctx.options.llm and stays
|
|
341
|
+
// provider-agnostic (Ollama or any OpenAI-compatible API).
|
|
342
|
+
opts.llm = resolveLlm(opts);
|
|
343
|
+
|
|
344
|
+
// Resume payload (#13), set only by resumeCrawl: { id, journals } — the run id to
|
|
345
|
+
// re-open and each scan's already-journaled pages. Internal; never a public option.
|
|
346
|
+
const resume = opts.__resume || null;
|
|
347
|
+
|
|
348
|
+
// Persistence is opt-in (see DEFAULT_OPTIONS.save): write a run to the cache only
|
|
349
|
+
// when the caller asked — explicitly via `save`, or implicitly by naming a place
|
|
350
|
+
// to put it (`cacheDir` / CRAWLDNA_CACHE_DIR). Otherwise the crawl stays in memory.
|
|
351
|
+
const willSave = opts.save === true || !!opts.cacheDir || !!process.env.CRAWLDNA_CACHE_DIR || !!resume || opts.incremental;
|
|
352
|
+
|
|
353
|
+
// Incremental journal (#13): when saving is on, every kept page is appended to
|
|
354
|
+
// <run>/<scanId>/pages.jsonl AS IT IS CAPTURED, so a crash (or Stop) at hour 4 of
|
|
355
|
+
// a 5-hour crawl loses nothing. Appends are serialised through a promise chain
|
|
356
|
+
// (concurrent workers must not interleave lines) and flushed before the final
|
|
357
|
+
// save. Zero writes when saving is off — the library contract is unchanged.
|
|
358
|
+
const journal = {
|
|
359
|
+
id: null, // set once initRun has created the run folder; null = journaling off
|
|
360
|
+
createdAt: null,
|
|
361
|
+
queue: Promise.resolve(),
|
|
362
|
+
warned: false,
|
|
363
|
+
append(scanId, record) {
|
|
364
|
+
if (!this.id) return;
|
|
365
|
+
this.queue = this.queue
|
|
366
|
+
.then(() => appendJournal(this.id, scanId, record, opts))
|
|
367
|
+
.catch((err) => {
|
|
368
|
+
if (!this.warned) {
|
|
369
|
+
this.warned = true;
|
|
370
|
+
emit({
|
|
371
|
+
type: 'warn',
|
|
372
|
+
reason: 'journal',
|
|
373
|
+
message:
|
|
374
|
+
'Failed to journal a page to disk (' + (err && err.message) + '). The crawl ' +
|
|
375
|
+
'continues in memory, but a crash before the final save would lose pages.',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const list = normalizeTargets(targets, opts.task);
|
|
383
|
+
// #23 — under noAi even per-target tasks carried by a RESUMED run (saved before
|
|
384
|
+
// this contract) are stripped: the task must have no role, on every path.
|
|
385
|
+
if (opts.noAi) for (const t of list) t.task = '';
|
|
386
|
+
const stream = createEventStream();
|
|
387
|
+
const startTime = Date.now();
|
|
388
|
+
|
|
389
|
+
// Each submitted link is an independent SCAN: its own pages, its own dedup,
|
|
390
|
+
// its own output files. A run is just the container recording which scans were
|
|
391
|
+
// crawled together (the user can later open one link or the whole run).
|
|
392
|
+
const emptyCounts = () => ({ 'docs:llms-full': 0, 'docs:sitemap': 0, agent: 0 });
|
|
393
|
+
// AI usage meter — input/output token totals so a run's API cost can be
|
|
394
|
+
// approximated after the fact (input and output are billed differently).
|
|
395
|
+
// `byKind` splits the same totals by WHICH judgment spent them (reveal / scope /
|
|
396
|
+
// links / nav-plan / …), so the eval harness (src/eval) can show WHERE the tokens
|
|
397
|
+
// go, not just the grand total. Same shape as the top-level counters, per kind.
|
|
398
|
+
// `cachedInputTokens` (#4) counts the slice of inputTokens a remote provider served
|
|
399
|
+
// from its prompt cache (~10× cheaper) — visible proof the stable prefixes pay off.
|
|
400
|
+
const emptyTokens = () => ({ calls: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, byKind: {} });
|
|
401
|
+
const scans = list.map((t, i) => ({
|
|
402
|
+
scanId: scanIdFor(t.url, i),
|
|
403
|
+
index: i,
|
|
404
|
+
url: t.url,
|
|
405
|
+
task: t.task,
|
|
406
|
+
title: hostOf(t.url) || t.url,
|
|
407
|
+
pages: [],
|
|
408
|
+
files: [],
|
|
409
|
+
documents: [], // per-page format, populated only when opts.perDocument is on (#10)
|
|
410
|
+
stats: { pages: 0, durationMs: 0, strategyCounts: emptyCounts(), tokens: emptyTokens(), deduped: { exact: 0, mirror: 0, near: 0 }, revealResidual: { pages: 0, chars: 0 }, incremental: null },
|
|
411
|
+
warnings: [],
|
|
412
|
+
_hashes: new Set(), // de-dupe pages with identical content, PER scan
|
|
413
|
+
_siblings: new Map(), // siblingKey → [{sh, url}] of KEPT pages, for the mirror tier
|
|
414
|
+
_startedAt: 0,
|
|
415
|
+
}));
|
|
416
|
+
|
|
417
|
+
// Resume (#13): replay each scan's journal BEFORE crawling. Restored pages go
|
|
418
|
+
// straight into the scan (they are part of the final output) and their hashes
|
|
419
|
+
// into the dedup set; their URLs become pre-visited so they are never re-rendered;
|
|
420
|
+
// their recorded LINKS re-seed the frontier — without them, pages reachable only
|
|
421
|
+
// through an already-kept page could never be rediscovered.
|
|
422
|
+
if (resume && resume.journals) {
|
|
423
|
+
for (const scan of scans) {
|
|
424
|
+
const records = resume.journals[scan.scanId];
|
|
425
|
+
if (!Array.isArray(records) || !records.length) continue;
|
|
426
|
+
const { visited, seeds } = restoreRecords(scan, records, opts);
|
|
427
|
+
scan._resume = { visited, seeds: [...seeds], restored: scan.pages.length };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const result = {
|
|
432
|
+
scans,
|
|
433
|
+
stats: { pages: 0, durationMs: 0, strategyCounts: emptyCounts(), tokens: emptyTokens(), deduped: { exact: 0, mirror: 0, near: 0 }, revealResidual: { pages: 0, chars: 0 } },
|
|
434
|
+
warnings: [],
|
|
435
|
+
run: null, // { id, dir, scans } once the run is saved to the cache
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
let stopped = false;
|
|
439
|
+
let resolveResult;
|
|
440
|
+
const resultPromise = new Promise((r) => {
|
|
441
|
+
resolveResult = r;
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
function emit(ev) {
|
|
445
|
+
// Stamp the active scan so consumers (UI/CLI) can route every event to the
|
|
446
|
+
// right link, without the engine/profiles having to know about scans.
|
|
447
|
+
if (ctx.currentScan && ev.scanId == null) {
|
|
448
|
+
ev.scanId = ctx.currentScan.scanId;
|
|
449
|
+
ev.scanIndex = ctx.currentScan.index;
|
|
450
|
+
}
|
|
451
|
+
if (ev.type === 'warn') {
|
|
452
|
+
const w = { url: ev.url, reason: ev.reason, message: ev.message, scanId: ev.scanId };
|
|
453
|
+
result.warnings.push(w);
|
|
454
|
+
if (ctx.currentScan) ctx.currentScan.warnings.push(w);
|
|
455
|
+
}
|
|
456
|
+
stream.push(ev);
|
|
457
|
+
if (typeof opts.onEvent === 'function') {
|
|
458
|
+
try {
|
|
459
|
+
opts.onEvent(ev);
|
|
460
|
+
} catch {
|
|
461
|
+
/* a faulty consumer must not break the crawl */
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Per-scan page cap: a scan stops at maxPages on its own, like a separate run,
|
|
467
|
+
// without aborting the others. The global stop() still halts everything.
|
|
468
|
+
const shouldStop = () =>
|
|
469
|
+
stopped || (opts.maxPages > 0 && !!ctx.currentScan && ctx.currentScan.pages.length >= opts.maxPages);
|
|
470
|
+
|
|
471
|
+
const ctx = {
|
|
472
|
+
options: opts,
|
|
473
|
+
emit,
|
|
474
|
+
shouldStop,
|
|
475
|
+
currentScan: null,
|
|
476
|
+
progress: { done: 0, total: 0 },
|
|
477
|
+
// Open a scan: it becomes the target for addPage/dedup/progress + event tags.
|
|
478
|
+
beginScan(scan) {
|
|
479
|
+
ctx.currentScan = scan;
|
|
480
|
+
scan._startedAt = Date.now();
|
|
481
|
+
ctx.progress = { done: 0, total: 0 };
|
|
482
|
+
},
|
|
483
|
+
setTotal(n) {
|
|
484
|
+
ctx.progress.total = Math.max(ctx.progress.total, Number(n) || 0);
|
|
485
|
+
},
|
|
486
|
+
// Progress measures WORK PROCESSED, not pages kept — so the bar always
|
|
487
|
+
// reaches 100% when the frontier drains (dedup/discovery/empty pages would
|
|
488
|
+
// otherwise leave it short, e.g. stuck at 162/297). Call once per unit of
|
|
489
|
+
// work attempted, whether or not it produced a kept page.
|
|
490
|
+
markProcessed() {
|
|
491
|
+
ctx.progress.done += 1;
|
|
492
|
+
emit({ type: 'progress', done: ctx.progress.done, total: ctx.progress.total, tokens: { ...result.stats.tokens } });
|
|
493
|
+
},
|
|
494
|
+
// `extra.links` (optional) = the page's discovered links, journaled with it so
|
|
495
|
+
// resume can re-seed the frontier without re-rendering already-kept pages.
|
|
496
|
+
// Returns TRUE when the page was kept, FALSE when it was dropped (duplicate,
|
|
497
|
+
// page cap) — the crawl loop uses this to stop expanding links from duplicates.
|
|
498
|
+
addPage(page, extra = {}) {
|
|
499
|
+
const scan = ctx.currentScan;
|
|
500
|
+
if (!scan) return false;
|
|
501
|
+
if (opts.maxPages > 0 && scan.pages.length >= opts.maxPages) return false;
|
|
502
|
+
// Skip pages whose content duplicates one already captured in THIS scan
|
|
503
|
+
// (the same page reached via throwaway query params like ?version=). The
|
|
504
|
+
// signature ignores link/URL targets so near-identical pages collapse too.
|
|
505
|
+
const md = page.markdown || '';
|
|
506
|
+
const sig = pageSignature(md);
|
|
507
|
+
const hash = createHash('sha1').update(sig).digest('hex');
|
|
508
|
+
if (scan._hashes.has(hash)) {
|
|
509
|
+
scan.stats.deduped.exact += 1;
|
|
510
|
+
emit({ type: 'dedup', url: page.url, kind: 'exact' });
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
scan._hashes.add(hash);
|
|
514
|
+
const sh = opts.mirrorHamming > 0 || opts.nearDupHamming > 0 ? simhash(sig) : null;
|
|
515
|
+
// MIRROR/VARIANT collapse (mirrorHamming > 0, DEFAULT ON): drop the page only when
|
|
516
|
+
// its URL is a SIBLING of a kept page's (same locale-stripped path — a mirror host,
|
|
517
|
+
// a UI-state query variant, a locale twin) AND its content SimHash is within the
|
|
518
|
+
// threshold. URL shape alone or content closeness alone never drops anything: the
|
|
519
|
+
// AND is what keeps sibling-shaped pages with real differences (?version=A vs B)
|
|
520
|
+
// and near-identical TEMPLATES at different paths (two tiny API pages) safe.
|
|
521
|
+
if (opts.mirrorHamming > 0) {
|
|
522
|
+
const kin = scan._siblings.get(siblingKey(page.url));
|
|
523
|
+
if (kin) {
|
|
524
|
+
for (const prev of kin) {
|
|
525
|
+
if (prev.url !== page.url && hamming(sh, prev.sh) <= opts.mirrorHamming) {
|
|
526
|
+
scan.stats.deduped.mirror += 1;
|
|
527
|
+
emit({ type: 'dedup', url: page.url, kind: 'mirror', of: prev.url });
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// OPT-IN cross-path near-duplicate collapse (nearDupHamming > 0): drop a page whose
|
|
534
|
+
// SimHash is within the Hamming threshold of ANY kept page, regardless of URL.
|
|
535
|
+
// Default 0 = off — content similarity alone can't tell a duplicate from a sibling
|
|
536
|
+
// (templated API pages measure ≤3 apart), so this aggressive tier stays a user choice.
|
|
537
|
+
if (opts.nearDupHamming > 0) {
|
|
538
|
+
if (!scan._simhashes) scan._simhashes = [];
|
|
539
|
+
for (const prev of scan._simhashes) {
|
|
540
|
+
if (hamming(sh, prev) <= opts.nearDupHamming) {
|
|
541
|
+
scan.stats.deduped.near += 1;
|
|
542
|
+
emit({ type: 'dedup', url: page.url, kind: 'near' });
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
scan._simhashes.push(sh);
|
|
547
|
+
}
|
|
548
|
+
if (sh && opts.mirrorHamming > 0) {
|
|
549
|
+
const key = siblingKey(page.url);
|
|
550
|
+
const kin = scan._siblings.get(key) || [];
|
|
551
|
+
kin.push({ sh, url: page.url });
|
|
552
|
+
scan._siblings.set(key, kin);
|
|
553
|
+
}
|
|
554
|
+
scan.pages.push(page);
|
|
555
|
+
const s = (page.meta && page.meta.strategy) || 'agent';
|
|
556
|
+
scan.stats.strategyCounts[s] = (scan.stats.strategyCounts[s] || 0) + 1;
|
|
557
|
+
// #21d — accumulate the reveal exit audit: pages that ended with text
|
|
558
|
+
// still hidden, and how much. The per-page number lives in page.meta.
|
|
559
|
+
const residual = (page.meta && page.meta.revealResidualChars) || 0;
|
|
560
|
+
if (residual > 0) {
|
|
561
|
+
scan.stats.revealResidual.pages += 1;
|
|
562
|
+
scan.stats.revealResidual.chars += residual;
|
|
563
|
+
}
|
|
564
|
+
// #6 — on an incremental crawl, stamp the freshness metadata a future run needs
|
|
565
|
+
// (sitemap <lastmod> + the content hash) — purely additive. Hash-net (slice 3):
|
|
566
|
+
// a page we HAD to re-crawl (no lastmod/validator) whose content hash matches the
|
|
567
|
+
// baseline was unchanged after all; count it, so the run reports what TRULY changed
|
|
568
|
+
// (measured, not guessed) — it can't save the render, only tell the truth about it.
|
|
569
|
+
if (opts.incremental) {
|
|
570
|
+
const key = normalizeUrl(page.url) || page.url;
|
|
571
|
+
page.meta || (page.meta = {});
|
|
572
|
+
if (scan._lastmodMap) {
|
|
573
|
+
const lm = scan._lastmodMap.get(key);
|
|
574
|
+
if (lm) page.meta.lastmod = lm;
|
|
575
|
+
}
|
|
576
|
+
page.meta.contentHash = hash;
|
|
577
|
+
if (scan._baselineHashes && scan._baselineHashes.get(key) === hash && scan.stats.incremental) {
|
|
578
|
+
scan.stats.incremental.unchangedByHash += 1;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Incremental persistence (#13): the kept page hits the disk NOW, verbatim,
|
|
582
|
+
// append-only — a crash from here on cannot lose it. No-op when saving is off.
|
|
583
|
+
journal.append(scan.scanId, { page, links: extra.links || [] });
|
|
584
|
+
// Note: progress is driven by markProcessed (work done), not by kept pages.
|
|
585
|
+
emit({
|
|
586
|
+
type: 'extracted',
|
|
587
|
+
url: page.url,
|
|
588
|
+
title: page.title,
|
|
589
|
+
bytes: (page.meta && page.meta.bytes) || Buffer.byteLength(md, 'utf8'),
|
|
590
|
+
preview: md.slice(0, 600),
|
|
591
|
+
});
|
|
592
|
+
return true;
|
|
593
|
+
},
|
|
594
|
+
// Passed to the docs profile so it can drive (or fall back to) the general
|
|
595
|
+
// engine — optionally seeded with a known page set — without a circular import.
|
|
596
|
+
runEngine: (target, opts) => runGeneralCrawl(target, ctx, opts),
|
|
597
|
+
tokens: result.stats.tokens, // run-level alias, surfaced on progress events
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// Sink every model call's token usage into the run total and the active scan, so
|
|
601
|
+
// the saved run records how much AI it cost (the engine/profiles stay unaware).
|
|
602
|
+
// `kind` (reveal/scope/links/nav-plan/…) is accumulated into `byKind` alongside the
|
|
603
|
+
// grand totals, so the cost can be attributed per call type without any extra plumbing.
|
|
604
|
+
opts.llm.__onUsage = ({ kind = 'other', inputTokens = 0, outputTokens = 0, cachedInputTokens = 0 } = {}) => {
|
|
605
|
+
const bump = (t) => {
|
|
606
|
+
if (!t) return;
|
|
607
|
+
t.calls += 1;
|
|
608
|
+
t.inputTokens += inputTokens;
|
|
609
|
+
t.outputTokens += outputTokens;
|
|
610
|
+
t.cachedInputTokens = (t.cachedInputTokens || 0) + cachedInputTokens;
|
|
611
|
+
if (!t.byKind) t.byKind = {};
|
|
612
|
+
const k = t.byKind[kind] || (t.byKind[kind] = { calls: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 });
|
|
613
|
+
k.calls += 1;
|
|
614
|
+
k.inputTokens += inputTokens;
|
|
615
|
+
k.outputTokens += outputTokens;
|
|
616
|
+
k.cachedInputTokens = (k.cachedInputTokens || 0) + cachedInputTokens;
|
|
617
|
+
};
|
|
618
|
+
bump(result.stats.tokens);
|
|
619
|
+
if (ctx.currentScan) bump(ctx.currentScan.stats.tokens);
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
(async () => {
|
|
623
|
+
// Hold the shared browser for this run's lifetime; the matching release in the
|
|
624
|
+
// finally below closes it only when NO other run is still using it (the UI can
|
|
625
|
+
// start a new crawl while the previous one is winding down).
|
|
626
|
+
retainBrowser();
|
|
627
|
+
try {
|
|
628
|
+
// Create the run folder UP FRONT (#13) so the incremental journal has a home
|
|
629
|
+
// and a kill -9 leaves a listed, resumable run (status 'running') instead of
|
|
630
|
+
// nothing. On resume this re-opens the existing folder, preserving createdAt.
|
|
631
|
+
if (willSave) {
|
|
632
|
+
try {
|
|
633
|
+
const init = await initRun({ id: resume ? resume.id : null, targets: list, options: opts });
|
|
634
|
+
journal.id = init.id;
|
|
635
|
+
journal.createdAt = init.createdAt;
|
|
636
|
+
} catch (err) {
|
|
637
|
+
emit({
|
|
638
|
+
type: 'warn',
|
|
639
|
+
reason: 'cache',
|
|
640
|
+
message: 'Failed to create the run folder: ' + (err && err.message) + '. Crawling in memory only.',
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Health-check the model ONCE before crawling. The judgment calls all
|
|
646
|
+
// `.catch()` and bias toward keep/follow/reveal, so a misconfigured model
|
|
647
|
+
// would silently degrade the whole crawl to heuristics (no AI reveal/scope/
|
|
648
|
+
// link-gating) and the caller would get poor output with no clue why. Warn
|
|
649
|
+
// loudly instead — the crawl still runs (heuristics keep it from losing
|
|
650
|
+
// content), but the reason is now visible.
|
|
651
|
+
if (llmDisabled(opts.llm)) {
|
|
652
|
+
// Deliberate no-AI mode: same heuristics, but CHOSEN — say what that means
|
|
653
|
+
// once (this is the run's one no-AI notice) and skip the health ping.
|
|
654
|
+
emit({
|
|
655
|
+
type: 'warn',
|
|
656
|
+
reason: 'no-ai',
|
|
657
|
+
message:
|
|
658
|
+
'AI is off for this run (no-AI mode): heuristic reveal, pages kept whole, ' +
|
|
659
|
+
'every in-scope link followed. Zero tokens — but the output is not ' +
|
|
660
|
+
'task-filtered; use include/exclude, minRelevance or maxPages to contain the crawl.',
|
|
661
|
+
});
|
|
662
|
+
} else {
|
|
663
|
+
const health = await checkModel(opts.llm);
|
|
664
|
+
if (!health.ok) {
|
|
665
|
+
emit({
|
|
666
|
+
type: 'warn',
|
|
667
|
+
reason: 'model',
|
|
668
|
+
message:
|
|
669
|
+
`Model not usable (${health.reason}). Running in DEGRADED heuristic mode — ` +
|
|
670
|
+
`AI reveal/scope/link-gating are OFF. Set a working model: a running local ` +
|
|
671
|
+
`Ollama model (e.g. model:'qwen3-coder:30b'), or provider:'openai' with baseUrl + apiKey.`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// #6 — incremental: find the prior incremental run for these targets so its
|
|
677
|
+
// still-fresh pages can be reused instead of re-crawled. Missing/failed lookup
|
|
678
|
+
// is not an error — the crawl just runs in full and establishes a baseline.
|
|
679
|
+
let baselineJournals = null;
|
|
680
|
+
if (opts.incremental && !resume) {
|
|
681
|
+
try {
|
|
682
|
+
const base = await findBaselineRun(list, opts);
|
|
683
|
+
if (base) {
|
|
684
|
+
baselineJournals = base.journals;
|
|
685
|
+
emit({ type: 'incremental', phase: 'baseline', baselineId: base.id });
|
|
686
|
+
} else {
|
|
687
|
+
emit({ type: 'incremental', phase: 'no-baseline', message: 'No prior incremental run to reuse — crawling in full to establish a baseline.' });
|
|
688
|
+
}
|
|
689
|
+
} catch (err) {
|
|
690
|
+
emit({ type: 'warn', reason: 'incremental', message: 'Incremental baseline lookup failed: ' + (err && err.message) + '. Crawling in full.' });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
for (const scan of scans) {
|
|
695
|
+
if (stopped) break;
|
|
696
|
+
ctx.beginScan(scan);
|
|
697
|
+
emit({ type: 'site', url: scan.url, task: scan.task, title: scan.title });
|
|
698
|
+
// #6 — decide which baseline pages are still fresh (sitemap lastmod unchanged),
|
|
699
|
+
// reuse those (skip render+reveal) and re-journal them so THIS run can be the
|
|
700
|
+
// next baseline; everything else falls through to a normal crawl below.
|
|
701
|
+
if (opts.incremental && !resume) {
|
|
702
|
+
let currentLastmod = new Map();
|
|
703
|
+
try {
|
|
704
|
+
currentLastmod = await sitemapLastmodMap(scan.url, { shouldStop: () => stopped });
|
|
705
|
+
} catch {
|
|
706
|
+
/* no sitemap / fetch failed → nothing reused, full crawl (safe) */
|
|
707
|
+
}
|
|
708
|
+
scan._lastmodMap = currentLastmod;
|
|
709
|
+
const records = (baselineJournals && baselineJournals[scan.scanId]) || [];
|
|
710
|
+
// hash-net (slice 3): the baseline content hashes, so a re-crawled page that
|
|
711
|
+
// turns out identical can be counted as unchanged (measured, not guessed).
|
|
712
|
+
scan._baselineHashes = new Map();
|
|
713
|
+
for (const rec of records) {
|
|
714
|
+
const h = rec.page && rec.page.meta && rec.page.meta.contentHash;
|
|
715
|
+
if (h) scan._baselineHashes.set(normalizeUrl(rec.page.url) || rec.page.url, h);
|
|
716
|
+
}
|
|
717
|
+
const { reuse, recrawl } = planIncremental(records, currentLastmod);
|
|
718
|
+
// Second freshness signal: for the still-stale, STATIC-SAFE pages that carry
|
|
719
|
+
// an HTTP validator, ask the server with a conditional GET — a 304 means it
|
|
720
|
+
// is byte-for-byte unchanged, so reuse without rendering. Dynamic/multi-state
|
|
721
|
+
// pages are never trusted to a shell 304 (planConditional gates them out).
|
|
722
|
+
let confirmed304 = [];
|
|
723
|
+
if (recrawl.length && !stopped) {
|
|
724
|
+
const { eligible } = planConditional(recrawl);
|
|
725
|
+
if (eligible.length) confirmed304 = await conditionalReuse(eligible, opts.concurrency, () => stopped);
|
|
726
|
+
}
|
|
727
|
+
const reused = reuse.concat(confirmed304);
|
|
728
|
+
if (reused.length) {
|
|
729
|
+
const { visited, seeds } = restoreRecords(scan, reused, opts);
|
|
730
|
+
scan._resume = { visited, seeds: [...seeds], restored: scan.pages.length };
|
|
731
|
+
for (const rec of reused) journal.append(scan.scanId, { page: rec.page, links: rec.links || [] });
|
|
732
|
+
}
|
|
733
|
+
scan.stats.incremental = { reused: scan.pages.length, viaLastmod: reuse.length, via304: confirmed304.length, unchangedByHash: 0, recrawled: 0, baseline: records.length, sitemapLastmods: currentLastmod.size };
|
|
734
|
+
emit({ type: 'incremental', phase: 'plan', url: scan.url, reused: scan.pages.length, viaLastmod: reuse.length, via304: confirmed304.length, baseline: records.length });
|
|
735
|
+
}
|
|
736
|
+
if (resume && scan._resume && scan._resume.restored) {
|
|
737
|
+
emit({ type: 'resume', url: scan.url, restored: scan._resume.restored });
|
|
738
|
+
}
|
|
739
|
+
const target = { url: scan.url, task: scan.task };
|
|
740
|
+
// #20 — the EXPLICIT mode picks the strategy; only 'auto' (legacy) still
|
|
741
|
+
// reads the task text. The docs profile is the completeness path: it tries
|
|
742
|
+
// llms-full.txt, then sitemap seeding, then falls back to the engine.
|
|
743
|
+
if (modeBehavior(opts.mode, scan.task).docsShortcuts) {
|
|
744
|
+
await runDocsProfile(target, ctx);
|
|
745
|
+
} else {
|
|
746
|
+
await runGeneralCrawl(target, ctx);
|
|
747
|
+
}
|
|
748
|
+
scan.stats.pages = scan.pages.length;
|
|
749
|
+
scan.stats.durationMs = Date.now() - scan._startedAt;
|
|
750
|
+
// #6 — truthful incremental report: reused (skipped) vs recrawled, and how many
|
|
751
|
+
// of the recrawled turned out unchanged anyway (hash-net) — measured, not guessed.
|
|
752
|
+
if (opts.incremental && !resume && scan.stats.incremental) {
|
|
753
|
+
const inc = scan.stats.incremental;
|
|
754
|
+
inc.recrawled = scan.pages.length - inc.reused;
|
|
755
|
+
emit({ type: 'incremental', phase: 'done', url: scan.url, reused: inc.reused, viaLastmod: inc.viaLastmod, via304: inc.via304, recrawled: inc.recrawled, unchangedByHash: inc.unchangedByHash });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
} catch (err) {
|
|
759
|
+
emit({ type: 'error', message: 'crawl failed: ' + (err && err.message ? err.message : String(err)) });
|
|
760
|
+
} finally {
|
|
761
|
+
ctx.currentScan = null; // the finalize events below are run-level, not per-scan
|
|
762
|
+
|
|
763
|
+
// Each scan files its OWN kept pages independently (its task drives the
|
|
764
|
+
// layout), so two links behave like two separate runs grouped under one.
|
|
765
|
+
for (const scan of scans) {
|
|
766
|
+
try {
|
|
767
|
+
if (!scan.pages.length) {
|
|
768
|
+
scan.files = [];
|
|
769
|
+
} else {
|
|
770
|
+
// Phase 1 output: one consolidated, VERBATIM .md per link. No splitting,
|
|
771
|
+
// filtering or reshaping happens here — that is Phase 2 ("reshape", the
|
|
772
|
+
// chat over these saved files). The crawl stays faithful by construction.
|
|
773
|
+
scan.files = assembleScan({ task: scan.task, pages: scan.pages });
|
|
774
|
+
// The faithful per-state record: for any page whose reveal captured >1
|
|
775
|
+
// state, write its whole snapshots verbatim under states/. Pure extra
|
|
776
|
+
// files (never touches the consolidated .md or the manifest); empty when
|
|
777
|
+
// no page had a multi-state reveal, so the default layout is unchanged.
|
|
778
|
+
const statesBundle = assembleStates({ pages: scan.pages });
|
|
779
|
+
if (statesBundle.files.length) scan._statesBundle = statesBundle;
|
|
780
|
+
// #10 (opt-in): ALSO package one identifiable document per page (+ index +
|
|
781
|
+
// JSONL). Pure repackaging of the SAME pages — the consolidated .md above is
|
|
782
|
+
// unchanged and no content is lost. `_docBundle` carries the files to save.
|
|
783
|
+
if (opts.perDocument) {
|
|
784
|
+
const doc = assemblePerDocument({ task: scan.task, pages: scan.pages });
|
|
785
|
+
scan.documents = doc.documents;
|
|
786
|
+
scan._docBundle = doc;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
} catch (err) {
|
|
790
|
+
scan.files = [];
|
|
791
|
+
emit({
|
|
792
|
+
type: 'warn',
|
|
793
|
+
url: scan.url,
|
|
794
|
+
reason: 'layout',
|
|
795
|
+
message: 'Failed to plan files for ' + scan.url + ': ' + (err && err.message),
|
|
796
|
+
scanId: scan.scanId,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Aggregate stats across scans for the run-level summary line.
|
|
802
|
+
result.stats.pages = scans.reduce((n, s) => n + s.pages.length, 0);
|
|
803
|
+
result.stats.durationMs = Date.now() - startTime;
|
|
804
|
+
for (const s of scans) {
|
|
805
|
+
for (const [k, v] of Object.entries(s.stats.strategyCounts)) {
|
|
806
|
+
result.stats.strategyCounts[k] = (result.stats.strategyCounts[k] || 0) + v;
|
|
807
|
+
}
|
|
808
|
+
for (const [k, v] of Object.entries(s.stats.deduped)) {
|
|
809
|
+
result.stats.deduped[k] = (result.stats.deduped[k] || 0) + v;
|
|
810
|
+
}
|
|
811
|
+
result.stats.revealResidual.pages += s.stats.revealResidual.pages;
|
|
812
|
+
result.stats.revealResidual.chars += s.stats.revealResidual.chars;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Persistence is opt-in. When the caller didn't ask to save (the library
|
|
816
|
+
// default), write nothing: result.scans[].files[].markdown is already in
|
|
817
|
+
// memory for them to put wherever they like. When they did opt in, save one
|
|
818
|
+
// folder per run (one subfolder per scan) under the cache root (cwd default).
|
|
819
|
+
if (willSave) {
|
|
820
|
+
try {
|
|
821
|
+
// Flush the journal before finalising: every append must be on disk
|
|
822
|
+
// before the manifest claims the run is complete (and before a 'done'
|
|
823
|
+
// save deletes the journal).
|
|
824
|
+
await journal.queue.catch(() => {});
|
|
825
|
+
const saved = await saveRun({
|
|
826
|
+
targets: list,
|
|
827
|
+
options: opts,
|
|
828
|
+
scans,
|
|
829
|
+
durationMs: result.stats.durationMs,
|
|
830
|
+
warnings: result.warnings,
|
|
831
|
+
tokens: result.stats.tokens,
|
|
832
|
+
id: journal.id,
|
|
833
|
+
createdAt: journal.createdAt,
|
|
834
|
+
// A voluntary Stop leaves the run resumable ('stopped', journal kept);
|
|
835
|
+
// a drained frontier is 'done' (journal superseded by the final files).
|
|
836
|
+
status: stopped ? 'stopped' : 'done',
|
|
837
|
+
});
|
|
838
|
+
result.run = { id: saved.id, dir: saved.dir, scans: saved.summary.scans };
|
|
839
|
+
emit({ type: 'saved', runId: saved.id, dir: saved.dir, scans: saved.summary.scans });
|
|
840
|
+
} catch (err) {
|
|
841
|
+
emit({ type: 'warn', reason: 'cache', message: 'Failed to save run: ' + (err && err.message) });
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
await releaseBrowser(); // closes the shared browser only when no run still holds it
|
|
846
|
+
emit({ type: 'done', stats: result.stats, run: result.run });
|
|
847
|
+
stream.close();
|
|
848
|
+
resolveResult(result);
|
|
849
|
+
}
|
|
850
|
+
})();
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
[Symbol.asyncIterator]() {
|
|
854
|
+
return stream[Symbol.asyncIterator]();
|
|
855
|
+
},
|
|
856
|
+
get result() {
|
|
857
|
+
return resultPromise;
|
|
858
|
+
},
|
|
859
|
+
stop() {
|
|
860
|
+
stopped = true;
|
|
861
|
+
// Drop the queued judgment-call backlog so Stop is near-instant instead of
|
|
862
|
+
// waiting for a slow local model to chew through it (only ≤N in-flight calls
|
|
863
|
+
// remain, each bounded by the request timeout).
|
|
864
|
+
abortPendingLlm();
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* General per-site crawl (§3): a sequential frontier driven by the browser-first
|
|
871
|
+
* engine, with dedupe + scope. Used for non-doc tasks and to drive the docs
|
|
872
|
+
* profile over a seeded page set.
|
|
873
|
+
*
|
|
874
|
+
* @param {object} [opts]
|
|
875
|
+
* @param {string[]} [opts.seeds] pages to enqueue up front (e.g. from a sitemap)
|
|
876
|
+
* @param {boolean} [opts.announce] emit a `strategy: agent` event (default true)
|
|
877
|
+
* @param {string} [opts.scopePrefix] restrict the frontier to URLs under this path
|
|
878
|
+
*/
|
|
879
|
+
async function runGeneralCrawl(target, ctx, opts = {}) {
|
|
880
|
+
const { seeds = [], announce = true, scopePrefix = null } = opts;
|
|
881
|
+
// Link-following is always AI-gated in the page engine (crawl-page.mjs), so
|
|
882
|
+
// the crawl never wanders into off-task pages regardless of the strategy.
|
|
883
|
+
const start = normalizeUrl(target.url) || target.url;
|
|
884
|
+
if (announce) ctx.emit({ type: 'strategy', url: target.url, strategy: 'agent' });
|
|
885
|
+
|
|
886
|
+
const inFrontierScope = (n) => {
|
|
887
|
+
if (!inScope(n, target.url, ctx.options)) return false;
|
|
888
|
+
if (scopePrefix) {
|
|
889
|
+
const p = pathOf(n);
|
|
890
|
+
if (!(p === scopePrefix || p.startsWith(scopePrefix + '/'))) return false;
|
|
891
|
+
}
|
|
892
|
+
return true;
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// #14 — politeness (opt-in, off by default = today's behaviour). One shared
|
|
896
|
+
// state per RUN so two scans on the same host still space out: a per-host
|
|
897
|
+
// pacer and a robots.txt cache (fetched once per origin, parsed offline).
|
|
898
|
+
const politeness = ctx._politeness || (ctx._politeness = { gate: createHostGate(), robots: new Map() });
|
|
899
|
+
const robotsFor = (u) => {
|
|
900
|
+
const origin = originOf(u);
|
|
901
|
+
if (!origin) return Promise.resolve({ rules: [], crawlDelay: null });
|
|
902
|
+
let p = politeness.robots.get(origin);
|
|
903
|
+
if (!p) {
|
|
904
|
+
p = fetchText(origin + '/robots.txt', { timeout: 15000, accept: 'text/plain, */*' })
|
|
905
|
+
.then((r) => (r.ok ? parseRobots(r.text) : { rules: [], crawlDelay: null }))
|
|
906
|
+
.catch(() => ({ rules: [], crawlDelay: null }));
|
|
907
|
+
politeness.robots.set(origin, p);
|
|
908
|
+
}
|
|
909
|
+
return p;
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
// Resume (#13): pages restored from the journal are pre-visited (never re-rendered)
|
|
913
|
+
// and their recorded links re-seed the frontier below, so the crawl picks up exactly
|
|
914
|
+
// where it left off — anything not yet kept is reached again through those links.
|
|
915
|
+
const pre = (ctx.currentScan && ctx.currentScan._resume) || null;
|
|
916
|
+
const visited = new Set(pre ? pre.visited : undefined);
|
|
917
|
+
const queued = new Set();
|
|
918
|
+
const queue = [];
|
|
919
|
+
// Discovery-only URLs are crawled to harvest their links (e.g. the site root's
|
|
920
|
+
// navigation) but are not themselves emitted as result pages.
|
|
921
|
+
const discoveryOnly = new Set();
|
|
922
|
+
|
|
923
|
+
const enqueue = (raw) => {
|
|
924
|
+
const n = normalizeUrl(raw);
|
|
925
|
+
if (!n || queued.has(n) || visited.has(n)) return;
|
|
926
|
+
if (!inFrontierScope(n)) return;
|
|
927
|
+
queued.add(n);
|
|
928
|
+
queue.push(n);
|
|
929
|
+
};
|
|
930
|
+
const enqueueDiscovery = (raw) => {
|
|
931
|
+
const n = normalizeUrl(raw);
|
|
932
|
+
if (!n || queued.has(n) || visited.has(n)) return;
|
|
933
|
+
queued.add(n);
|
|
934
|
+
queue.push(n);
|
|
935
|
+
discoveryOnly.add(n);
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
enqueue(start);
|
|
939
|
+
for (const s of seeds) enqueue(s);
|
|
940
|
+
if (pre) for (const s of pre.seeds) enqueue(s);
|
|
941
|
+
// Base the bar on the ACTUAL frontier, not on any earlier setTotal() estimate
|
|
942
|
+
// (e.g. a sitemap's raw count) that may include URLs filtered out at enqueue —
|
|
943
|
+
// an unreachable floor is what left the bar stuck below 100%.
|
|
944
|
+
ctx.progress.total = ctx.progress.done + queue.length;
|
|
945
|
+
ctx.emit({ type: 'progress', done: ctx.progress.done, total: ctx.progress.total });
|
|
946
|
+
|
|
947
|
+
// Restored pages count as production, so a fully-crawled resumed scan doesn't
|
|
948
|
+
// trigger the root-bootstrap fallback for no reason.
|
|
949
|
+
let produced = pre ? pre.restored : 0;
|
|
950
|
+
let bootstrapped = false;
|
|
951
|
+
let active = 0; // pages currently being crawled
|
|
952
|
+
const retried = new Set(); // URLs already given their one transient-failure retry
|
|
953
|
+
|
|
954
|
+
// Each page's reveal loop is self-contained, so pages can be crawled in
|
|
955
|
+
// parallel (each in its own browser context) — far faster on large docs sets.
|
|
956
|
+
const concurrency = Math.max(1, Number(ctx.options.concurrency) || 1);
|
|
957
|
+
|
|
958
|
+
async function processOne(url) {
|
|
959
|
+
// #14 — robots (opt-in): a disallowed URL is skipped LOUDLY. The warning is
|
|
960
|
+
// the contract — the user chose --respect-robots and gets told what it cost.
|
|
961
|
+
if (ctx.options.respectRobots) {
|
|
962
|
+
const rob = await robotsFor(url);
|
|
963
|
+
let path = '/';
|
|
964
|
+
try {
|
|
965
|
+
const u = new URL(url);
|
|
966
|
+
path = u.pathname + u.search;
|
|
967
|
+
} catch {
|
|
968
|
+
/* unparsable → treated as allowed */
|
|
969
|
+
}
|
|
970
|
+
if (!isAllowed(rob.rules, path)) {
|
|
971
|
+
ctx.emit({ type: 'warn', url, reason: 'robots', message: 'Skipped: robots.txt disallows this URL (--respect-robots is on).' });
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// #14 — per-host pacing (opt-in): the user's delay and robots' Crawl-delay,
|
|
976
|
+
// the larger wins. Different hosts never wait on each other.
|
|
977
|
+
let delayMs = ctx.options.delay || 0;
|
|
978
|
+
if (ctx.options.respectRobots) {
|
|
979
|
+
const rob = await robotsFor(url); // cached — the second await is free
|
|
980
|
+
if (rob.crawlDelay) delayMs = Math.max(delayMs, rob.crawlDelay * 1000);
|
|
981
|
+
}
|
|
982
|
+
if (delayMs > 0) {
|
|
983
|
+
await politeness.gate.wait(url, delayMs);
|
|
984
|
+
if (ctx.shouldStop()) return; // a polite wait must not outlive a Stop
|
|
985
|
+
}
|
|
986
|
+
if (!discoveryOnly.has(url)) ctx.emit({ type: 'page', url, status: 0 });
|
|
987
|
+
let outcome;
|
|
988
|
+
try {
|
|
989
|
+
outcome = await crawlPageWithEngine({ url, task: target.task }, ctx);
|
|
990
|
+
} catch (err) {
|
|
991
|
+
outcome = { page: null, links: [], failed: true, error: err };
|
|
992
|
+
}
|
|
993
|
+
// A transient load failure (navigation timeout, connection reset) yields neither
|
|
994
|
+
// content nor links. Losing the page silently breaks "never miss content", so
|
|
995
|
+
// give each URL exactly one retry before declaring it failed.
|
|
996
|
+
if (outcome.failed) {
|
|
997
|
+
if (!retried.has(url) && !ctx.shouldStop()) {
|
|
998
|
+
retried.add(url);
|
|
999
|
+
visited.delete(url);
|
|
1000
|
+
queued.delete(url);
|
|
1001
|
+
enqueue(url);
|
|
1002
|
+
ctx.emit({ type: 'warn', url, reason: 'retry', message: 'Page failed to load; retrying once.' });
|
|
1003
|
+
} else {
|
|
1004
|
+
ctx.emit({
|
|
1005
|
+
type: 'error',
|
|
1006
|
+
url,
|
|
1007
|
+
message: 'page failed: ' + ((outcome.error && outcome.error.message) || 'did not load (after retry)'),
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
let expand = true;
|
|
1013
|
+
if (outcome.page && !discoveryOnly.has(url)) {
|
|
1014
|
+
// The page's links travel with it into the journal (#13): resume re-seeds
|
|
1015
|
+
// the frontier from them instead of re-rendering the page to rediscover them.
|
|
1016
|
+
const kept = ctx.addPage(outcome.page, { links: outcome.links || [] });
|
|
1017
|
+
produced++;
|
|
1018
|
+
// A dropped duplicate's links replicate a page that was already expanded —
|
|
1019
|
+
// following them re-crawls the whole mirror/variant cascade (measured live:
|
|
1020
|
+
// 57% of a run). Discovery-only pages still always expand (that's their job).
|
|
1021
|
+
if (!kept) expand = false;
|
|
1022
|
+
}
|
|
1023
|
+
if (expand) for (const link of outcome.links || []) enqueue(link);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function worker() {
|
|
1027
|
+
while (!ctx.shouldStop()) {
|
|
1028
|
+
if (queue.length === 0) {
|
|
1029
|
+
if (active > 0) {
|
|
1030
|
+
// Another worker may still enqueue links; wait briefly and re-check.
|
|
1031
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
// Frontier truly drained. Recover once if the entry led nowhere (e.g. a
|
|
1035
|
+
// 404 section root or an SPA with no static links) by discovering from
|
|
1036
|
+
// the site root's navigation.
|
|
1037
|
+
if (!bootstrapped && produced === 0) {
|
|
1038
|
+
bootstrapped = true;
|
|
1039
|
+
const root = normalizeUrl((originOf(target.url) || '') + '/');
|
|
1040
|
+
if (root && root !== start) {
|
|
1041
|
+
ctx.emit({
|
|
1042
|
+
type: 'warn',
|
|
1043
|
+
url: target.url,
|
|
1044
|
+
reason: 'bootstrap-root',
|
|
1045
|
+
message: 'Entry yielded no pages; discovering from the site root and following navigation.',
|
|
1046
|
+
});
|
|
1047
|
+
enqueueDiscovery(root);
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const url = queue.shift();
|
|
1055
|
+
if (visited.has(url)) continue;
|
|
1056
|
+
visited.add(url);
|
|
1057
|
+
|
|
1058
|
+
active += 1;
|
|
1059
|
+
try {
|
|
1060
|
+
await processOne(url);
|
|
1061
|
+
} finally {
|
|
1062
|
+
active -= 1;
|
|
1063
|
+
// One unit of work done; total = processed + still-queued + in-flight.
|
|
1064
|
+
// Every queued URL is guaranteed to be processed, so done converges to
|
|
1065
|
+
// total and the bar hits 100% exactly when the frontier drains.
|
|
1066
|
+
ctx.progress.done += 1;
|
|
1067
|
+
ctx.progress.total = ctx.progress.done + queue.length + active;
|
|
1068
|
+
ctx.emit({ type: 'progress', done: ctx.progress.done, total: ctx.progress.total, tokens: { ...ctx.tokens } });
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Resume an interrupted run (#13): status 'running' after a crash/kill, or
|
|
1078
|
+
* 'stopped' after a voluntary Stop. Replays the run's incremental journal —
|
|
1079
|
+
* already-extracted pages are restored verbatim (never re-rendered), their
|
|
1080
|
+
* recorded links re-seed the frontier, and the crawl completes into the SAME
|
|
1081
|
+
* run folder. Returns the same async-iterable Run object as {@link crawlDocs}.
|
|
1082
|
+
*
|
|
1083
|
+
* `overrides` are merged over the run's saved options (model, provider,
|
|
1084
|
+
* concurrency, …). An `apiKey` is never persisted, so for `provider: 'openai'`
|
|
1085
|
+
* pass it again here or via CRAWLDNA_API_KEY / OPENAI_API_KEY.
|
|
1086
|
+
*
|
|
1087
|
+
* @param {string} runId
|
|
1088
|
+
* @param {Partial<typeof DEFAULT_OPTIONS>} [overrides]
|
|
1089
|
+
* @returns {Promise<ReturnType<typeof crawlDocs>>}
|
|
1090
|
+
*/
|
|
1091
|
+
export async function resumeCrawl(runId, overrides = {}) {
|
|
1092
|
+
const state = await loadRunForResume(runId, overrides);
|
|
1093
|
+
if (state.status === 'done') {
|
|
1094
|
+
throw new Error(`run ${runId} is already complete — nothing to resume`);
|
|
1095
|
+
}
|
|
1096
|
+
if (!state.targets.length) {
|
|
1097
|
+
throw new Error(`run ${runId} has no recorded targets (saved before resume support?) — cannot resume`);
|
|
1098
|
+
}
|
|
1099
|
+
const saved = { ...state.options };
|
|
1100
|
+
// Runtime/derived values must be recomputed, never replayed from disk.
|
|
1101
|
+
delete saved.llm;
|
|
1102
|
+
delete saved.onEvent;
|
|
1103
|
+
delete saved.__resume;
|
|
1104
|
+
return crawlDocs(state.targets, {
|
|
1105
|
+
...saved,
|
|
1106
|
+
...overrides,
|
|
1107
|
+
save: true,
|
|
1108
|
+
// Pin the cache to where the run was actually found, so the completed run
|
|
1109
|
+
// lands in the same folder even if the caller's cwd changed.
|
|
1110
|
+
cacheDir: cacheRoot(overrides),
|
|
1111
|
+
__resume: { id: state.id, journals: state.journals },
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
export default crawlDocs;
|