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
|
@@ -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
|
+
}
|