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.
@@ -0,0 +1,242 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Lazy Playwright loader. Playwright is an optional dependency: nothing here
4
+ // imports it until a crawl actually needs the browser as an actuator.
5
+
6
+ let _pw = null;
7
+ let _imported = null; // null = unknown
8
+ let _browser = null;
9
+ let _error = null; // clean, user-facing reason when unavailable
10
+
11
+ async function importPlaywright() {
12
+ if (_imported !== null) return _imported;
13
+ try {
14
+ _pw = await import('playwright');
15
+ _imported = true;
16
+ } catch {
17
+ _imported = false;
18
+ _error = 'Playwright is not installed. Run: npm install playwright && npx playwright install chromium';
19
+ }
20
+ return _imported;
21
+ }
22
+
23
+ function cleanLaunchError(err) {
24
+ const msg = String((err && err.message) || err || '');
25
+ if (/Executable doesn'?t exist|playwright install|Looks like Playwright/i.test(msg)) {
26
+ return 'Chromium is not fully installed. Run: npx playwright install chromium';
27
+ }
28
+ return 'Browser launch failed: ' + msg.split('\n')[0];
29
+ }
30
+
31
+ /** A clean, user-facing reason the browser is unavailable, or null. */
32
+ export function browserError() {
33
+ return _error;
34
+ }
35
+
36
+ /** Launch (once) and return a shared headless Chromium browser. Clean errors. */
37
+ export async function getBrowser() {
38
+ if (_browser) return _browser;
39
+ if (!(await importPlaywright())) throw new Error(_error);
40
+ try {
41
+ _browser = await _pw.chromium.launch({ headless: true });
42
+ } catch (err) {
43
+ _error = cleanLaunchError(err);
44
+ throw new Error(_error);
45
+ }
46
+ return _browser;
47
+ }
48
+
49
+ /**
50
+ * True if the browser can actually launch — verified once by a real launch
51
+ * (the launched browser is cached for reuse), not just a module import. A
52
+ * missing/partial browser binary therefore degrades cleanly instead of throwing
53
+ * on every page mid-crawl. Never throws.
54
+ */
55
+ export async function isBrowserAvailable() {
56
+ if (_browser) return true; // already launched and cached
57
+ if (!(await importPlaywright())) return false;
58
+ try {
59
+ await getBrowser();
60
+ return true;
61
+ } catch {
62
+ // Do NOT cache a failure: the browser may be installed between crawls (e.g.
63
+ // a long-running `serve`). Re-probe next time rather than staying stuck.
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Runs before any page script. Monkey-patches addEventListener so that ANY
69
+ // element which registers a click-like listener — however it was built, even a
70
+ // plain <div> with a JS handler — is tagged. This is how we spot non-obvious
71
+ // interactivity universally, without per-site selectors.
72
+ const SNIFFER = `(() => {
73
+ try {
74
+ const proto = EventTarget.prototype;
75
+ const orig = proto.addEventListener;
76
+ const INTERESTING = { click: 1, mousedown: 1, pointerdown: 1, keydown: 1, change: 1 };
77
+ proto.addEventListener = function (type, listener, opts) {
78
+ try {
79
+ if (INTERESTING[type] && this && this.nodeType === 1) {
80
+ this.setAttribute('data-crawldna-listener', '1');
81
+ }
82
+ } catch (e) {}
83
+ return orig.call(this, type, listener, opts);
84
+ };
85
+ } catch (e) {}
86
+ })();`;
87
+
88
+ const CONTEXT_OPTS = {
89
+ userAgent: 'crawldna/0.1 (+https://crawldna.com)',
90
+ viewport: { width: 1280, height: 900 },
91
+ };
92
+
93
+ // --- Browser-context pool (asset-cache reuse across pages) ------------------
94
+ // A brand-new context per page throws away the browser's HTTP cache, so the site's
95
+ // shared CSS/JS/fonts are re-downloaded on EVERY page — pure waste on a docs site with
96
+ // hundreds of same-styled pages. Instead we keep a small pool of contexts and REUSE
97
+ // them: within a context the HTTP cache is shared, so a page's static assets are fetched
98
+ // once and served from cache thereafter. Parallelism is preserved — the pool holds up to
99
+ // `_maxIdle` idle contexts (set to the crawl's concurrency), so N workers each keep their
100
+ // own context alive between pages.
101
+ //
102
+ // COMPLETENESS IS NOT AT RISK (the project's first rule). Reuse only shares the ASSET
103
+ // cache; it does not change what a page renders for us, because the engine (1) opens a
104
+ // FRESH page and navigates fresh for every URL, and (2) exhaustively clicks EVERY reveal
105
+ // control regardless of any remembered client state — so accumulated cookies/localStorage
106
+ // can't hide content from extraction (a "remembered" tab is still clicked; a first-visit
107
+ // tour is chrome, not content). Contexts are recycled after `_maxUses` pages as plain
108
+ // hygiene against a pathological page corrupting one. Consent is still dismissed per page
109
+ // by the reveal engine, so a carried-over consent cookie only ever helps.
110
+ const _idleContexts = [];
111
+ let _maxIdle = 8; // set from concurrency by configureContextPool
112
+ const _maxUses = 100; // recycle a context after this many pages
113
+
114
+ /** Size the idle pool to the crawl's concurrency so each worker keeps its own context. */
115
+ export function configureContextPool(concurrency) {
116
+ _maxIdle = Math.max(1, Number(concurrency) || 1);
117
+ }
118
+
119
+ async function makeContext() {
120
+ const browser = await getBrowser();
121
+ const context = await browser.newContext(CONTEXT_OPTS);
122
+ await context.addInitScript(SNIFFER); // once per context, not per page
123
+ context.__uses = 0;
124
+ return context;
125
+ }
126
+
127
+ /** Return a context to the pool for reuse, or close it once it's served enough pages
128
+ * (or the idle pool is already full). Never throws. */
129
+ async function releaseContext(context) {
130
+ if (!context) return;
131
+ context.__uses = (context.__uses || 0) + 1;
132
+ if (context.__uses >= _maxUses || _idleContexts.length >= _maxIdle) {
133
+ await context.close().catch(() => {});
134
+ return;
135
+ }
136
+ _idleContexts.push(context);
137
+ }
138
+
139
+ /**
140
+ * A fresh page for one URL, drawn from a REUSED browser context (shared asset cache),
141
+ * plus a `release()` to return the context to the pool when the page is done.
142
+ * The sniffer is already installed on the context. Callers MUST call `release()` (not
143
+ * `context.close()`) to get the caching benefit — closing the context still works but
144
+ * forfeits reuse.
145
+ *
146
+ * @returns {Promise<{ page, context, release: () => Promise<void> }>}
147
+ */
148
+ export async function newPage() {
149
+ // Reuse an idle context if one is available; a stale/broken one is dropped and a
150
+ // fresh context made, so a bad context can never wedge the crawl.
151
+ let context = _idleContexts.pop();
152
+ let page;
153
+ if (context) {
154
+ try {
155
+ page = await context.newPage();
156
+ } catch {
157
+ await context.close().catch(() => {});
158
+ context = null;
159
+ }
160
+ }
161
+ if (!context) {
162
+ context = await makeContext();
163
+ page = await context.newPage();
164
+ }
165
+
166
+ let released = false;
167
+ const release = async () => {
168
+ if (released) return; // idempotent: safe to call from both catch and finally
169
+ released = true;
170
+ try {
171
+ await page.close();
172
+ } catch {
173
+ /* ignore */
174
+ }
175
+ await releaseContext(context);
176
+ };
177
+ return { page, context, release };
178
+ }
179
+
180
+ /** Close the shared browser, if any, and drop the pooled contexts. Safe to call
181
+ * multiple times (e.g. once per run — the browser relaunches lazily next run). */
182
+ export async function closeBrowser() {
183
+ _idleContexts.length = 0; // the contexts are closed with the browser below
184
+ if (_browser) {
185
+ try {
186
+ await _browser.close();
187
+ } catch {
188
+ /* ignore */
189
+ }
190
+ _browser = null;
191
+ }
192
+ }
193
+
194
+ // --- run-scoped ownership of the shared browser ------------------------------
195
+ // The browser is ONE process shared by every crawl in this Node process. A run
196
+ // that closed it unconditionally when it finished would pull it out from under a
197
+ // run still using it — exactly what happened when the UI stopped a crawl and
198
+ // immediately started the next one (the old run's cleanup killed the new run's
199
+ // pages). So runs RETAIN the browser for their lifetime and only the LAST release
200
+ // actually closes it.
201
+ let _retainers = 0;
202
+
203
+ /** Mark a run as using the shared browser. Pair with releaseBrowser(). */
204
+ export function retainBrowser() {
205
+ _retainers++;
206
+ }
207
+
208
+ /** Release a run's hold; closes the browser once no run holds it. */
209
+ export async function releaseBrowser() {
210
+ _retainers = Math.max(0, _retainers - 1);
211
+ if (_retainers === 0) await closeBrowser();
212
+ }
213
+
214
+ let _installTried = false;
215
+
216
+ /**
217
+ * Make sure the browser can launch, installing Chromium once if it is missing.
218
+ * Returns true when the browser is ready. Used at `serve` startup so the user
219
+ * never hits a mid-crawl "browser not installed" error.
220
+ */
221
+ export async function ensureBrowser({ log = () => {} } = {}) {
222
+ if (await isBrowserAvailable()) return true;
223
+ if (_installTried) return false;
224
+ _installTried = true;
225
+
226
+ log('Chromium not found — installing it once (this is a one-time ~150MB download)…');
227
+ const { spawn } = await import('node:child_process');
228
+ const ok = await new Promise((resolve) => {
229
+ try {
230
+ const child = spawn('npx', ['playwright', 'install', 'chromium', 'chromium-headless-shell'], {
231
+ stdio: 'inherit',
232
+ shell: true,
233
+ });
234
+ child.on('exit', (code) => resolve(code === 0));
235
+ child.on('error', () => resolve(false));
236
+ } catch {
237
+ resolve(false);
238
+ }
239
+ });
240
+ if (!ok) return false;
241
+ return isBrowserAvailable();
242
+ }
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // #14 — anti-bot / challenge-page detection. ALWAYS ON: this is a PRECISION
4
+ // guard, not a courtesy — when a site's defense serves a "checking your
5
+ // browser" interstitial or a CAPTCHA wall (often with HTTP 200), keeping it
6
+ // would put boilerplate in the output AS IF it were content, silently.
7
+ //
8
+ // Universality argument (same as the consent lexicon, #21a): we read the
9
+ // ARTEFACT OF THE DEFENSE, not the site. Challenge pages are produced by a
10
+ // handful of vendors (Cloudflare, hCaptcha, reCAPTCHA, DataDome, PerimeterX,
11
+ // AWS WAF) and look the same everywhere, so their MECHANICAL markers — widget
12
+ // script/iframe URLs, the cf-mitigated header, meta-refresh interstitials —
13
+ // are universal signals. A page that merely TALKS about captchas (docs, blog
14
+ // posts) has real text mass and no thin-page signal, so it never trips this.
15
+ //
16
+ // Policy on detection (enforced by the callers): NEVER kept as content, a loud
17
+ // `anti-bot` warning with the URL, ONE retry with backoff (honouring
18
+ // Retry-After), then a declared skip. NEVER bypassed — CAPTCHAs/challenges are
19
+ // out of scope forever (ARCHITECTURE §14): we signal, we don't break through.
20
+
21
+ // Vendor widget/iframe/script markers — mechanical evidence a challenge is
22
+ // being SERVED (not merely mentioned; these are resource URLs and internals).
23
+ const WIDGET_RE = new RegExp(
24
+ [
25
+ 'challenges\\.cloudflare\\.com',
26
+ 'cdn-cgi/challenge-platform',
27
+ '__cf_chl',
28
+ 'cf_chl_',
29
+ 'js\\.hcaptcha\\.com',
30
+ 'hcaptcha\\.com/1/api\\.js',
31
+ 'google\\.com/recaptcha/api',
32
+ 'gstatic\\.com/recaptcha',
33
+ 'recaptcha/api\\.js',
34
+ 'captcha-delivery\\.com',
35
+ 'geo\\.captcha-delivery\\.com',
36
+ 'datadome',
37
+ 'px-cdn\\.net',
38
+ 'px-cloud\\.net',
39
+ '_pxhd',
40
+ 'awswaf\\.com',
41
+ 'amazonaws\\.com/captcha',
42
+ ].join('|'),
43
+ 'i',
44
+ );
45
+
46
+ // Interstitial phrasing — meaningful only TOGETHER with a thin page AND a
47
+ // mechanical corroborator (blocked status / meta-refresh / widget), never alone.
48
+ const PHRASE_RE = new RegExp(
49
+ [
50
+ 'checking your browser',
51
+ 'verify(?:ing)? (?:that )?you are (?:a )?human',
52
+ 'just a moment',
53
+ 'attention required',
54
+ 'enable javascript and cookies to continue',
55
+ 'unusual traffic',
56
+ 'are you a robot',
57
+ 'ddos protection by',
58
+ 'please stand by, while we are checking',
59
+ ].join('|'),
60
+ 'i',
61
+ );
62
+
63
+ const META_REFRESH_RE = /<meta[^>]+http-equiv\s*=\s*["']?refresh/i;
64
+
65
+ // "Thin": challenge pages carry a sentence or two (~200–400 chars of real
66
+ // text); real content pages carry far more. The phrase/widget must ALSO match —
67
+ // thinness alone never flags anything (an empty page is just an empty page).
68
+ const THIN_CONTENT_CHARS = 800;
69
+
70
+ /**
71
+ * Is this response a bot-defense challenge rather than content?
72
+ *
73
+ * @param {object} a
74
+ * @param {number} [a.status] HTTP status (challenges use 200/403/429/503)
75
+ * @param {object} [a.headers] response headers, lowercase keys
76
+ * @param {string} [a.html] the page HTML (widget markers live in markup)
77
+ * @param {number} [a.contentLen] chars of real (non-link) text on the page —
78
+ * the same metric as extract.contentWordLen
79
+ * @returns {{ challenge: boolean, signal?: string }}
80
+ */
81
+ export function detectChallenge({ status = 0, headers = {}, html = '', contentLen = 0 } = {}) {
82
+ const hdr = (name) => String(headers[name] || headers[String(name).toLowerCase()] || '');
83
+ // Cloudflare labels challenge responses explicitly — the strongest signal.
84
+ if (/challenge/i.test(hdr('cf-mitigated'))) {
85
+ return { challenge: true, signal: 'cf-mitigated: challenge response header' };
86
+ }
87
+ const h = String(html || '');
88
+ const thin = contentLen < THIN_CONTENT_CHARS;
89
+ const blocked = status === 403 || status === 429 || status === 503;
90
+ if (thin && WIDGET_RE.test(h)) {
91
+ return { challenge: true, signal: 'captcha/challenge widget on a near-empty page' + (blocked ? ` (HTTP ${status})` : '') };
92
+ }
93
+ if (thin && PHRASE_RE.test(h) && (blocked || META_REFRESH_RE.test(h))) {
94
+ return { challenge: true, signal: 'challenge interstitial marker' + (blocked ? ` (HTTP ${status})` : ' (meta-refresh)') };
95
+ }
96
+ return { challenge: false };
97
+ }
98
+
99
+ /** The backoff before the single retry: honour Retry-After (seconds or an
100
+ * HTTP-date) when sane, else a fixed courtesy pause. Bounded. */
101
+ export function challengeBackoffMs(headers = {}) {
102
+ const raw = String(headers['retry-after'] || headers['Retry-After'] || '').trim();
103
+ if (raw) {
104
+ const secs = Number(raw);
105
+ if (Number.isFinite(secs) && secs > 0) return Math.min(15000, secs * 1000);
106
+ const at = Date.parse(raw);
107
+ if (!Number.isNaN(at)) return Math.min(15000, Math.max(0, at - Date.now()));
108
+ }
109
+ return 2500;
110
+ }
@@ -0,0 +1,167 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Faithfulness verification for reshape output (#11). The reshape step is the ONLY
4
+ // place the AI may reformat content — and therefore the only place it could ALTER or
5
+ // INVENT a value. The prompt already demands "value-faithful", but a prompt is a
6
+ // request, not a guarantee (observed live: asked for v-alert props past the context
7
+ // budget, the model fabricated a plausible props table from its own memory — e.g. a
8
+ // `'Close'` default where the crawled docs say `'$vuetify.close'` — with no warning).
9
+ //
10
+ // This module is the deterministic enforcement: extract the VALUE-LIKE atoms from a
11
+ // produced file (numbers, URLs, inline code, quoted literals, code lines — the things
12
+ // the reshape contract says must be copied exactly) and check that each one exists in
13
+ // the crawled sources. Anything that doesn't is reported so the caller can FLAG it
14
+ // instead of serving it silently. RAGAS-faithfulness, done dependency-free at home.
15
+ //
16
+ // Honest limits: matching is by normalised substring, deliberately GENEROUS — a value
17
+ // that appears anywhere in the sources passes, so common words pass trivially and a
18
+ // lucky guess can pass too. What it reliably catches is the dangerous case: specific
19
+ // values (defaults, prices, URLs, code) that exist nowhere in the extraction.
20
+
21
+ /** Lowercase + collapse whitespace: matching must not fail on wrapping/indentation. */
22
+ const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
23
+
24
+ const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/g;
25
+ const CODESPAN_RE = /`([^`\n]+)`/g;
26
+ const FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
27
+ const SINGLE_QUOTED_RE = /'([^'\n]{2,80})'/g;
28
+ const DOUBLE_QUOTED_RE = /"([^"\n]{2,80})"/g;
29
+ // currency/percent/decimal numbers, or integers of 2+ digits (a lone "3" is noise)
30
+ const NUM_RE = /[€$£]\s?\d[\d.,]*|\d[\d.,]*\s?%|\b\d+(?:[.,]\d+)+\b|\b\d{2,}\b/g;
31
+
32
+ const wordsIn = (s) => String(s).trim().split(/\s+/).filter(Boolean).length;
33
+
34
+ /**
35
+ * Extract the verifiable, value-like atoms of a Markdown document:
36
+ * - `code-line` — each substantive line of a fenced code block (invented examples
37
+ * are caught line by line);
38
+ * - `code` — inline code spans (prop names, defaults, commands);
39
+ * - `url` — absolute links;
40
+ * - `string` — short quoted literals ('$vuetify.close', "elevated");
41
+ * - `number` — prices/percentages/decimals/multi-digit integers.
42
+ * Prose is deliberately NOT extracted: the reshape contract allows rephrasing layout,
43
+ * only VALUES must survive verbatim.
44
+ *
45
+ * @returns {Array<{ value:string, kind:string }>} deduplicated by normalised value
46
+ */
47
+ export function extractAtoms(markdown) {
48
+ let text = String(markdown || '');
49
+ const atoms = [];
50
+ const push = (value, kind) => atoms.push({ value: value.trim(), kind });
51
+
52
+ // fenced code first (then removed, so its content isn't re-matched as prose)
53
+ text = text.replace(FENCE_RE, (_m, body) => {
54
+ for (const line of String(body).split('\n')) {
55
+ const t = line.trim();
56
+ if (t.length >= 10 && /[a-z0-9]/i.test(t)) push(t, 'code-line');
57
+ }
58
+ return ' ';
59
+ });
60
+ text = text.replace(CODESPAN_RE, (_m, body) => {
61
+ if (body.trim().length >= 2 && body.length <= 120) push(body, 'code');
62
+ return ' ';
63
+ });
64
+ text = text.replace(URL_RE, (m) => {
65
+ push(m.replace(/[.,;:!?]+$/, ''), 'url');
66
+ return ' ';
67
+ });
68
+ for (const re of [SINGLE_QUOTED_RE, DOUBLE_QUOTED_RE]) {
69
+ text = text.replace(re, (_m, body) => {
70
+ if (wordsIn(body) <= 4) push(body, 'string');
71
+ return ' ';
72
+ });
73
+ }
74
+ // strip residual markdown structure so list markers/tables don't fabricate numbers
75
+ text = text.replace(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]/gm, ' ').replace(/^[ \t]*\|[\s:|-]*\|?[ \t]*$/gm, ' ');
76
+ for (const m of text.match(NUM_RE) || []) push(m, 'number');
77
+
78
+ const seen = new Set();
79
+ return atoms.filter((a) => {
80
+ const k = norm(a.value);
81
+ if (!k || seen.has(k)) return false;
82
+ seen.add(k);
83
+ return true;
84
+ });
85
+ }
86
+
87
+ /** A value that IS a number, however it was extracted — a `1,299` in a code span or
88
+ * table cell deserves the same separator-insensitive matching as a bare number. */
89
+ const isNumericish = (atom, n) => atom.kind === 'number' || /^[€$£]?\s?\d[\d.,]*\s?%?$/.test(n);
90
+
91
+ /** Matching variants for an atom: normalised form; a Markdown-unescaped form (a model
92
+ * writing a table escapes pipes — `string \| number` must match the source's
93
+ * `string | number`); separator-insensitive digit forms for numbers ("1,299" must
94
+ * match "1299" and vice versa). Generous by design. */
95
+ function variantsOf(atom) {
96
+ const n = norm(atom.value);
97
+ const v = [n];
98
+ const unescaped = n.replace(/\\([\\`*_{}[\]()#+\-.!|<>~])/g, '$1');
99
+ if (unescaped !== n) v.push(unescaped);
100
+ if (isNumericish(atom, n)) {
101
+ const digits = n.replace(/[^\d]/g, '');
102
+ if (digits && digits !== n) v.push(digits);
103
+ }
104
+ return v;
105
+ }
106
+
107
+ /**
108
+ * Verify a produced document's value-like atoms against the crawled sources.
109
+ *
110
+ * @param {string} markdown the produced file's content
111
+ * @param {string[]} sources FULL source documents (not the model's context —
112
+ * a value anywhere in the extraction is faithful)
113
+ * @param {{ allow?: string }} [o] extra acceptable text: the user's own instruction
114
+ * (values THEY typed — a date, a filename — are not
115
+ * inventions even when absent from the sources)
116
+ * @returns {{ total:number, verified:number, unverified:string[], ratio:number }}
117
+ */
118
+ export function verifyValues(markdown, sources, { allow = '' } = {}) {
119
+ const atoms = extractAtoms(markdown);
120
+ if (!atoms.length) return { total: 0, verified: 0, unverified: [], ratio: 1 };
121
+
122
+ const hay = norm((sources || []).join('\n'));
123
+ const hayDigits = hay.replace(/[^\da-z]/g, ''); // separator-insensitive number lane
124
+ const allowN = norm(allow);
125
+
126
+ const unverified = [];
127
+ let verified = 0;
128
+ for (const atom of atoms) {
129
+ const numeric = isNumericish(atom, norm(atom.value));
130
+ const ok = variantsOf(atom).some(
131
+ (v) => hay.includes(v) || (allowN && allowN.includes(v)) || (numeric && hayDigits.includes(v.replace(/[^\d]/g, ''))),
132
+ );
133
+ if (ok) verified++;
134
+ else unverified.push(atom.value);
135
+ }
136
+ return { total: atoms.length, verified, unverified, ratio: verified / atoms.length };
137
+ }
138
+
139
+ /**
140
+ * The warning block prepended to a produced file that failed verification — clearly
141
+ * tool-generated (blockquote, named), so it can't be mistaken for model output and
142
+ * can be stripped mechanically (see FIDELITY_BANNER_RE).
143
+ */
144
+ export function fidelityBanner(f) {
145
+ const shown = f.unverified
146
+ .slice(0, 10)
147
+ .map((v) => '`' + String(v).replace(/`/g, '´') + '`')
148
+ .join(', ');
149
+ const more = f.unverified.length > 10 ? ` (+${f.unverified.length - 10} more)` : '';
150
+ const head =
151
+ f.total > 0 && f.verified / f.total < 0.5
152
+ ? `most of this file (${f.unverified.length} of ${f.total} checked values) could NOT be found in the crawled sources`
153
+ : `${f.unverified.length} of ${f.total} checked value(s) were not found in the crawled sources`;
154
+ return (
155
+ `> ⚠️ **Fidelity check (crawldna):** ${head} — the model may have invented them: ${shown}${more}.\n` +
156
+ `> Do not trust these values without checking the original extraction.`
157
+ );
158
+ }
159
+
160
+ /** Matches a fidelity banner at the start of a file (through its trailing blank line). */
161
+ export const FIDELITY_BANNER_RE = /^> ⚠️ \*\*Fidelity check \(crawldna\):\*\*[^\n]*\n> [^\n]*\n\n/;
162
+
163
+ /** Remove a leading fidelity banner, e.g. before re-feeding a produced file to the
164
+ * model as context (the warning is for the USER, not content to iterate on). */
165
+ export function stripFidelityBanner(text) {
166
+ return String(text || '').replace(FIDELITY_BANNER_RE, '');
167
+ }
@@ -0,0 +1,151 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Network layer: plain fetch, with optional escalation to a real browser when
4
+ // a page looks like a JS-rendered shell or the caller forces it.
5
+
6
+ import { isBrowserAvailable, newPage } from './browser.mjs';
7
+ import { settle } from './settle.mjs';
8
+
9
+ const DEFAULT_HEADERS = {
10
+ 'user-agent': 'crawldna/0.1 (+https://crawldna.com)',
11
+ accept:
12
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5',
13
+ 'accept-language': 'en-US,en;q=0.9',
14
+ };
15
+
16
+ /** Plain `fetch` returning text. Never throws — failures come back as `ok:false`. */
17
+ export async function fetchText(url, { headers, timeout = 30000, accept } = {}) {
18
+ const ctrl = new AbortController();
19
+ const timer = setTimeout(() => ctrl.abort(), timeout);
20
+ try {
21
+ const res = await fetch(url, {
22
+ redirect: 'follow',
23
+ signal: ctrl.signal,
24
+ headers: { ...DEFAULT_HEADERS, ...(accept ? { accept } : {}), ...headers },
25
+ });
26
+ const contentType = res.headers.get('content-type') || '';
27
+ // Plain object, lowercase keys — the challenge guard (#14) reads vendor
28
+ // headers like cf-mitigated / retry-after from here. (Named resHeaders: the
29
+ // `headers` PARAMETER above is the request's, and shadowing it here would
30
+ // TDZ the fetch call.)
31
+ const resHeaders = {};
32
+ for (const [k, v] of res.headers) resHeaders[k] = v;
33
+ const text = await res.text();
34
+ return { ok: res.ok, status: res.status, text, contentType, finalUrl: res.url || url, headers: resHeaders };
35
+ } catch (err) {
36
+ return { ok: false, status: 0, text: '', contentType: '', finalUrl: url, error: err, headers: {} };
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * #6 — a conditional GET for incremental re-crawl. Sends If-None-Match /
44
+ * If-Modified-Since built from a page's stored validators; a 304 is the SERVER
45
+ * itself confirming the resource is byte-for-byte unchanged. Returns the fresh
46
+ * validators too (so they can be re-stored) and never throws. With no validators
47
+ * to send there is nothing to ask, so it reports "changed" (notModified:false).
48
+ * @param {string} url
49
+ * @param {{etag?:string,lastModified?:string,timeout?:number}} v
50
+ * @returns {Promise<{status:number,notModified:boolean,etag:string,lastModified:string}>}
51
+ */
52
+ export async function conditionalGet(url, { etag, lastModified, timeout = 15000 } = {}) {
53
+ if (!etag && !lastModified) return { status: 0, notModified: false, etag: '', lastModified: '' };
54
+ const headers = {};
55
+ if (etag) headers['if-none-match'] = etag;
56
+ if (lastModified) headers['if-modified-since'] = lastModified;
57
+ const res = await fetchText(url, { headers, timeout });
58
+ return {
59
+ status: res.status,
60
+ notModified: res.status === 304,
61
+ etag: (res.headers && res.headers['etag']) || '',
62
+ lastModified: (res.headers && res.headers['last-modified']) || '',
63
+ };
64
+ }
65
+
66
+ /** Rough "did this HTML arrive with real content?" heuristic. */
67
+ export function looksRendered(html) {
68
+ if (!html) return false;
69
+ const text = html
70
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
71
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
72
+ .replace(/<[^>]+>/g, ' ')
73
+ .replace(/\s+/g, ' ')
74
+ .trim();
75
+ return text.length > 800;
76
+ }
77
+
78
+ /**
79
+ * Load a page's HTML, escalating to the browser when needed.
80
+ *
81
+ * `browserMode`:
82
+ * - 'never' : plain fetch only.
83
+ * - 'auto' : fetch first; render with the browser only if the page looks
84
+ * like an empty SPA shell (and Playwright is available).
85
+ * - 'always' : render with the browser (fall back to fetch if unavailable).
86
+ *
87
+ * Returns `{ html, finalUrl, status, rendered }`.
88
+ */
89
+ export async function loadHtml(url, { browserMode = 'auto', ctx } = {}) {
90
+ let staticRes = null;
91
+
92
+ if (browserMode !== 'always') {
93
+ staticRes = await fetchText(url);
94
+ if (browserMode === 'never') {
95
+ return { html: staticRes.text, finalUrl: staticRes.finalUrl, status: staticRes.status, rendered: false, headers: staticRes.headers };
96
+ }
97
+ if (staticRes.ok && looksRendered(staticRes.text)) {
98
+ return { html: staticRes.text, finalUrl: staticRes.finalUrl, status: staticRes.status, rendered: false, headers: staticRes.headers };
99
+ }
100
+ }
101
+
102
+ // We want (or need) the browser.
103
+ if (await isBrowserAvailable()) {
104
+ let release;
105
+ try {
106
+ const p = await newPage();
107
+ release = p.release;
108
+ const { page } = p;
109
+ let status = 200;
110
+ let headers = {};
111
+ try {
112
+ // domcontentloaded + response-quiet (#15), NOT waitUntil:'networkidle':
113
+ // a site holding a websocket/long-poll open never reaches idle, which
114
+ // made this goto burn its FULL 45s timeout before falling through.
115
+ const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
116
+ status = resp ? resp.status() : 200;
117
+ if (resp) headers = resp.headers();
118
+ await settle(page, { maxMs: 8000 });
119
+ } catch {
120
+ // fall back to whatever rendered before the timeout
121
+ }
122
+ const html = await page.content();
123
+ const finalUrl = page.url();
124
+ return { html, finalUrl, status, rendered: true, headers };
125
+ } catch (err) {
126
+ if (staticRes) {
127
+ return { html: staticRes.text, finalUrl: staticRes.finalUrl, status: staticRes.status, rendered: false, error: err, headers: staticRes.headers };
128
+ }
129
+ return { html: '', finalUrl: url, status: 0, rendered: false, error: err, headers: {} };
130
+ } finally {
131
+ if (release) await release(); // return the context to the pool (reuse its asset cache)
132
+ }
133
+ }
134
+
135
+ // Browser wanted but unavailable.
136
+ if (browserMode === 'always' && ctx?.emit) {
137
+ ctx.emit({
138
+ type: 'warn',
139
+ url,
140
+ reason: 'browser-missing',
141
+ message:
142
+ 'Browser mode "always" was requested but Playwright is not installed. ' +
143
+ 'Run: npm install playwright && npx playwright install chromium. Using a plain fetch instead.',
144
+ });
145
+ }
146
+ if (staticRes) {
147
+ return { html: staticRes.text, finalUrl: staticRes.finalUrl, status: staticRes.status, rendered: false, headers: staticRes.headers };
148
+ }
149
+ const res = await fetchText(url);
150
+ return { html: res.text, finalUrl: res.finalUrl, status: res.status, rendered: false, headers: res.headers };
151
+ }