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