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/index.d.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// Type definitions for crawldna — the public API of `src/index.mjs`.
|
|
4
|
+
// Hand-written (the source is plain ESM JavaScript). Kept in sync with
|
|
5
|
+
// DEFAULT_OPTIONS and the `crawlDocs` result shape documented in README.md.
|
|
6
|
+
|
|
7
|
+
export type Provider = 'ollama' | 'openai';
|
|
8
|
+
export type BrowserMode = 'never' | 'auto' | 'always';
|
|
9
|
+
/**
|
|
10
|
+
* What to extract — an explicit switch, never inferred from the task wording.
|
|
11
|
+
* `"complete"` (default): everything reachable (completeness shortcuts tried first,
|
|
12
|
+
* pages kept whole, zero AI link-gate/scoping calls). `"targeted"`: only what the
|
|
13
|
+
* task asks (AI link gate + section scoping — requires AI, incompatible with `noAi`).
|
|
14
|
+
* `"auto"`: legacy behaviour — a multilingual regex on the task picks the docs path;
|
|
15
|
+
* never the default (#23), kept only for saved runs and callers that name it.
|
|
16
|
+
*/
|
|
17
|
+
export type CrawlMode = 'auto' | 'complete' | 'targeted';
|
|
18
|
+
|
|
19
|
+
/** One unit of work: a URL plus the natural-language task describing what to extract. */
|
|
20
|
+
export interface Target {
|
|
21
|
+
url: string;
|
|
22
|
+
/** What to extract. Falls back to `options.task` when omitted. */
|
|
23
|
+
task?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Accepted shapes for the first argument of {@link crawlDocs}. */
|
|
27
|
+
export type Targets = string | string[] | Target | Target[];
|
|
28
|
+
|
|
29
|
+
export interface CrawlOptions {
|
|
30
|
+
/** Shared/default task when a target doesn't carry its own. */
|
|
31
|
+
task?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Model id. REQUIRED — there is no default. For Ollama use a local model id
|
|
34
|
+
* (e.g. `"qwen3-coder:30b"`); for an OpenAI-compatible API use that API's id
|
|
35
|
+
* (e.g. `"gpt-4o-mini"`).
|
|
36
|
+
*/
|
|
37
|
+
model?: string;
|
|
38
|
+
/** `"ollama"` (local, default) or `"openai"` (any OpenAI-compatible API). */
|
|
39
|
+
provider?: Provider;
|
|
40
|
+
/**
|
|
41
|
+
* Optional embedding model id (#22), from the same provider as `model` (e.g.
|
|
42
|
+
* `"nomic-embed-text"` on Ollama, `"text-embedding-3-small"` on OpenAI). Makes
|
|
43
|
+
* task→link relevance SEMANTIC — multilingual and synonym-aware — feeding the
|
|
44
|
+
* best-first frontier ordering, the route budget, the opt-in `minRelevance`
|
|
45
|
+
* pruning and the reshape context retrieval. Embeddings only ORDER; they never
|
|
46
|
+
* drop links by themselves. Unset (default) = the lexical scorer; unreachable
|
|
47
|
+
* backend = one loud warning, then the lexical floor. Ignored with `noAi`
|
|
48
|
+
* (zero calls to ANY model, embeddings included).
|
|
49
|
+
*/
|
|
50
|
+
embedModel?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Crawl with ZERO model calls (no model needed): reveal runs on DOM heuristics,
|
|
53
|
+
* pages are kept whole, every in-scope link is followed. Zero tokens; the output
|
|
54
|
+
* is not task-filtered and big sites can take longer — contain with `maxPages` /
|
|
55
|
+
* `include` / `exclude`. #23 — the task speaks only to the AI, so here it has NO
|
|
56
|
+
* role: an explicit `task` (or `minRelevance`, which reads it) is refused loudly,
|
|
57
|
+
* and output files are named from the site. Incompatible with `mode: "targeted"`
|
|
58
|
+
* (task-filtering IS the model's job) — that combination is refused loudly.
|
|
59
|
+
* Default `false`.
|
|
60
|
+
*/
|
|
61
|
+
noAi?: boolean;
|
|
62
|
+
/** What to extract (see {@link CrawlMode}). Default `"complete"`. */
|
|
63
|
+
mode?: CrawlMode;
|
|
64
|
+
/** API base URL for `provider: "openai"` (e.g. `https://api.openai.com/v1`). */
|
|
65
|
+
baseUrl?: string;
|
|
66
|
+
/** API key for `provider: "openai"`. Falls back to `CRAWLDNA_API_KEY` / `OPENAI_API_KEY`. */
|
|
67
|
+
apiKey?: string;
|
|
68
|
+
/** Override the Ollama server URL (default `http://127.0.0.1:11434`). */
|
|
69
|
+
ollamaHost?: string;
|
|
70
|
+
/** Whether to render pages in a real browser. Lazy-loads Playwright. Default `"auto"`. */
|
|
71
|
+
browser?: BrowserMode;
|
|
72
|
+
/** Parallel page renders. Default `4`. */
|
|
73
|
+
concurrency?: number;
|
|
74
|
+
/** Per-scan page cap; `0` = unlimited. Default `0`. */
|
|
75
|
+
maxPages?: number;
|
|
76
|
+
/** Per-page reveal action budget. Default `40`. */
|
|
77
|
+
maxActions?: number;
|
|
78
|
+
/** Only crawl URLs matching this pattern. */
|
|
79
|
+
include?: string | RegExp;
|
|
80
|
+
/** Skip URLs matching this pattern. */
|
|
81
|
+
exclude?: string | RegExp;
|
|
82
|
+
/**
|
|
83
|
+
* Politeness, opt-in (#14): minimum milliseconds between requests to the SAME
|
|
84
|
+
* host (concurrent workers queue behind each other per host, never across
|
|
85
|
+
* hosts). `0` (default) = off.
|
|
86
|
+
*/
|
|
87
|
+
delay?: number;
|
|
88
|
+
/**
|
|
89
|
+
* Politeness, opt-in (#14): read each origin's robots.txt — disallowed URLs
|
|
90
|
+
* are SKIPPED with a `robots` warning (never silently) and `Crawl-delay` is
|
|
91
|
+
* honoured (the larger of it and `delay` wins). Default `false`: the tool is
|
|
92
|
+
* user-directed, like wget. Note the anti-bot CHALLENGE guard is separate and
|
|
93
|
+
* always on: a bot-defense interstitial is never kept as content (one retry
|
|
94
|
+
* with backoff, then a declared skip with an `anti-bot` warning).
|
|
95
|
+
*/
|
|
96
|
+
respectRobots?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Cap on the speculative JS-mined route candidates (paths dug out of script/JSON
|
|
99
|
+
* blobs, up to 800 per page) sent to the AI link gate, top-ranked by task
|
|
100
|
+
* relevance. `0` = unlimited. Default `200`. Conservative: only cuts when the
|
|
101
|
+
* relevance scores discriminate among the routes — a generic task cuts nothing —
|
|
102
|
+
* and real DOM links are never capped.
|
|
103
|
+
*/
|
|
104
|
+
maxRoutes?: number;
|
|
105
|
+
/**
|
|
106
|
+
* Focused mode (opt-in): prune links whose task-relevance score (`0..1`) falls below
|
|
107
|
+
* this threshold BEFORE the AI link gate. `0` (default) = off — relevance then only
|
|
108
|
+
* orders links best-first and never drops any. Trades some recall for speed/scope,
|
|
109
|
+
* and only applies when the task actually discriminates among a page's links.
|
|
110
|
+
*/
|
|
111
|
+
minRelevance?: number;
|
|
112
|
+
/**
|
|
113
|
+
* Persist the run to the cache. Library default `false` — the result is returned
|
|
114
|
+
* in memory only. The CLI and Web UI set this to `true`. Passing `cacheDir` also
|
|
115
|
+
* turns saving on.
|
|
116
|
+
*/
|
|
117
|
+
save?: boolean;
|
|
118
|
+
/** Where to save when saving is on. Default `<cwd>/.crawldna/runs`. */
|
|
119
|
+
cacheDir?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Also package one identifiable Markdown document per page (with metadata + a stable
|
|
122
|
+
* id), plus an `index.md` and a `documents.jsonl`, alongside the consolidated `.md`.
|
|
123
|
+
* Off by default. Pure repackaging — content stays verbatim. See {@link Scan.documents}.
|
|
124
|
+
*/
|
|
125
|
+
perDocument?: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Collapse near-duplicate pages ACROSS different paths whose 64-bit SimHash is within
|
|
128
|
+
* this Hamming distance of ANY already-kept page. `0` (default) = off. **Opt-in**
|
|
129
|
+
* because content similarity alone cannot tell a duplicate from a sibling — measured
|
|
130
|
+
* on a real run, genuinely distinct templated API pages sat at distance ≤3 — so this
|
|
131
|
+
* aggressive tier can drop real content and stays a deliberate user choice.
|
|
132
|
+
*/
|
|
133
|
+
nearDupHamming?: number;
|
|
134
|
+
/**
|
|
135
|
+
* Collapse mirror/variant re-servings of a kept page (default `8`, on). A page is
|
|
136
|
+
* dropped only when BOTH signals agree: its URL is a SIBLING of a kept page's (same
|
|
137
|
+
* path once a leading locale segment is stripped — mirror hosts like dev./v2., UI-state
|
|
138
|
+
* query variants like `?panel=settings`, locale twins `/en/x` vs `/x`) AND its content
|
|
139
|
+
* SimHash is within this Hamming distance. Sibling-shaped pages with real content
|
|
140
|
+
* differences (`?version=A` vs `?version=B`) measure far apart and are kept. Links found
|
|
141
|
+
* on a dropped duplicate are not followed, so mirror cascades stop at the first page.
|
|
142
|
+
* `0` = off.
|
|
143
|
+
*/
|
|
144
|
+
mirrorHamming?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Incremental re-crawl (opt-in). On a re-crawl of the same target, reuse pages whose
|
|
147
|
+
* sitemap `<lastmod>` — or an HTTP `304` on a static-safe page — is unchanged since the
|
|
148
|
+
* last `incremental` run, skipping render + reveal, and re-crawl only what changed.
|
|
149
|
+
* Conservative: any doubt re-crawls, so a change is never missed. Implies saving; the
|
|
150
|
+
* first run establishes the baseline. Default `false`.
|
|
151
|
+
*/
|
|
152
|
+
incremental?: boolean;
|
|
153
|
+
/** Called for every event, in addition to the async iterator. */
|
|
154
|
+
onEvent?: (event: CrawlEvent) => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type EventType =
|
|
158
|
+
| 'site'
|
|
159
|
+
| 'strategy'
|
|
160
|
+
| 'discover'
|
|
161
|
+
| 'page'
|
|
162
|
+
| 'action'
|
|
163
|
+
| 'extracted'
|
|
164
|
+
| 'dedup'
|
|
165
|
+
| 'incremental'
|
|
166
|
+
| 'resume'
|
|
167
|
+
| 'progress'
|
|
168
|
+
| 'warn'
|
|
169
|
+
| 'error'
|
|
170
|
+
| 'saved'
|
|
171
|
+
| 'done';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A streamed progress event. `type` is one of {@link EventType}; the remaining
|
|
175
|
+
* fields depend on the type (e.g. `extracted` carries `url`/`title`/`bytes`,
|
|
176
|
+
* `progress` carries `done`/`total`, `warn` carries `reason`/`message`). Every
|
|
177
|
+
* event is also stamped with the active `scanId`/`scanIndex`.
|
|
178
|
+
*/
|
|
179
|
+
export interface CrawlEvent {
|
|
180
|
+
type: EventType;
|
|
181
|
+
scanId?: string;
|
|
182
|
+
scanIndex?: number;
|
|
183
|
+
[key: string]: unknown;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PageMeta {
|
|
187
|
+
strategy: string;
|
|
188
|
+
framework?: string;
|
|
189
|
+
fetchedAt?: string;
|
|
190
|
+
bytes?: number;
|
|
191
|
+
/**
|
|
192
|
+
* The reveal exit audit (#21): characters of text still hidden in the page's main
|
|
193
|
+
* content when the reveal loop ended. `0` = the page was measurably drained.
|
|
194
|
+
* Present on browser-engine pages (`strategy: "agent"`); absent on static paths.
|
|
195
|
+
*/
|
|
196
|
+
revealResidualChars?: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One captured reveal state, whole and verbatim — the faithful per-state record.
|
|
200
|
+
* The consolidated `markdown` is compact (shared frame once); this keeps each
|
|
201
|
+
* state's full co-occurrence recoverable (e.g. a tab/view swap, a partial change). */
|
|
202
|
+
export interface RevealState {
|
|
203
|
+
/** The state's variant marker (e.g. a tab/view label), or null for the base state. */
|
|
204
|
+
label: string | null;
|
|
205
|
+
/** How the state was surfaced: `"baseline"`/`"tab:…"`/`"expander:…"`/`"dropdown:…"`/`"loadmore"`. */
|
|
206
|
+
provenance: string;
|
|
207
|
+
/** The revealing control's vertical position (#27), used to order states. */
|
|
208
|
+
order: number;
|
|
209
|
+
/** The whole state's Markdown, verbatim. */
|
|
210
|
+
markdown: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface Page {
|
|
214
|
+
url: string;
|
|
215
|
+
task: string;
|
|
216
|
+
title: string;
|
|
217
|
+
markdown: string;
|
|
218
|
+
/** The faithful per-state reveal record — present only when a page's reveal
|
|
219
|
+
* captured MORE THAN ONE state (else the single state IS `markdown`). Also
|
|
220
|
+
* written to disk under `states/<page>.md` when the run is saved. */
|
|
221
|
+
states?: RevealState[];
|
|
222
|
+
meta: PageMeta;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** One consolidated, verbatim Markdown file produced for a scan. */
|
|
226
|
+
export interface OutputFile {
|
|
227
|
+
filename: string;
|
|
228
|
+
title: string;
|
|
229
|
+
/** The file's full Markdown — available in memory even when nothing is saved. */
|
|
230
|
+
markdown: string;
|
|
231
|
+
bytes: number;
|
|
232
|
+
/** Source page URLs that contributed to this file. */
|
|
233
|
+
pages: string[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** AI token usage counters. `byKind` splits the same totals by call type
|
|
237
|
+
* (`"reveal"`, `"scope"`, `"links"`, `"nav-plan"`, `"embed"`, `"health"`, …) so cost
|
|
238
|
+
* can be attributed to the judgment that spent it. `cachedInputTokens` is the slice of
|
|
239
|
+
* `inputTokens` a remote provider served from its prompt cache (~10× cheaper);
|
|
240
|
+
* `0` for providers that don't report it (e.g. local Ollama). */
|
|
241
|
+
export interface TokenUsage {
|
|
242
|
+
calls: number;
|
|
243
|
+
inputTokens: number;
|
|
244
|
+
outputTokens: number;
|
|
245
|
+
cachedInputTokens: number;
|
|
246
|
+
byKind: Record<
|
|
247
|
+
string,
|
|
248
|
+
{ calls: number; inputTokens: number; outputTokens: number; cachedInputTokens: number }
|
|
249
|
+
>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface Stats {
|
|
253
|
+
pages: number;
|
|
254
|
+
durationMs: number;
|
|
255
|
+
strategyCounts: Record<string, number>;
|
|
256
|
+
/** AI token usage for the run/scan; input and output are billed differently. */
|
|
257
|
+
tokens: TokenUsage;
|
|
258
|
+
/** Pages dropped as duplicates, by tier: `exact` (sha1), `mirror` (URL-sibling +
|
|
259
|
+
* SimHash, default on), `near` (cross-path SimHash, opt-in). */
|
|
260
|
+
deduped?: { exact: number; mirror: number; near: number };
|
|
261
|
+
/** Reveal exit audit (#21): how many kept pages ended with text still hidden, and the
|
|
262
|
+
* total hidden characters. `{ pages: 0, chars: 0 }` = every page measurably drained. */
|
|
263
|
+
revealResidual?: { pages: number; chars: number };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface Warning {
|
|
267
|
+
url?: string;
|
|
268
|
+
reason?: string;
|
|
269
|
+
message: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One page packaged as an identifiable document (opt-in `perDocument`). The body is
|
|
273
|
+
* the page's verbatim Markdown; `id` is stable (derived from the URL). */
|
|
274
|
+
export interface Document {
|
|
275
|
+
id: string;
|
|
276
|
+
url: string;
|
|
277
|
+
title: string;
|
|
278
|
+
fetchedAt: string;
|
|
279
|
+
bytes: number;
|
|
280
|
+
/** The verbatim page Markdown (no header). */
|
|
281
|
+
markdown: string;
|
|
282
|
+
/** An H1–H3 outline of the page, for section paths / chunking. */
|
|
283
|
+
headings: Array<{ level: number; text: string }>;
|
|
284
|
+
/** The per-document filename written under `documents/` when the run is saved. */
|
|
285
|
+
file: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** One submitted link's crawl: its own pages, output files, dedup and stats. */
|
|
289
|
+
export interface Scan {
|
|
290
|
+
scanId: string;
|
|
291
|
+
index: number;
|
|
292
|
+
url: string;
|
|
293
|
+
task: string;
|
|
294
|
+
title: string;
|
|
295
|
+
pages: Page[];
|
|
296
|
+
files: OutputFile[];
|
|
297
|
+
/** Per-page documents, populated only when `perDocument` is enabled; else empty. */
|
|
298
|
+
documents: Document[];
|
|
299
|
+
stats: Stats;
|
|
300
|
+
warnings: Warning[];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Present on the result only when the run was saved (otherwise `run` is `null`). */
|
|
304
|
+
export interface SavedRun {
|
|
305
|
+
id: string;
|
|
306
|
+
dir: string;
|
|
307
|
+
scans: unknown[];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface Result {
|
|
311
|
+
scans: Scan[];
|
|
312
|
+
stats: Stats;
|
|
313
|
+
warnings: Warning[];
|
|
314
|
+
/** Where the run was saved, or `null` when saving was off (the library default). */
|
|
315
|
+
run: SavedRun | null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* A running crawl. Async-iterate it for live {@link CrawlEvent}s, await
|
|
320
|
+
* {@link Run.result} for the final {@link Result}, or call {@link Run.stop} to
|
|
321
|
+
* end early (the result still resolves with whatever was gathered).
|
|
322
|
+
*/
|
|
323
|
+
export interface Run extends AsyncIterable<CrawlEvent> {
|
|
324
|
+
result: Promise<Result>;
|
|
325
|
+
stop(): void;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The default option values; spread under any options you pass to {@link crawlDocs}. */
|
|
329
|
+
export declare const DEFAULT_OPTIONS: Required<
|
|
330
|
+
Pick<
|
|
331
|
+
CrawlOptions,
|
|
332
|
+
| 'task'
|
|
333
|
+
| 'model'
|
|
334
|
+
| 'provider'
|
|
335
|
+
| 'noAi'
|
|
336
|
+
| 'mode'
|
|
337
|
+
| 'browser'
|
|
338
|
+
| 'delay'
|
|
339
|
+
| 'respectRobots'
|
|
340
|
+
| 'concurrency'
|
|
341
|
+
| 'maxPages'
|
|
342
|
+
| 'maxActions'
|
|
343
|
+
| 'maxRoutes'
|
|
344
|
+
| 'minRelevance'
|
|
345
|
+
| 'nearDupHamming'
|
|
346
|
+
| 'mirrorHamming'
|
|
347
|
+
| 'save'
|
|
348
|
+
| 'perDocument'
|
|
349
|
+
>
|
|
350
|
+
> &
|
|
351
|
+
CrawlOptions;
|
|
352
|
+
|
|
353
|
+
/** Normalise the various accepted `targets` shapes into `{ url, task }[]`. */
|
|
354
|
+
export declare function normalizeTargets(targets: Targets, defaultTask?: string): Target[];
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Crawl one or more links, each with a natural-language task, and return a
|
|
358
|
+
* {@link Run}. The crawl renders pages in a real browser, reveals interaction-
|
|
359
|
+
* hidden content, and extracts task-relevant Markdown verbatim. Nothing is written
|
|
360
|
+
* to disk unless `save: true` or a `cacheDir` is given. When saving is on, every
|
|
361
|
+
* kept page is also journaled to disk as it is captured, so an interrupted run
|
|
362
|
+
* can be completed later with {@link resumeCrawl}.
|
|
363
|
+
*/
|
|
364
|
+
export declare function crawlDocs(targets: Targets, options?: CrawlOptions): Run;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Resume an interrupted saved run: pages already extracted are restored verbatim
|
|
368
|
+
* from the run's incremental journal (never re-rendered), their recorded links
|
|
369
|
+
* re-seed the frontier, and the crawl completes into the same run folder.
|
|
370
|
+
* `overrides` are merged over the run's saved options. An API key is never
|
|
371
|
+
* persisted — for `provider: "openai"` pass `apiKey` again (or set the env var).
|
|
372
|
+
* Rejects if the run is already complete.
|
|
373
|
+
*/
|
|
374
|
+
export declare function resumeCrawl(runId: string, overrides?: CrawlOptions): Promise<Run>;
|
|
375
|
+
|
|
376
|
+
export default crawlDocs;
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crawldna",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "General, task-driven web crawler that reveals interaction-hidden content and outputs clean Markdown.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"author": "BogdanVasaiu",
|
|
8
|
+
"homepage": "https://crawldna.com",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/BogdanVasaiu/crawlDNA.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/BogdanVasaiu/crawlDNA/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"crawler",
|
|
18
|
+
"scraper",
|
|
19
|
+
"markdown",
|
|
20
|
+
"ai",
|
|
21
|
+
"llm",
|
|
22
|
+
"playwright",
|
|
23
|
+
"documentation",
|
|
24
|
+
"extraction"
|
|
25
|
+
],
|
|
26
|
+
"main": "./src/index.mjs",
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./index.d.ts",
|
|
31
|
+
"default": "./src/index.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"crawldna": "bin/cli.mjs"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"bin",
|
|
40
|
+
"index.d.ts",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"start": "node bin/cli.mjs",
|
|
46
|
+
"serve": "node bin/cli.mjs serve",
|
|
47
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
48
|
+
"eval": "node scripts/eval.mjs",
|
|
49
|
+
"prepublishOnly": "npm test"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"fast-xml-parser": "^4.5.0",
|
|
56
|
+
"node-html-parser": "^7.0.1",
|
|
57
|
+
"ollama": "^0.5.11",
|
|
58
|
+
"turndown": "^7.2.0",
|
|
59
|
+
"turndown-plugin-gfm": "^1.0.2"
|
|
60
|
+
},
|
|
61
|
+
"optionalDependencies": {
|
|
62
|
+
"playwright": "^1.49.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// Low-level page actuation via Playwright. Elements are targeted by the
|
|
4
|
+
// `data-crawldna-id` attribute that perceive() stamped on the live DOM.
|
|
5
|
+
// The "wait until the click's effects landed" signal lives in lib/settle.mjs
|
|
6
|
+
// (#15) — shared with the initial render and the browser-escalation fetch.
|
|
7
|
+
|
|
8
|
+
import { settle } from '../lib/settle.mjs';
|
|
9
|
+
|
|
10
|
+
const SETTLE_MS = 350;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Activate a revealer by id. Guards against navigation: if the click took us to
|
|
14
|
+
* a new URL (the control was really a link), we record that URL and go back so
|
|
15
|
+
* revealing continues on the original page.
|
|
16
|
+
*
|
|
17
|
+
* @returns {Promise<{ ok: boolean, navigatedTo?: string, note?: string }>}
|
|
18
|
+
*/
|
|
19
|
+
export async function clickRevealer(page, id) {
|
|
20
|
+
const before = page.url();
|
|
21
|
+
const loc = page.locator(`[data-crawldna-id="${id}"]`).first();
|
|
22
|
+
try {
|
|
23
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 2500 }).catch(() => {});
|
|
24
|
+
await loc.click({ timeout: 4000 });
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return { ok: false, note: String(err && err.message).slice(0, 120) };
|
|
27
|
+
}
|
|
28
|
+
// Wait for the click's async content (AJAX panels + any first-touch lazy-load
|
|
29
|
+
// cascade) to finish before the caller captures. This is what reliably gets a
|
|
30
|
+
// slow first reveal — e.g. the day-2 slot grid — that a single immediate
|
|
31
|
+
// snapshot, or networkidle alone, can miss.
|
|
32
|
+
await settle(page);
|
|
33
|
+
|
|
34
|
+
const after = page.url();
|
|
35
|
+
if (after !== before) {
|
|
36
|
+
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
|
37
|
+
await page.waitForTimeout(SETTLE_MS);
|
|
38
|
+
return { ok: true, navigatedTo: after };
|
|
39
|
+
}
|
|
40
|
+
return { ok: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scroll down one viewport to trigger lazy/infinite content.
|
|
45
|
+
* @returns {Promise<boolean>} whether the document grew (more content loaded).
|
|
46
|
+
*/
|
|
47
|
+
export async function scrollStep(page) {
|
|
48
|
+
try {
|
|
49
|
+
const before = await page.evaluate(() => document.body.scrollHeight);
|
|
50
|
+
await page.evaluate(() => window.scrollBy(0, Math.round(window.innerHeight * 0.9)));
|
|
51
|
+
await page.waitForTimeout(500);
|
|
52
|
+
const after = await page.evaluate(() => document.body.scrollHeight);
|
|
53
|
+
return after > before + 4;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// #21a — consent/overlay dismissal: the DECISION, kept pure and testable.
|
|
4
|
+
//
|
|
5
|
+
// perceive() MEASURES (a visible button-like element inside a true overlay —
|
|
6
|
+
// fixed/sticky/dialog/aria-modal — with its label, geometry and the overlay's
|
|
7
|
+
// text); THIS module DECIDES which of those buttons to click. The split is the
|
|
8
|
+
// whole point: the in-page code stays a thin sensor, the policy is unit-tested
|
|
9
|
+
// offline with multilingual fixtures.
|
|
10
|
+
//
|
|
11
|
+
// Universality argument (why a lexicon is allowed here at all): we never read
|
|
12
|
+
// the SITE — we read the ARTEFACT OF THE DEFENSE, the cookie/consent banner,
|
|
13
|
+
// which is the same everywhere because the law that mandates it is. The words
|
|
14
|
+
// on its buttons come from a tiny, closed vocabulary per language ("accept
|
|
15
|
+
// all" / "reject all" / "close"), so a micro-lexicon of ~40 stems covers the
|
|
16
|
+
// world's banners the way `documenta` covers the word "documentation" — it is
|
|
17
|
+
// the deterministic-backstop rule (#4), not a per-site rule.
|
|
18
|
+
//
|
|
19
|
+
// Policy, in order:
|
|
20
|
+
// 1. Overlay ABOUT consent (its text mentions cookie/consent/GDPR/privacy):
|
|
21
|
+
// prefer a REJECT button (closes the banner just the same, and is the more
|
|
22
|
+
// respectful default), then ACCEPT, then a plain dismiss (ok/close/…),
|
|
23
|
+
// then — banners with exotic wording — the PRIMARY button by geometry
|
|
24
|
+
// (largest visible button with a short label).
|
|
25
|
+
// 2. Any OTHER overlay (newsletter modal, app interstitial): only a plain
|
|
26
|
+
// dismiss/close is ever clicked. Never accept/reject words out of context,
|
|
27
|
+
// and NEVER geometry — the biggest button of an arbitrary dialog is
|
|
28
|
+
// "Subscribe"/"Log in", exactly what must not be pressed.
|
|
29
|
+
|
|
30
|
+
/** Does the overlay's own text say it is a consent/cookie banner? These four
|
|
31
|
+
* stems are quasi-universal: "cookie" is used verbatim in virtually every
|
|
32
|
+
* language, "consent/GDPR/privacy" cover the legal phrasing. */
|
|
33
|
+
export const CONSENT_TOPIC_RE = /cookie|consent|gdpr|privacy|rgpd|dsgvo/i;
|
|
34
|
+
|
|
35
|
+
// The micro-lexicon. Stems, not full words, so inflections match; each entry
|
|
36
|
+
// reads the BANNER's button, never the site's content.
|
|
37
|
+
const REJECT_RE = new RegExp(
|
|
38
|
+
[
|
|
39
|
+
// en / it / de / fr / es / pt / nl / scandinavian / fi / pl / cs / hu / ro / ru / zh / ja / ko
|
|
40
|
+
'reject', 'decline', 'refuse', 'deny', 'necessary only', 'only necessary',
|
|
41
|
+
'rifiut', 'solo necessari',
|
|
42
|
+
'ablehn', 'nur notwendige',
|
|
43
|
+
'refuser', 'tout refuser',
|
|
44
|
+
'rechaz', 'solo necesarias',
|
|
45
|
+
'recus', 'rejeit',
|
|
46
|
+
'weiger', 'alleen noodzakelijk',
|
|
47
|
+
'avvisa', 'avslå', 'afvis', 'kun nødvendige',
|
|
48
|
+
'hylkää', 'odrzuć', 'odmítn', 'elutasít', 'resping',
|
|
49
|
+
'отклон', 'отказ',
|
|
50
|
+
'拒绝', '全部拒绝', '拒否', '거부',
|
|
51
|
+
].join('|'),
|
|
52
|
+
'i',
|
|
53
|
+
);
|
|
54
|
+
const ACCEPT_RE = new RegExp(
|
|
55
|
+
[
|
|
56
|
+
'accept', 'agree', 'allow', 'got it', 'i understand', 'understood',
|
|
57
|
+
'accett', 'consent[io]', 'ho capito',
|
|
58
|
+
'akzeptier', 'zustimm', 'einverstanden', 'verstanden',
|
|
59
|
+
'accepter', "j'accepte", 'tout accepter',
|
|
60
|
+
'acept', 'aceit', 'de acuerdo',
|
|
61
|
+
'accepteer', 'akkoord',
|
|
62
|
+
'acceptera', 'godkänn', 'godta', 'accepter alle', 'hyväksy',
|
|
63
|
+
'akceptuj', 'zgadzam', 'přijm', 'souhlas', 'elfogad', 'accept toate',
|
|
64
|
+
'принять', 'принима', 'соглас',
|
|
65
|
+
'同意', '接受', '同意する', '승인', '동의',
|
|
66
|
+
].join('|'),
|
|
67
|
+
'i',
|
|
68
|
+
);
|
|
69
|
+
const DISMISS_RE = new RegExp(
|
|
70
|
+
[
|
|
71
|
+
'^ok$', '^okay$', 'close', 'dismiss', 'continue', 'no,? thanks', 'not now', 'maybe later',
|
|
72
|
+
'chiudi', 'schließen', 'weiter', 'fermer', 'continuer', 'cerrar',
|
|
73
|
+
'continuar', 'fechar', 'sluiten', 'luk', 'stäng', 'lukk', 'sulje',
|
|
74
|
+
'zamknij', 'zavří', 'bezár', 'închide', 'закрыть', 'продолжить',
|
|
75
|
+
'关闭', '閉じる', '닫기', '^[×✕x]$',
|
|
76
|
+
].join('|'),
|
|
77
|
+
'i',
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pick which overlay buttons to dismiss, one per overlay.
|
|
82
|
+
*
|
|
83
|
+
* @param {Array<{ id:number, label:string, area:number, overlayText:string, href?:string }>} candidates
|
|
84
|
+
* measured by perceive(): every visible short-labelled button inside a true overlay.
|
|
85
|
+
* @returns {Array<{ id:number, label:string }>} the buttons to click, in order.
|
|
86
|
+
*/
|
|
87
|
+
export function pickConsent(candidates = []) {
|
|
88
|
+
// Group by overlay (candidates carry their overlay's text sample).
|
|
89
|
+
const overlays = new Map();
|
|
90
|
+
for (const c of candidates) {
|
|
91
|
+
if (!c || !c.label) continue;
|
|
92
|
+
const key = c.overlayText || '';
|
|
93
|
+
if (!overlays.has(key)) overlays.set(key, []);
|
|
94
|
+
overlays.get(key).push(c);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const picks = [];
|
|
98
|
+
for (const [overlayText, group] of overlays) {
|
|
99
|
+
const isConsent = CONSENT_TOPIC_RE.test(overlayText);
|
|
100
|
+
// A policy/settings LINK ("Cookie policy", "Learn more") is never the way to
|
|
101
|
+
// close a banner — buttons only. (An <a> used AS the button has no href.)
|
|
102
|
+
const buttons = group.filter((c) => !c.href);
|
|
103
|
+
const byLexicon = (re) => buttons.find((c) => re.test(c.label));
|
|
104
|
+
|
|
105
|
+
let pick = null;
|
|
106
|
+
if (isConsent) {
|
|
107
|
+
pick =
|
|
108
|
+
byLexicon(REJECT_RE) ||
|
|
109
|
+
byLexicon(ACCEPT_RE) ||
|
|
110
|
+
byLexicon(DISMISS_RE) ||
|
|
111
|
+
// Exotic wording: the PRIMARY button by geometry — banners paint their
|
|
112
|
+
// one-click exit as the biggest button. Short label only (a long label
|
|
113
|
+
// is body text, not a button caption).
|
|
114
|
+
[...buttons].filter((c) => c.label.length < 40).sort((a, b) => (b.area || 0) - (a.area || 0))[0] ||
|
|
115
|
+
null;
|
|
116
|
+
} else {
|
|
117
|
+
// Not (visibly) a consent banner: only ever a plain dismiss. No accept/
|
|
118
|
+
// reject out of context, no geometry — never press an arbitrary modal's
|
|
119
|
+
// primary action.
|
|
120
|
+
pick = byLexicon(DISMISS_RE) || null;
|
|
121
|
+
}
|
|
122
|
+
if (pick) picks.push({ id: pick.id, label: pick.label });
|
|
123
|
+
}
|
|
124
|
+
return picks;
|
|
125
|
+
}
|