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/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;