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/README.md ADDED
@@ -0,0 +1,525 @@
1
+ # crawldna
2
+
3
+ [![tests](https://github.com/BogdanVasaiu/crawlDNA/actions/workflows/test.yml/badge.svg)](https://github.com/BogdanVasaiu/crawlDNA/actions/workflows/test.yml)
4
+ [![license: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE)
5
+
6
+ A **general, task-driven web crawler**. Give it one or more links, each with a
7
+ natural-language **task** describing what to extract. It crawls each site to
8
+ fulfil its task and outputs clean **Markdown**.
9
+
10
+ Its defining capability: on each page it can **take actions to reveal content
11
+ that only appears after interaction** — clicking tabs, expanding accordions,
12
+ pressing "load more", scrolling for lazy content — i.e. content a plain fetch
13
+ never sees. After revealing everything, it extracts what's relevant to the task
14
+ and follows the other useful links it finds, like a crawler.
15
+
16
+ What it extracts stays **verbatim** — exactly what your task asked for, one clean
17
+ `.md` per link. Turning that into tables, splits or filtered subsets is a separate,
18
+ optional step — **reshape** — a chat over the saved files you can reuse any number
19
+ of times. **Crawl once, reshape many times.**
20
+
21
+ It runs three ways from a single headless core:
22
+
23
+ 1. **CLI** — point it at link(s) + task(s), get Markdown.
24
+ 2. **Importable library** — another Node project imports it and consumes results.
25
+ 3. **Web UI** *(optional)* — a control panel to set links/tasks, run, and watch
26
+ live. It's a thin frontend over the same core and ships **only with the source
27
+ repository**, never the npm package — so a `crawldna` dependency stays lean and
28
+ the CLI/library work with zero UI weight.
29
+
30
+ > 📐 **How it works:** see [`ARCHITECTURE.md`](ARCHITECTURE.md) for the full
31
+ > pipeline — the AI-driven reveal engine, the two-phase (crawl → reshape) model,
32
+ > and output layout.
33
+
34
+ ## Install
35
+
36
+ **As a CLI** — install globally so the `crawldna` command is on your PATH:
37
+
38
+ ```sh
39
+ npm install -g crawldna
40
+ crawldna https://docusaurus.io/docs --task "Extract all documentation" --model qwen3-coder:30b
41
+ ```
42
+
43
+ Or run it once, without installing anything:
44
+
45
+ ```sh
46
+ npx crawldna https://docusaurus.io/docs --task "Extract all documentation" --model qwen3-coder:30b
47
+ ```
48
+
49
+ **As a library:**
50
+
51
+ ```sh
52
+ npm install crawldna
53
+ ```
54
+
55
+ **From source** — the only install that also includes the optional Web UI:
56
+
57
+ ```sh
58
+ git clone https://github.com/BogdanVasaiu/crawlDNA
59
+ cd crawlDNA
60
+ npm install
61
+ node bin/cli.mjs https://docusaurus.io/docs --task "Extract all documentation" --model qwen3-coder:30b
62
+ ```
63
+
64
+ The npm package is just the crawler core + CLI — the Web UI is **not** included, so
65
+ it adds no dead weight to your dependency. If you want the UI, use the source install
66
+ above and run `npm run serve` (see [Web UI](#web-ui)).
67
+
68
+ > The examples use `--model qwen3-coder:30b` (Ollama). You must choose a model —
69
+ > or pass `--no-ai` for a zero-token crawl with no model at all. See
70
+ > [Requirements](#requirements).
71
+
72
+ ### Requirements
73
+
74
+ - **Node.js ≥ 20** (uses built-in `fetch`, `node:util.parseArgs`, web streams).
75
+ - **A language model** — the engine needs one for its AI judgment (reveal / scope /
76
+ link-gating / reshape). **There is no default — you must choose one.** Two ways,
77
+ pick either:
78
+ - **[Ollama](https://ollama.com)** running locally — pull a capable model and pass
79
+ it: `ollama pull qwen3-coder:30b`, then `--model qwen3-coder:30b`.
80
+ - **Any OpenAI-compatible API** (OpenAI, OpenRouter, Groq, Together, …):
81
+ `--provider openai --base-url <…/v1> --model <id> --api-key <key>` (the key can
82
+ also come from `CRAWLDNA_API_KEY` / `OPENAI_API_KEY`).
83
+
84
+ If the model isn't reachable the crawl still runs but **warns** that it has dropped
85
+ to degraded heuristic mode (no AI reveal/scope/link-gating) — so you never get poor
86
+ output without knowing why.
87
+
88
+ **Or crawl without a model at all** — `--no-ai` (the *Crawl without AI* checkbox in
89
+ the UI) makes that mode a deliberate choice: the reveal engine still clicks tabs,
90
+ accordions and "load more" (picked by DOM heuristics), but zero model calls are
91
+ made. Zero tokens, no model needed; the trade-off is that nothing is task-filtered —
92
+ pages are kept whole and **every in-scope link is followed**, so on a big site the
93
+ crawl can grow (the AI link gate is what normally keeps it small). The task speaks
94
+ only to the AI, so without AI it has **no role**: `--task` (and `--min-relevance`,
95
+ which reads it) is refused loudly, and output files are named from the site. Contain
96
+ the crawl with `--max-pages` or `--include`/`--exclude`. Reshape (Phase 2) is chat
97
+ with a model, so it still needs one — a `--no-ai` run can be reshaped later by
98
+ enabling a model then.
99
+ - **[Playwright](https://playwright.dev) Chromium** — needed for crawls that take
100
+ actions / reveal hidden content (the engine). Pure structured or static extraction
101
+ (e.g. a docs site exposing `llms-full.txt` or a sitemap) runs **without** it.
102
+ Install the browser once:
103
+
104
+ ```sh
105
+ npx playwright install chromium
106
+ ```
107
+
108
+ `playwright` is an `optionalDependency` and is lazy-loaded only when a crawl
109
+ actually needs the browser — so the reveal engine works out of the box. If you
110
+ only use the static path (a docs site's `llms-full.txt` / sitemap) and want to
111
+ skip the ~50 MB download, install without it:
112
+
113
+ ```sh
114
+ npm install crawldna --omit=optional
115
+ ```
116
+
117
+ Crawls that need the browser then print a one-line hint to run
118
+ `npm install playwright && npx playwright install chromium`.
119
+
120
+ ## CLI
121
+
122
+ ```sh
123
+ crawldna <url> [--task "..."] # crawl one site (Phase 1)
124
+ crawldna crawl <url> [--task "..."] [--model qwen3-coder:30b | --no-ai]
125
+ [--mode complete|targeted|auto]
126
+ [--browser auto|never|always] [--concurrency 4]
127
+ [--include "..."] [--exclude "..."] [--max-pages 0]
128
+ [--cache-dir <dir>]
129
+ crawldna resume <runId> # complete an interrupted run (crash/stop)
130
+ crawldna reshape <runId> --ask "..." # reshape a saved extraction (Phase 2)
131
+ crawldna runs [list|rm <id…>|clear|path] # manage cached runs
132
+ crawldna serve [--port 4000] # start the Web UI
133
+ crawldna --help
134
+ ```
135
+
136
+ **The CLI saves every run automatically** to the runs cache (`<cwd>/.crawldna/runs` —
137
+ rooted at the directory you run from, overridable with `--cache-dir` or the
138
+ `CRAWLDNA_CACHE_DIR` env var) — there is no `--out` flag. Each run is one folder: the
139
+ grouped Markdown file(s), a `manifest.json`, and a small `run.json` summary.
140
+ *(As a **library**, saving is opt-in — see [Library API](#library-api).)*
141
+
142
+ **A crash never loses extracted content.** While the crawl runs, every kept page is
143
+ also journaled to disk *as it is captured* (`<scanId>/pages.jsonl`, append-only,
144
+ verbatim). If the process dies — or you stop it with Ctrl-C — the run stays in the
145
+ cache as *resumable* (`crawldna runs` marks it), and
146
+
147
+ ```sh
148
+ crawldna resume <runId> # restores the journaled pages (not re-crawled),
149
+ # re-seeds the frontier from their recorded links,
150
+ # and completes the run into the SAME folder
151
+ ```
152
+
153
+ Flags override the run's saved options (e.g. `--concurrency`). An API key is never
154
+ written to disk, so with `--provider openai` pass `--api-key` again or set the env
155
+ var. A run that finished normally can't be resumed (there's nothing left to do).
156
+
157
+ **Per-link tasks** — either repeated pairs:
158
+
159
+ ```sh
160
+ crawldna --url https://a.dev --task "Get pricing" --url https://b.dev --task "Get API docs"
161
+ ```
162
+
163
+ …or a JSON file (`--targets targets.json`) whose contents are a `targets` array:
164
+
165
+ ```json
166
+ [
167
+ { "url": "https://a.dev/docs", "task": "Extract all documentation" },
168
+ { "url": "https://b.dev", "task": "List every product and its price" }
169
+ ]
170
+ ```
171
+
172
+ ### Managing cached runs
173
+
174
+ ```sh
175
+ crawldna runs # list saved runs (id, date, task, files)
176
+ crawldna runs rm <id> [<id>…] # delete specific run(s)
177
+ crawldna runs clear # delete every cached run
178
+ crawldna runs path # print the cache directory
179
+ ```
180
+
181
+ ## Web UI
182
+
183
+ > **Optional, and from the repo only.** The Web UI ships with the source repository,
184
+ > not the npm package. Run it from a repo clone:
185
+ >
186
+ > ```sh
187
+ > git clone https://github.com/BogdanVasaiu/crawlDNA
188
+ > cd crawlDNA && npm install
189
+ > npm run serve # or: node bin/cli.mjs serve --port 4000
190
+ > # open http://localhost:4000
191
+ > ```
192
+ >
193
+ > If you run `crawldna serve` from a bare `npm install` (no UI present), it won't
194
+ > crash — it prints how to get the UI and points you at the CLI/library, which do
195
+ > everything without it.
196
+
197
+ The UI has two steps:
198
+
199
+ 1. **Setup + history.** Add multiple links (each with its own task, or one
200
+ shared task) and set options, then **Start**. Below the form is a list of
201
+ **previous runs** (with the cache path shown) — click one to open it, or
202
+ delete runs (per-run ✕, or "Delete all").
203
+ 2. **Run / view.** For a live crawl: watch where it's looking, the extractions
204
+ **with their content rendered as you go**, and the actions the engine takes;
205
+ the progress bar reaches 100% when the frontier drains. When it finishes, the
206
+ produced files appear **as tabs** (one per file), shown as **formatted
207
+ Markdown** (headings, lists, tables, links, images), with the run's save path.
208
+ For a past run: browse its saved files the same way. **← runs** returns to
209
+ step 1.
210
+
211
+ ## Library API
212
+
213
+ The single most important contract (refdna depends on it):
214
+
215
+ ```js
216
+ import { crawlDocs } from 'crawldna';
217
+
218
+ const run = crawlDocs(targets, options);
219
+
220
+ // (a) consume live events
221
+ for await (const ev of run) {
222
+ // ev = { type, ...payload }
223
+ }
224
+
225
+ // (b) or get the final result
226
+ const result = await run.result;
227
+
228
+ // (c) control (used by the UI / long jobs)
229
+ run.stop(); // graceful; result still resolves with what was gathered
230
+ ```
231
+
232
+ `run` is **async-iterable** and exposes `run.result` (`Promise<Result>`) and
233
+ `run.stop()`.
234
+
235
+ ### Targets
236
+
237
+ ```
238
+ string // one URL, uses options.task
239
+ string[] // many URLs, all use options.task
240
+ { url, task? } // one target with its own task
241
+ Array<{ url, task? }> // many targets, each with its own task
242
+ ```
243
+
244
+ ### Options
245
+
246
+ | option | default | meaning |
247
+ |---|---|---|
248
+ | `task` | `"Extract the complete documentation."` | shared/default task |
249
+ | `model` | — (**required** unless `noAi`) | model id — Ollama (e.g. `"qwen3-coder:30b"`) or OpenAI-compatible (e.g. `"gpt-4o-mini"`) |
250
+ | `provider` | `"ollama"` | `"ollama"` (local) \| `"openai"` (any OpenAI-compatible API) |
251
+ | `embedModel` | — | **optional** embedding model from the same provider (e.g. `"nomic-embed-text"` on Ollama, `"text-embedding-3-small"` on OpenAI). Task→link relevance becomes **semantic** — multilingual, synonym-aware ("estrai i prezzi" ranks a German site's *Preise* pages first) — feeding the best-first frontier, `maxRoutes`, the opt-in `minRelevance` and reshape's context retrieval. Orders only, never drops by itself; unset = lexical scoring; unreachable = one loud warning, lexical floor. Ignored with `noAi` |
252
+ | `noAi` | `false` | crawl with **zero model calls** (no model needed): reveal runs on DOM heuristics, pages are kept whole, every in-scope link is followed. Zero tokens; output is not task-filtered and big sites can take longer — contain with `maxPages`/`include`/`exclude`. The task has **no role** here (it speaks only to the AI): an explicit `task` — or `minRelevance`, which reads it — is refused loudly, and files are named from the site. Incompatible with `mode: "targeted"` (refused loudly) |
253
+ | `mode` | `"complete"` | **what to extract, as an explicit switch** — the task wording never flips engine behaviour. `"complete"` (default): everything reachable — completeness shortcuts (`llms-full.txt`/sitemap) tried first, pages kept **whole**, and **zero AI link-gate/scoping calls** even with AI on (the default-on mirror dedup keeps follow-everything contained; AI still drives reveal + nav-plan). Works with `noAi`. `"targeted"`: only what the task asks — AI link gate + per-page section scoping, in any language (**needs AI**). `"auto"`: legacy — a multilingual regex on the task picks the docs path; never the default, kept only for old saved runs and callers that name it |
254
+ | `baseUrl` | — | API base URL for `provider: "openai"` |
255
+ | `apiKey` | — | API key for `provider: "openai"` (falls back to `CRAWLDNA_API_KEY` / `OPENAI_API_KEY`) |
256
+ | `ollamaHost` | `127.0.0.1:11434` | override the Ollama server |
257
+ | `browser` | `"auto"` | `never` \| `auto` \| `always` (lazy-loads Playwright) |
258
+ | `concurrency` | `4` | parallel page fetches |
259
+ | `maxPages` | `0` | safety cap (0 = unlimited) |
260
+ | `maxActions` | `40` | per-page reveal action cap (a ceiling — simple pages stop early) |
261
+ | `maxRoutes` | `200` | cap on speculative JS-mined route candidates sent to the AI link gate, top-ranked by task relevance (`0` = unlimited; only cuts when the scores discriminate; real DOM links are never capped) |
262
+ | `minRelevance` | `0` | focus on task: skip links scoring below this task-relevance (`0`..`1`; `0` = off, never drops). Only cuts when the task **discriminates** among a page's links — a generic task never over-prunes. Reads the task, so **refused with `noAi`** |
263
+ | `include` | — | only crawl URLs matching (string regex or `RegExp`) |
264
+ | `exclude` | — | skip URLs matching |
265
+ | `delay` | `0` | politeness (opt-in): minimum ms between requests to the **same host** — parallel workers queue behind each other per host. `0` = off |
266
+ | `respectRobots` | `false` | politeness (opt-in): read `robots.txt` — disallowed URLs are **skipped with a warning** (never silently), `Crawl-delay` honoured (the larger of it and `delay` wins). Separate from this, the **anti-bot challenge guard is always on**: a bot-defense interstitial (Cloudflare "checking your browser", CAPTCHA walls — often served with HTTP 200) is never kept as content — loud `anti-bot` warning, one retry with backoff, then a declared skip. Never bypassed |
267
+ | `save` | `false` | persist the run to the cache. **Library default: off** (result returned in memory). The CLI/UI turn it on |
268
+ | `cacheDir` | — | where to save when saving is on (default `<cwd>/.crawldna/runs`); setting it also turns saving on |
269
+ | `perDocument` | `false` | also package one identifiable `.md` per page (+ `index.md` + `documents.jsonl`) for programmatic use, alongside the consolidated `.md`. Verbatim — see [Output](#output) |
270
+ | `mirrorHamming` | `8` | collapse mirror/variant re-servings of a kept page: dropped only when the URL is a **sibling** (same locale-stripped path — mirror hosts like `dev.`/`v2.`, UI-state query variants, `/en/x` vs `/x` locale twins) **and** the content SimHash is within the distance. Sibling-shaped pages with real differences (`?version=A` vs `B`) measure far apart and are kept. Links on a dropped duplicate are not followed, so mirror cascades stop at their first page. `0` = off |
271
+ | `nearDupHamming` | `0` | collapse near-duplicate pages **across different paths** within this SimHash Hamming distance (`0` = off). **Opt-in** — content similarity alone can't tell a duplicate from a sibling (templated API pages measure ≤3 apart), so this can drop a real page |
272
+ | `incremental` | `false` | re-crawl only what changed: reuse pages whose sitemap `<lastmod>` (or an HTTP `304`) is unchanged since the last `incremental` run of the same target, skipping render + reveal. **Conservative** — any doubt re-crawls, so a change is never skipped. Implies saving; the first run establishes the baseline. See [Incremental re-crawl](#incremental-re-crawl-opt-in) |
273
+ | `onEvent` | — | `(ev) => void` callback |
274
+
275
+ ### Result
276
+
277
+ ```js
278
+ {
279
+ scans: [ { // one entry per submitted link
280
+ scanId, index, url, task, title,
281
+ pages: [ { url, task, title, markdown, meta: { strategy, framework?, fetchedAt, bytes, revealResidualChars } } ],
282
+ files: [ { filename, title, markdown, bytes, pages: [url, …] } ], // the verbatim .md, in memory
283
+ documents: [ { id, url, title, fetchedAt, bytes, markdown, headings, file } ], // only when perDocument:true
284
+ stats, warnings,
285
+ } ],
286
+ stats: {
287
+ pages, durationMs,
288
+ strategyCounts: { 'docs:llms-full', 'docs:sitemap', agent },
289
+ tokens: { calls, inputTokens, outputTokens, cachedInputTokens, byKind: { reveal, scope, links, 'nav-plan', … } }, // AI cost, split by call type
290
+ revealResidual: { pages, chars }, // reveal exit audit: kept pages that ended with text still hidden
291
+ },
292
+ warnings: [ { url?, reason, message } ],
293
+ run: { id, dir, scans } | null, // null unless the run was SAVED (see below)
294
+ }
295
+ ```
296
+
297
+ **As a library, nothing is written to disk by default.** Every extracted file's
298
+ Markdown is already here in memory under `scans[].files[].markdown` — save it
299
+ wherever you like:
300
+
301
+ ```js
302
+ import { crawlDocs } from 'crawldna';
303
+ import { writeFile } from 'node:fs/promises';
304
+
305
+ const run = crawlDocs([{ url, task }], { provider: 'openai', baseUrl, apiKey, model: 'gpt-4o-mini' });
306
+ const { scans } = await run.result; // no disk writes
307
+ for (const s of scans)
308
+ for (const f of s.files)
309
+ await writeFile(`./out/${f.filename}`, f.markdown); // you decide where
310
+ ```
311
+
312
+ To *also* have crawldna persist a run to its cache (so `reshape` can reuse it), pass
313
+ `save: true` or a `cacheDir` — then `result.run` is populated.
314
+
315
+ ## How it crawls
316
+
317
+ The crawler is **browser-first and AI-driven**, built for precision over speed,
318
+ and works the same way on any site — no per-framework special-casing.
319
+
320
+ **Per page (the engine):**
321
+
322
+ 1. **Render** the page in a real browser so dynamic / client-rendered content
323
+ (SPAs, JS widgets) actually exists.
324
+ 2. **Reveal everything that hides content.** It exhaustively exercises every
325
+ tab, accordion, segmented control, "load more"/lazy-scroll and JS widget *in
326
+ the main content* — capturing each revealed state and de-duplicating so
327
+ mutually-exclusive variants (e.g. Firebase's per-SDK code tabs) are all kept.
328
+ Interactive controls are found not just by selectors but by a
329
+ **listener-sniffer** that tags any element with a JS click handler, so
330
+ non-obvious widgets aren't missed. Site chrome (nav/header/footer) is skipped
331
+ and cookie/consent banners are dismissed once (multilingual — the banner's own
332
+ buttons are read, preferring *reject*). The loop is **closed by measurement**,
333
+ not judgment: a control with measurable hidden text behind it is clicked even
334
+ if a judge said no; a control that keeps *adding* content is re-clicked to
335
+ saturation whatever its label's language; and at exit the engine measures the
336
+ text still hidden — `meta.revealResidualChars` per page (`0` = measurably
337
+ drained), with an advisory warning when real mass remains. If that residual is
338
+ high and the action budget wasn't the limit, one **deterministic a11y-fallback
339
+ pass** re-scans with the label gate relaxed — reaching interactive elements that
340
+ carry an accessibility role or click listener but no text label (a bare
341
+ `role=tab`, a hover-only toggle) — and clicks those before giving up. No model,
342
+ no vision call; it only ever runs on that rare high-residual page.
343
+ 3. **Extract** the revealed content to clean, **verbatim** Markdown.
344
+ 4. **Decide relevance with AI** (for non-documentation tasks): keep only the
345
+ sections that belong to the task — "extract the pizza menu", "extract the
346
+ pricing" — dropping nav/marketing/footer.
347
+ 5. **Discover more pages, and let AI decide what to follow.** The engine surfaces
348
+ *every* destination on the page — in-content links, nav/footer/app-bar links,
349
+ and route tables mined from page JS/JSON — **exactly as written, making no
350
+ assumption about URL shape**. The AI then decides which are real pages worth
351
+ following for the task. This is deliberate: routing/pagination is
352
+ site-specific (a fragment route like `#/contact`, a query route like
353
+ `?view=pricing`, or some bespoke scheme), so instead of teaching the algorithm
354
+ each pattern, the AI recognises the navigation and skips mere same-page
355
+ anchors. Anything chosen is followed by loading that exact URL so the page (or
356
+ SPA view) renders before extraction; identical content is de-duplicated.
357
+
358
+ **Documentation accelerators.** When the task is documentation, two complete
359
+ sources short-circuit the work when present: **`/llms-full.txt`** (the
360
+ publisher's own full export, used verbatim) and **`/sitemap.xml`** (an
361
+ authoritative page list used to *seed* the engine). Every seeded page still goes
362
+ through the browser-first engine, so dynamic docs are fully revealed.
363
+
364
+ **Precision matters.** Completeness is preferred over speed. Whenever
365
+ completeness can't be guaranteed, a `warn` event is emitted and recorded in the
366
+ manifest. Login-gated, CAPTCHA-protected, and image/`<canvas>`-only content is
367
+ skipped with a warning — never circumvented.
368
+
369
+ > The browser is required for the engine. With `--browser never` the crawler
370
+ > degrades to static extraction (no reveal) and warns that hidden content may be
371
+ > missing.
372
+
373
+ ## Output
374
+
375
+ The **CLI and Web UI** save every run automatically — one folder per run under the
376
+ runs cache (`<cwd>/.crawldna/runs/<runId>/`). *(As a library, saving is opt-in — see
377
+ [Library API](#library-api); the layout below is what gets written when it's on.)*
378
+
379
+ - **One verbatim `.md` per link.** The crawl consolidates everything it kept for a
380
+ link into a single faithful Markdown file (named from the task), in crawl order.
381
+ When a link spans several pages, each page is introduced by a heading + a
382
+ `_Source:_` line so provenance is clear; the content itself is never rewritten.
383
+ The crawl **does not** split, filter or reshape — that is Phase 2.
384
+ - **`manifest.json`** — `runId`, `createdAt`, `targets`, `options`, the `files`
385
+ list (`{ filename, title, bytes, pages }`), every page `{ url, task, title,
386
+ strategy, file }`, plus `stats` and `warnings`. **refdna reads this manifest.**
387
+ - **`run.json`** — a small summary used to list runs quickly.
388
+
389
+ Each Markdown file starts with a short YAML front-matter block (`task`,
390
+ `generatedAt`, `sources`). Manage saved runs with `crawldna runs …` or the Web UI's
391
+ "Previous runs" list.
392
+
393
+ ### Per-document output (opt-in)
394
+
395
+ For programmatic consumers (a pipeline, an index, a RAG chunker) the single
396
+ consolidated file is awkward. Pass `perDocument: true` (CLI `--per-document`) to
397
+ **also** get one identifiable document per page, alongside the consolidated `.md`:
398
+
399
+ - `documents/<id>.md` — one file per kept page, each with a small front-matter
400
+ (`url`, `title`, `fetchedAt`) then the page's **verbatim** Markdown. The `<id>` is
401
+ stable (derived from the URL).
402
+ - `documents.jsonl` — one machine-readable record per line: `{ id, url, title,
403
+ fetchedAt, bytes, file, headings }` (an H1–H3 outline per page, for section paths).
404
+ - `index.md` — an llms.txt-style index of everything crawled.
405
+
406
+ This is **pure repackaging** — the content is identical to the consolidated file
407
+ (the union of the per-document bodies equals it, byte-for-byte per page); nothing is
408
+ filtered or transformed. In the library, `result.scans[].documents` carries the same
409
+ records in memory even when saving is off.
410
+
411
+ ### Incremental re-crawl (opt-in)
412
+
413
+ Keeping a site fresh over time shouldn't mean re-rendering thousands of unchanged
414
+ pages. Pass `incremental: true` (CLI `--incremental`) and a re-crawl of the same
415
+ target **reuses** every page whose sitemap `<lastmod>` is unchanged since the last
416
+ incremental run — skipping render + reveal for it — and re-crawls only what changed.
417
+
418
+ Two freshness signals decide "unchanged", cheapest first:
419
+
420
+ - **Sitemap `<lastmod>`** — reuse a page when its stored and current `<lastmod>` are
421
+ both present and equal (no fetch at all).
422
+ - **HTTP `304`** — for the still-uncertain pages that carry an `ETag`/`Last-Modified`,
423
+ a conditional GET (`If-None-Match`/`If-Modified-Since`) asks the server; a `304` is
424
+ the server confirming it's byte-for-byte unchanged. This is applied **only to
425
+ static-safe pages** (captured in a single state with nothing left hidden) — a page
426
+ whose content is click/JS-driven is never trusted to a shell `304`.
427
+
428
+ Everything else is **conservative by construction**: any uncertainty (no signal, a
429
+ blank/missing validator, a URL absent from the sitemap, a dynamic multi-state page)
430
+ re-crawls — so a real change is never skipped. Unchanged pages come out
431
+ **byte-identical**.
432
+
433
+ A page that no signal can clear is re-crawled — and a **content-hash net** then
434
+ reports the truth: of the pages that had to be re-crawled, how many were unchanged
435
+ anyway (hash matches the baseline) versus genuinely changed. It can't save the render,
436
+ only make the run's change report **measured, not guessed**.
437
+
438
+ - The first `--incremental` run is a normal full crawl that **establishes the
439
+ baseline** (it stamps each page's `lastmod`/`ETag`/`Last-Modified`/content hash and
440
+ keeps its journal). Subsequent `--incremental` runs of the same target reuse from it.
441
+ - Implies saving. A site with neither a `<lastmod>` sitemap nor validators simply
442
+ crawls in full (no gain, no risk) — the hash net still tells you what actually moved.
443
+
444
+ ## Reshape (Phase 2)
445
+
446
+ The crawl gives you a faithful extraction; **reshape** turns it into whatever you
447
+ need, on demand, over the **saved** files — as many times as you like, reusing the
448
+ same extraction as context (like a knowledge base). It can **filter** ("only the
449
+ available slots"), **reshape** ("as a table"), **regroup** ("by day") and **split**
450
+ into several files. It is **value-faithful**: every kept name, number, price and
451
+ time is copied exactly — it never invents or alters a value.
452
+
453
+ ```sh
454
+ crawldna reshape <runId> --ask "make a table of the prices"
455
+ crawldna reshape <runId> --ask "split the menu into one file per category" --scan 01-example-com
456
+ ```
457
+
458
+ "Value-faithful" is **enforced, not just requested**:
459
+
460
+ - **Relevant context, not blind truncation.** When the extraction exceeds the model
461
+ budget, the sections *relevant to your request* are retrieved and sent (verbatim,
462
+ in document order, omissions marked) — instead of the first N characters, which let
463
+ the model "answer" out-of-budget topics from its own memory.
464
+ - **Fidelity check on every produced file.** Each value-like atom (numbers, URLs,
465
+ inline code, quoted literals, code lines) is verified against the **full** crawled
466
+ sources; values found nowhere are flagged with a warning banner inside the file and
467
+ reported per-file — never served silently as extracted facts. Opt out with
468
+ `--no-verify` (or `verify: false`).
469
+ - **Re-emission filter.** A produced file near-identical (SimHash) to one already in
470
+ the chat is skipped with a note, so iterating doesn't litter the folder with copies.
471
+
472
+ In the Web UI, open a saved link and use the **Reshape** panel — each answer is
473
+ saved as a new file (under `<runId>/<scan>/chat/`) you can open and reuse. The
474
+ crawl's own files are never modified.
475
+
476
+ ## Measuring quality
477
+
478
+ An evaluation harness turns the crawler's promises into **numbers you can compare
479
+ before/after a change**: reveal completeness (did known interaction-hidden content
480
+ survive?), sitemap coverage + run diff, task recall/precision against a golden set
481
+ (SWDE-style), and **tokens per call type** (`reveal` / `scope` / `links` / `nav-plan`),
482
+ including the input slice served from the provider's **prompt cache** (the judgment
483
+ system prompts are byte-stable on purpose, so OpenAI/DeepSeek/vLLM-style automatic
484
+ prefix caching — and OpenRouter's explicit `cache_control` — makes repeat input ~10× cheaper).
485
+ The scoring in [`src/eval/`](src/eval/) is pure and ships with the package; the runner
486
+ that drives a real crawl is repo-only:
487
+
488
+ ```sh
489
+ npm run eval -- --model qwen3-coder:30b # scores every eval/golden/*.json
490
+ ```
491
+
492
+ Write one JSON spec per site under `eval/golden/`. See [eval/README.md](eval/README.md)
493
+ for the schema and the honest limits (absolute completeness is not provable — these are
494
+ the standard proxies).
495
+
496
+ ## Tests
497
+
498
+ The unit suite runs in seconds with **no browser, no model and no network** (the AI
499
+ judgment layer is exercised against a local OpenAI-compatible stub), using Node's
500
+ built-in runner — zero extra dependencies:
501
+
502
+ ```sh
503
+ npm test
504
+ ```
505
+
506
+ It covers extraction (chrome removal, link-density pruning and its never-lose-content
507
+ cascade), URL normalisation/scoping, task-relevance scoring, SimHash near-dup detection,
508
+ the docs-intent detector, output assembly (consolidated + per-document), the LLM
509
+ provider descriptor, the eval metrics, and the AI link/scope/reveal gates' completeness
510
+ contracts (empty verdict honoured, garbage → follow-all, no candidate lost to a batch cap).
511
+ Run it before and after any engine change; a live check on a reference site
512
+ (`npm run eval`) remains the final word for crawl behaviour.
513
+
514
+ ## License
515
+
516
+ crawlDNA © 2026 Bogdan Marian Vasaiu. Licensed under [AGPL-3.0-only](LICENSE):
517
+ free to use, self-host and modify; if you offer a modified version of crawlDNA
518
+ to others as a network service, you must release your service's source under
519
+ the same license. Internal/personal use carries no such obligation. For a
520
+ commercial license outside these terms, open an issue.
521
+
522
+ The name **crawlDNA**, its logo, and the domain **crawldna.com** are reserved
523
+ (see the Brand Reservation Notice in [LICENSE](LICENSE)) — the AGPL covers the
524
+ software, not the brand. Factual attribution ("based on crawlDNA by Bogdan
525
+ Marian Vasaiu") and links to the official repository are welcome.