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/README.md
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# crawldna
|
|
2
|
+
|
|
3
|
+
[](https://github.com/BogdanVasaiu/crawlDNA/actions/workflows/test.yml)
|
|
4
|
+
[](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.
|