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,511 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// The general per-page engine (§3): browser-first render -> exhaustive reveal of
|
|
4
|
+
// hidden/dynamic content -> task-scoped verbatim extraction -> link discovery.
|
|
5
|
+
// Universal: no per-site logic. Whether the AI gates links / scopes sections is
|
|
6
|
+
// decided by the EXPLICIT `mode` option (#20, see lib/task.mjs modeBehavior):
|
|
7
|
+
// 'complete' keeps pages whole and follows all in-scope links (zero gate/scope
|
|
8
|
+
// calls); 'targeted' judges both; 'auto' (legacy) derives it from the task text.
|
|
9
|
+
|
|
10
|
+
import { isBrowserAvailable, newPage, browserError } from '../lib/browser.mjs';
|
|
11
|
+
import { loadHtml } from '../lib/fetcher.mjs';
|
|
12
|
+
import { extractMarkdown, splitBlocks, contentWordLen } from '../extract.mjs';
|
|
13
|
+
import { revealAll } from './reveal.mjs';
|
|
14
|
+
import { aiScopeContent, aiSelectLinks } from './decide.mjs';
|
|
15
|
+
import { modeBehavior } from '../lib/task.mjs';
|
|
16
|
+
import { normalizeUrl, inScope, resolveUrl } from '../lib/url.mjs';
|
|
17
|
+
import { taskTerms, scoreLink } from '../lib/relevance.mjs';
|
|
18
|
+
import { createScorer } from '../lib/semantic.mjs';
|
|
19
|
+
import { detectChallenge, challengeBackoffMs } from '../lib/challenge.mjs';
|
|
20
|
+
import { settle } from '../lib/settle.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* #22 — the per-scan relevance scorer: semantic (embeddings, multilingual) when
|
|
24
|
+
* the user configured an `embedModel`, the lexical floor otherwise. One instance
|
|
25
|
+
* per scan (its vector cache and one-time failure warning live there), shared by
|
|
26
|
+
* the link gate ordering, the minRelevance pruning and the route budget.
|
|
27
|
+
*/
|
|
28
|
+
function scorerFor(ctx, task) {
|
|
29
|
+
const host = ctx.currentScan || ctx;
|
|
30
|
+
if (!host._scorer) {
|
|
31
|
+
host._scorer = createScorer({
|
|
32
|
+
llm: ctx.options.llm,
|
|
33
|
+
task,
|
|
34
|
+
onWarn: (message) => ctx.emit({ type: 'warn', reason: 'embed', message }),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return host._scorer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const now = () => new Date().toISOString();
|
|
41
|
+
const bytesOf = (s) => Buffer.byteLength(s || '', 'utf8');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decide which candidate links to follow, using AI relevance gating with a
|
|
45
|
+
* per-scan cache (a link is judged once) and a completeness bias: on any model
|
|
46
|
+
* failure we follow everything rather than risk dropping a page. The cache lives
|
|
47
|
+
* on the current scan, not the run, so a link rejected for one link's task is
|
|
48
|
+
* not wrongly skipped for another link with a different task.
|
|
49
|
+
* (Exported for the test suite; the crawl only reaches it via crawlPageWithEngine.)
|
|
50
|
+
*/
|
|
51
|
+
export async function decideFollow(ctx, task, candidateObjs) {
|
|
52
|
+
const cacheHost = ctx.currentScan || ctx;
|
|
53
|
+
if (!cacheHost._followCache) cacheHost._followCache = new Map();
|
|
54
|
+
const cache = cacheHost._followCache;
|
|
55
|
+
// Task topic terms, computed once per scan (the task is fixed for a scan).
|
|
56
|
+
if (!cacheHost._taskTerms) cacheHost._taskTerms = taskTerms(task);
|
|
57
|
+
const terms = cacheHost._taskTerms;
|
|
58
|
+
|
|
59
|
+
// Universal, task-driven relevance score per candidate (URL + label). Used to crawl
|
|
60
|
+
// the most on-task links FIRST, and — only when asked — to prune clearly off-task ones
|
|
61
|
+
// before the model. No per-site/URL-shape rule is involved; the task is the query.
|
|
62
|
+
// #22: semantic (embeddings) when configured — an Italian task ranks German links —
|
|
63
|
+
// lexical floor otherwise; either way the same [0,1] scores feed everything below.
|
|
64
|
+
const scoreOf = await scorerFor(ctx, task).scoreAll(candidateObjs);
|
|
65
|
+
|
|
66
|
+
// #20 — in 'complete' mode there IS no link gate: the user asked for everything,
|
|
67
|
+
// so keep/drop has no meaning and every batch call would be a token spent to hear
|
|
68
|
+
// "follow it". The candidates below are followed as-is (minRelevance, an explicit
|
|
69
|
+
// opt-in, still prunes; best-first ordering still applies). 'auto'/'targeted'
|
|
70
|
+
// keep the gate. The AI stays where it earns its cost: reveal + nav-plan.
|
|
71
|
+
const gate = modeBehavior(ctx.options.mode, task).linkGate;
|
|
72
|
+
|
|
73
|
+
const keep = [];
|
|
74
|
+
const unknown = [];
|
|
75
|
+
for (const c of candidateObjs) {
|
|
76
|
+
if (cache.has(c.href)) {
|
|
77
|
+
if (cache.get(c.href)) keep.push(c.href);
|
|
78
|
+
} else {
|
|
79
|
+
unknown.push(c);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// FOCUSED MODE (opt-in via `minRelevance` > 0): drop clearly off-task links before the
|
|
84
|
+
// model ever sees them — but ONLY when the task actually discriminates among THIS
|
|
85
|
+
// page's links (at least one reaches the threshold), so a generic task can never nuke
|
|
86
|
+
// everything. DEFAULT is minRelevance = 0 → prune nothing: precision/completeness
|
|
87
|
+
// first; scoring then only reorders, never drops.
|
|
88
|
+
const minRel = Number(ctx.options.minRelevance) || 0;
|
|
89
|
+
let toJudge = unknown;
|
|
90
|
+
if (minRel > 0 && terms.length && unknown.some((c) => scoreOf.get(c.href) >= minRel)) {
|
|
91
|
+
toJudge = [];
|
|
92
|
+
for (const c of unknown) {
|
|
93
|
+
if (scoreOf.get(c.href) >= minRel) toJudge.push(c);
|
|
94
|
+
else cache.set(c.href, false); // pruned as off-task; not re-judged this scan
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (toJudge.length && !gate) {
|
|
99
|
+
// Gate off (mode 'complete'): follow every remaining candidate, zero model calls.
|
|
100
|
+
// Cached like a gate verdict so re-seen hrefs stay O(1).
|
|
101
|
+
for (const c of toJudge) {
|
|
102
|
+
cache.set(c.href, true);
|
|
103
|
+
keep.push(c.href);
|
|
104
|
+
}
|
|
105
|
+
} else if (toJudge.length) {
|
|
106
|
+
// Rank by relevance so the MOST on-task links are judged first. Then judge EVERY
|
|
107
|
+
// candidate in batches of aiSelectLinks' cap (160): a page can carry more links than
|
|
108
|
+
// one call fits, and a candidate the model never saw must not be recorded as
|
|
109
|
+
// rejected — that would silently drop its page for the whole scan (rule #1).
|
|
110
|
+
const ranked = [...toJudge].sort((a, b) => scoreOf.get(b.href) - scoreOf.get(a.href));
|
|
111
|
+
for (let i = 0; i < ranked.length; i += 160) {
|
|
112
|
+
const batch = ranked.slice(i, i + 160);
|
|
113
|
+
let chosen;
|
|
114
|
+
try {
|
|
115
|
+
chosen = await aiSelectLinks({ llm: ctx.options.llm, task, links: batch });
|
|
116
|
+
} catch {
|
|
117
|
+
chosen = batch.map((c) => c.href); // completeness bias on failure
|
|
118
|
+
}
|
|
119
|
+
const chosenSet = new Set(chosen);
|
|
120
|
+
for (const c of batch) {
|
|
121
|
+
const follow = chosenSet.has(c.href);
|
|
122
|
+
cache.set(c.href, follow);
|
|
123
|
+
if (follow) keep.push(c.href);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Best-first: hand the frontier this page's chosen links most-on-task FIRST, so an
|
|
129
|
+
// early Stop (or a maxPages cap) keeps what the task cares about most.
|
|
130
|
+
keep.sort((a, b) => (scoreOf.get(b) || 0) - (scoreOf.get(a) || 0));
|
|
131
|
+
return keep;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* #16 — budget the SPECULATIVE JS-mined routes that reach the AI link gate.
|
|
136
|
+
* perceive() mines up to 800 same-site paths per page from script/JSON blobs:
|
|
137
|
+
* real router manifests mixed with build/chunk noise. They all pass scope, so
|
|
138
|
+
* they all used to be judged by the model in batches of 160 — up to 5 extra
|
|
139
|
+
* calls per page spent mostly on `/static/chunk-…`. Rank them by task
|
|
140
|
+
* relevance (scoreLink — universal, task-driven, no URL-shape rules) and keep
|
|
141
|
+
* only the top `maxRoutes`.
|
|
142
|
+
*
|
|
143
|
+
* Conservative by construction (rule #1): the cut happens ONLY when the scores
|
|
144
|
+
* actually discriminate among the routes (min < max). A generic task scores
|
|
145
|
+
* everything 1 and an off-vocabulary task scores everything 0 — no variance,
|
|
146
|
+
* no cut, today's behaviour. Ties keep mined order (deterministic). DOM links
|
|
147
|
+
* are NEVER budgeted — this touches only the speculative source, and a cut
|
|
148
|
+
* route stays reachable via real links / sitemap on any later page.
|
|
149
|
+
* Pure; exported for the test suite.
|
|
150
|
+
*
|
|
151
|
+
* #22: an optional `scoreOf` map (href → score, from the per-scan scorer) lets
|
|
152
|
+
* the semantic tier feed the ranking; any href it misses — and every call
|
|
153
|
+
* without the map — falls back to the lexical scoreLink, so the guard's
|
|
154
|
+
* variance semantics are unchanged.
|
|
155
|
+
*
|
|
156
|
+
* @returns {{ routes: string[], cut: number }}
|
|
157
|
+
*/
|
|
158
|
+
export function budgetRoutes(routes, terms, maxRoutes, scoreOf = null) {
|
|
159
|
+
const budget = Math.max(0, Math.floor(Number(maxRoutes) || 0));
|
|
160
|
+
if (!budget || routes.length <= budget) return { routes, cut: 0 };
|
|
161
|
+
const scored = routes.map((href, i) => ({
|
|
162
|
+
href,
|
|
163
|
+
i,
|
|
164
|
+
score: scoreOf && scoreOf.has(href) ? scoreOf.get(href) : scoreLink(terms, { href }).score,
|
|
165
|
+
}));
|
|
166
|
+
let min = Infinity;
|
|
167
|
+
let max = -Infinity;
|
|
168
|
+
for (const s of scored) {
|
|
169
|
+
if (s.score < min) min = s.score;
|
|
170
|
+
if (s.score > max) max = s.score;
|
|
171
|
+
}
|
|
172
|
+
if (!(min < max)) return { routes, cut: 0 }; // nothing discriminates → cut nothing
|
|
173
|
+
scored.sort((a, b) => b.score - a.score || a.i - b.i);
|
|
174
|
+
return { routes: scored.slice(0, budget).map((s) => s.href), cut: routes.length - budget };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Dedupe + keep only in-scope http(s) URLs. */
|
|
178
|
+
function inScopeUnique(urls, baseUrl, options) {
|
|
179
|
+
const out = new Map();
|
|
180
|
+
for (const u of urls) {
|
|
181
|
+
const n = normalizeUrl(u);
|
|
182
|
+
if (!n) continue;
|
|
183
|
+
if (!inScope(n, baseUrl, options)) continue;
|
|
184
|
+
if (!out.has(n)) out.set(n, n);
|
|
185
|
+
}
|
|
186
|
+
return [...out.values()];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function crawlPageWithEngine(target, ctx) {
|
|
190
|
+
const { url, task } = target;
|
|
191
|
+
const browserMode = ctx.options.browser;
|
|
192
|
+
|
|
193
|
+
if (browserMode === 'never' || !(await isBrowserAvailable())) {
|
|
194
|
+
if (browserMode !== 'never' && !ctx._browserWarned) {
|
|
195
|
+
ctx._browserWarned = true;
|
|
196
|
+
ctx.emit({
|
|
197
|
+
type: 'warn',
|
|
198
|
+
url,
|
|
199
|
+
reason: 'browser-missing',
|
|
200
|
+
message:
|
|
201
|
+
(browserError() || 'The engine needs a browser to render and reveal dynamic content.') +
|
|
202
|
+
' Falling back to static HTML — interaction-hidden content is likely missing; completeness is not guaranteed.',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return staticFallback(target, ctx);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let pageCtx;
|
|
209
|
+
try {
|
|
210
|
+
pageCtx = await newPage();
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (!ctx._browserWarned) {
|
|
213
|
+
ctx._browserWarned = true;
|
|
214
|
+
ctx.emit({
|
|
215
|
+
type: 'warn',
|
|
216
|
+
url,
|
|
217
|
+
reason: 'browser-missing',
|
|
218
|
+
message:
|
|
219
|
+
(browserError() || 'Browser launch failed: ' + (err && err.message)) +
|
|
220
|
+
' Falling back to static HTML.',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return staticFallback(target, ctx);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { page, release } = pageCtx;
|
|
227
|
+
const popups = new Set();
|
|
228
|
+
page.on('popup', (p) => {
|
|
229
|
+
try {
|
|
230
|
+
popups.add(p.url());
|
|
231
|
+
} catch {
|
|
232
|
+
/* ignore */
|
|
233
|
+
}
|
|
234
|
+
p.close().catch(() => {});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
let revealed;
|
|
238
|
+
let status = 0;
|
|
239
|
+
let headers = {};
|
|
240
|
+
let navFailed = false; // navigation itself failed (timeout / network) — retryable
|
|
241
|
+
try {
|
|
242
|
+
try {
|
|
243
|
+
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
|
244
|
+
status = resp ? resp.status() : 0;
|
|
245
|
+
if (resp) headers = resp.headers();
|
|
246
|
+
} catch {
|
|
247
|
+
navFailed = true; // fall through to whatever rendered
|
|
248
|
+
}
|
|
249
|
+
// Give a client-rendered app a chance to paint real content before we look.
|
|
250
|
+
await page
|
|
251
|
+
.waitForFunction(
|
|
252
|
+
() => {
|
|
253
|
+
const m = document.querySelector('main,article,[role=main]') || document.body;
|
|
254
|
+
return m && (m.innerText || '').trim().length > 150;
|
|
255
|
+
},
|
|
256
|
+
{ timeout: 6000 },
|
|
257
|
+
)
|
|
258
|
+
.catch(() => {});
|
|
259
|
+
// Then wait for the load to actually FINISH via the response-quiet signal
|
|
260
|
+
// (#15): network quiet for a grace window AND the text no longer changing.
|
|
261
|
+
// `networkidle` sat here before and was a FIXED 8s tax on any site holding a
|
|
262
|
+
// connection open (analytics, websocket, long-poll) — the idle never fires.
|
|
263
|
+
// settle() counts response EVENTS, not open connections, so those sites exit
|
|
264
|
+
// after one grace window; a real late cascade is still waited out. The same
|
|
265
|
+
// 8s bound keeps the worst case from ever regressing, and settle's final
|
|
266
|
+
// quiet+stable exit subsumes the flat 400ms that used to follow.
|
|
267
|
+
await settle(page, { maxMs: 8000 });
|
|
268
|
+
if (status >= 400) {
|
|
269
|
+
ctx.emit({
|
|
270
|
+
type: 'warn',
|
|
271
|
+
url,
|
|
272
|
+
reason: 'http-' + status,
|
|
273
|
+
message: `Page returned HTTP ${status}; it may not exist or have moved. Trying to recover via site navigation.`,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// #14 — anti-bot guard, ALWAYS on (a precision guard, not a courtesy): a
|
|
277
|
+
// bot-defense challenge ("checking your browser", CAPTCHA wall — often HTTP
|
|
278
|
+
// 200) must NEVER enter the output as content. Policy: loud `anti-bot`
|
|
279
|
+
// warning, ONE retry after a backoff (honouring Retry-After), then a
|
|
280
|
+
// declared skip. Never bypassed — challenges stay out of scope forever
|
|
281
|
+
// (ARCHITECTURE §14): we signal, we don't break through.
|
|
282
|
+
const probeChallenge = async () => {
|
|
283
|
+
const html = await page.content().catch(() => '');
|
|
284
|
+
const text = await page.evaluate(() => (document.body && document.body.innerText) || '').catch(() => '');
|
|
285
|
+
return detectChallenge({ status, headers, html, contentLen: text.replace(/\s+/g, ' ').trim().length });
|
|
286
|
+
};
|
|
287
|
+
let det = await probeChallenge();
|
|
288
|
+
if (det.challenge) {
|
|
289
|
+
ctx.emit({
|
|
290
|
+
type: 'warn',
|
|
291
|
+
url,
|
|
292
|
+
reason: 'anti-bot',
|
|
293
|
+
message: `Bot-defense challenge detected (${det.signal}); retrying once after a pause.`,
|
|
294
|
+
});
|
|
295
|
+
await new Promise((r) => setTimeout(r, challengeBackoffMs(headers)));
|
|
296
|
+
try {
|
|
297
|
+
const resp2 = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
|
298
|
+
status = resp2 ? resp2.status() : status;
|
|
299
|
+
headers = resp2 ? resp2.headers() : {};
|
|
300
|
+
} catch {
|
|
301
|
+
/* keep whatever loaded — the re-probe decides */
|
|
302
|
+
}
|
|
303
|
+
await settle(page, { maxMs: 8000 });
|
|
304
|
+
det = await probeChallenge();
|
|
305
|
+
if (det.challenge) {
|
|
306
|
+
ctx.emit({
|
|
307
|
+
type: 'warn',
|
|
308
|
+
url,
|
|
309
|
+
reason: 'anti-bot',
|
|
310
|
+
message: `Still challenged after the retry (${det.signal}) — page skipped; its interstitial is NOT in the output.`,
|
|
311
|
+
});
|
|
312
|
+
return { page: null, links: [] }; // never kept, never bypassed
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
revealed = await revealAll(page, ctx, url, task);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
ctx.emit({ type: 'error', url, message: 'render failed: ' + (err && err.message) });
|
|
318
|
+
return staticFallback(target, ctx); // `release()` runs in finally before we return
|
|
319
|
+
} finally {
|
|
320
|
+
await release(); // close the page and return the context to the pool for reuse
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (revealed.hitCap) {
|
|
324
|
+
ctx.emit({
|
|
325
|
+
type: 'warn',
|
|
326
|
+
url,
|
|
327
|
+
reason: 'max-actions',
|
|
328
|
+
message: `Reached the per-page reveal cap (${Math.max(8, ctx.options.maxActions || 40)}); some hidden content may remain. Raise --max-actions for full coverage.`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let markdown = revealed.markdown;
|
|
333
|
+
let title = revealed.title;
|
|
334
|
+
let relevant = true;
|
|
335
|
+
// Raw { text, provenance } blocks from the reveal — the spine the layout router
|
|
336
|
+
// addresses by metadata (provenance/section/ordinal). Null for the static path.
|
|
337
|
+
let blocks = Array.isArray(revealed.blocks) ? revealed.blocks : null;
|
|
338
|
+
|
|
339
|
+
// Assemble candidate links: in-content + nav (button-revealed) + popups + JS routes.
|
|
340
|
+
// Real destinations (DOM links, revealed nav, popups) are never capped; the
|
|
341
|
+
// speculative JS-mined routes go through the #16 relevance budget first, and
|
|
342
|
+
// ones already present as real links don't consume it.
|
|
343
|
+
const realLinks = inScopeUnique(
|
|
344
|
+
[...revealed.links.map((l) => l.href), ...revealed.navLinks, ...popups],
|
|
345
|
+
url,
|
|
346
|
+
ctx.options,
|
|
347
|
+
);
|
|
348
|
+
const realSet = new Set(realLinks);
|
|
349
|
+
let routes = inScopeUnique(revealed.routes, url, ctx.options).filter((r) => !realSet.has(r));
|
|
350
|
+
if (routes.length) {
|
|
351
|
+
const cacheHost = ctx.currentScan || ctx;
|
|
352
|
+
if (!cacheHost._taskTerms) cacheHost._taskTerms = taskTerms(task);
|
|
353
|
+
// #22: rank the speculative routes with the same per-scan scorer as the link
|
|
354
|
+
// gate (semantic when configured); the lexical floor keeps the conservative
|
|
355
|
+
// no-variance-no-cut guard exactly as before. Scored ONLY when the budget
|
|
356
|
+
// would actually cut — under-budget pages must not pay for embeddings.
|
|
357
|
+
const overBudget = ctx.options.maxRoutes > 0 && routes.length > ctx.options.maxRoutes;
|
|
358
|
+
const routeScores = overBudget ? await scorerFor(ctx, task).scoreAll(routes.map((href) => ({ href }))) : null;
|
|
359
|
+
const budgeted = budgetRoutes(routes, cacheHost._taskTerms, ctx.options.maxRoutes, routeScores);
|
|
360
|
+
if (budgeted.cut > 0) {
|
|
361
|
+
ctx.emit({
|
|
362
|
+
type: 'action',
|
|
363
|
+
action: 'route-budget',
|
|
364
|
+
url,
|
|
365
|
+
detail: `${budgeted.routes.length}/${routes.length} mined routes sent to the link gate (ranked by task relevance)`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
routes = budgeted.routes;
|
|
369
|
+
}
|
|
370
|
+
const candidates = [...realLinks, ...routes];
|
|
371
|
+
const candidateObjs = candidates.map((href) => {
|
|
372
|
+
const found = revealed.links.find((l) => normalizeUrl(l.href) === href);
|
|
373
|
+
return { href, label: found ? found.label : '' };
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// The navigation never loaded anything AND the page yielded neither content nor
|
|
377
|
+
// links: a transient failure (timeout, connection reset), not an empty page.
|
|
378
|
+
// Signal it so the frontier can retry the URL once instead of silently losing it.
|
|
379
|
+
if (navFailed && !markdown && candidates.length === 0) {
|
|
380
|
+
return { page: null, links: [], failed: true };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Crawl-time scoping keeps only task-relevant SECTIONS, VERBATIM. Whether it
|
|
384
|
+
// runs is decided by the EXPLICIT mode (#20): 'complete' keeps pages whole;
|
|
385
|
+
// 'targeted' always scopes; 'auto' (legacy) scopes for non-doc tasks only.
|
|
386
|
+
// This is the "stay focused" step — it drops off-task chrome (landing/footer/
|
|
387
|
+
// pricing) but NEVER transforms content. All task-driven filtering / reshaping /
|
|
388
|
+
// regrouping ("only the available slots", "prices as a table") is Phase 2 — the
|
|
389
|
+
// user asks for it AFTER the crawl, over the saved files, via aiReshape
|
|
390
|
+
// (see src/reshape.mjs). The crawl stays verbatim.
|
|
391
|
+
if (markdown && modeBehavior(ctx.options.mode, task).scopeSections) {
|
|
392
|
+
const scoped = await aiScopeContent({ llm: ctx.options.llm, task, title, markdown }).catch(() => null);
|
|
393
|
+
if (scoped) {
|
|
394
|
+
// Keep blocks in sync with the scoped markdown so reveal PROVENANCE survives
|
|
395
|
+
// scoping: drop the blocks whose verbatim text the scope step removed.
|
|
396
|
+
if (blocks && scoped.markdown !== markdown) {
|
|
397
|
+
const norm = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
398
|
+
const kept = new Set(splitBlocks(scoped.markdown).map(norm));
|
|
399
|
+
blocks = blocks.filter((b) => kept.has(norm(b.text)));
|
|
400
|
+
}
|
|
401
|
+
markdown = scoped.markdown;
|
|
402
|
+
relevant = scoped.relevant;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Which links to follow is ALWAYS an AI decision. That's the universal way to
|
|
407
|
+
// catch non-obvious navigation — SPA fragment routes (#/contact), query routes
|
|
408
|
+
// (?view=pricing), framework-specific pagination — without the algorithm
|
|
409
|
+
// hard-coding any URL-shape rules. Per-run caching keeps it cheap: each href is
|
|
410
|
+
// judged once, so homogeneous nav (a shared sidebar/footer) costs one call.
|
|
411
|
+
const follow = await decideFollow(ctx, task, candidateObjs);
|
|
412
|
+
|
|
413
|
+
// An HTTP error page (404/410/500 …) whose rendered content is thin is server
|
|
414
|
+
// boilerplate ("page not found"), not content — keep its links (they drive the
|
|
415
|
+
// recovery navigation) but never its body. The word-count floor protects the one
|
|
416
|
+
// legitimate exception: a misconfigured SPA host that answers 404 while the app
|
|
417
|
+
// renders a full, real page client-side.
|
|
418
|
+
const errorPage = status >= 400 && contentWordLen(markdown) < 200;
|
|
419
|
+
if (!markdown || !relevant || errorPage) return { page: null, links: follow };
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
page: {
|
|
423
|
+
url,
|
|
424
|
+
task,
|
|
425
|
+
title,
|
|
426
|
+
markdown,
|
|
427
|
+
blocks,
|
|
428
|
+
// The faithful per-state record (BlockAccumulator.states()): every DISTINCT
|
|
429
|
+
// reveal state, whole and verbatim. Attached ONLY when a reveal produced more
|
|
430
|
+
// than one distinct state — a single-state page's one snapshot IS its
|
|
431
|
+
// consolidated markdown (and chrome clicks that changed no content collapse
|
|
432
|
+
// to it), so there is nothing extra to keep. Written to disk under states/.
|
|
433
|
+
states: Array.isArray(revealed.states) && revealed.states.length > 1 ? revealed.states : undefined,
|
|
434
|
+
// #21d — the reveal exit audit, per page and machine-readable: how many
|
|
435
|
+
// characters of text were STILL hidden in the main content when the
|
|
436
|
+
// reveal loop ended (0 = measured drain). Travels into the manifest and
|
|
437
|
+
// scan stats so completeness is a number, not a hope.
|
|
438
|
+
meta: { strategy: 'agent', fetchedAt: now(), bytes: bytesOf(markdown), revealResidualChars: revealed.hiddenResidualChars || 0, ...httpValidators(headers, ctx.options.incremental) },
|
|
439
|
+
},
|
|
440
|
+
links: follow,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** #6 — pull HTTP cache validators from a response's (lowercased) headers, only when
|
|
445
|
+
* an incremental crawl will need them. Empty object otherwise, so meta is unchanged. */
|
|
446
|
+
function httpValidators(headers, incremental) {
|
|
447
|
+
if (!incremental || !headers) return {};
|
|
448
|
+
const out = {};
|
|
449
|
+
const etag = headers['etag'];
|
|
450
|
+
const lm = headers['last-modified'];
|
|
451
|
+
if (etag) out.httpEtag = String(etag);
|
|
452
|
+
if (lm) out.httpLastModified = String(lm);
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** No-browser path: plain fetch + static extraction (degraded; emits no reveal). */
|
|
457
|
+
async function staticFallback(target, ctx) {
|
|
458
|
+
const { url, task } = target;
|
|
459
|
+
let res = await loadHtml(url, { browserMode: ctx.options.browser, ctx });
|
|
460
|
+
// status 0 = the fetch itself failed (network/timeout) — retryable, not "empty page".
|
|
461
|
+
if (!res.html) return { page: null, links: [], failed: !res.status };
|
|
462
|
+
|
|
463
|
+
let { title, markdown } = extractMarkdown(res.html, { baseUrl: res.finalUrl });
|
|
464
|
+
|
|
465
|
+
// #14 — the same always-on anti-bot guard as the engine path: warn, one
|
|
466
|
+
// backoff retry, then a declared skip. A challenge is never content.
|
|
467
|
+
let det = detectChallenge({ status: res.status, headers: res.headers || {}, html: res.html, contentLen: contentWordLen(markdown) });
|
|
468
|
+
if (det.challenge) {
|
|
469
|
+
ctx.emit({
|
|
470
|
+
type: 'warn',
|
|
471
|
+
url,
|
|
472
|
+
reason: 'anti-bot',
|
|
473
|
+
message: `Bot-defense challenge detected (${det.signal}); retrying once after a pause.`,
|
|
474
|
+
});
|
|
475
|
+
await new Promise((r) => setTimeout(r, challengeBackoffMs(res.headers || {})));
|
|
476
|
+
res = await loadHtml(url, { browserMode: ctx.options.browser, ctx });
|
|
477
|
+
if (!res.html) return { page: null, links: [], failed: !res.status };
|
|
478
|
+
({ title, markdown } = extractMarkdown(res.html, { baseUrl: res.finalUrl }));
|
|
479
|
+
det = detectChallenge({ status: res.status, headers: res.headers || {}, html: res.html, contentLen: contentWordLen(markdown) });
|
|
480
|
+
if (det.challenge) {
|
|
481
|
+
ctx.emit({
|
|
482
|
+
type: 'warn',
|
|
483
|
+
url,
|
|
484
|
+
reason: 'anti-bot',
|
|
485
|
+
message: `Still challenged after the retry (${det.signal}) — page skipped; its interstitial is NOT in the output.`,
|
|
486
|
+
});
|
|
487
|
+
return { page: null, links: [] }; // never kept, never bypassed
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const links = new Set();
|
|
491
|
+
for (const m of res.html.matchAll(/<a\b[^>]*href=["']([^"']+)["']/gi)) {
|
|
492
|
+
const abs = resolveUrl(m[1], res.finalUrl);
|
|
493
|
+
if (abs) links.add(abs);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Same rule as the engine path: an HTTP error page with thin content is server
|
|
497
|
+
// boilerplate — harvest its links, never keep its body.
|
|
498
|
+
if (!markdown || (res.status >= 400 && contentWordLen(markdown) < 200)) {
|
|
499
|
+
return { page: null, links: [...links] };
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
page: {
|
|
503
|
+
url,
|
|
504
|
+
task,
|
|
505
|
+
title,
|
|
506
|
+
markdown,
|
|
507
|
+
meta: { strategy: 'agent', fetchedAt: now(), bytes: bytesOf(markdown), ...httpValidators(res.headers, ctx.options.incremental) },
|
|
508
|
+
},
|
|
509
|
+
links: [...links],
|
|
510
|
+
};
|
|
511
|
+
}
|