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,799 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // The reveal engine: exhaustively exercise every control that could hide content
4
+ // (tabs, accordions, "load more", menus, JS widgets, paginators) and capture each
5
+ // revealed state until nothing NEW appears. Universal — it does not care how the
6
+ // site is built. AI (aiSelectRevealers) decides WHICH controls hide content; this
7
+ // loop decides HOW to traverse them, driven by a small map of explored state.
8
+ //
9
+ // Traversal model (map-driven, deterministic — no per-click LLM, no per-site code):
10
+ // every click is classified by its EFFECT, by comparing the controls (siblings)
11
+ // and the state fingerprint before vs after:
12
+ // - IN-PLACE the siblings stay (a tab swaps text, an accordion opens, a calendar
13
+ // day shows its slots below the grid) → the control is a leaf, clicked
14
+ // once, and we keep going in the same view.
15
+ // - ADVANCING the siblings are largely replaced by a NEW set with new content (a
16
+ // "next month"/"next page" paginator, a wizard step) → we DON'T retire
17
+ // it; we record which states it was applied from and re-apply it from
18
+ // each new state, walking the whole sequence (June → July → August …)
19
+ // until it stops yielding new states (saturation) or the budget runs
20
+ // out. This is the load-more idea generalised to any view-advancing
21
+ // control — the fix for stateful sequences a one-shot click missed.
22
+ // - NAVIGATED the click left the page (real URL) or replaced the view with a
23
+ // dead/seen one → recorded as a link, or restoreBase() to recover the
24
+ // siblings.
25
+ // A set of visited state fingerprints prevents cycles; the action budget and a
26
+ // per-control re-use cap guarantee termination.
27
+
28
+ import { perceive, markVisualHeadings } from './perceive.mjs';
29
+ import { pickConsent } from './consent.mjs';
30
+ import { clickRevealer, scrollStep } from './actions.mjs';
31
+ import { settle } from '../lib/settle.mjs';
32
+ import { extractMarkdown, BlockAccumulator } from '../extract.mjs';
33
+ import { aiSelectRevealers, aiPlanNavigation } from './decide.mjs';
34
+
35
+ // #21b — the measurement thresholds of the closed loop. PAYLOAD_MIN: a control
36
+ // with at least this many characters of MEASURED hidden text behind it (its
37
+ // aria-controls target, a hidden sibling panel, an unopened <details>) is
38
+ // revealed even when a judge said no — ~30 words is real content, above
39
+ // menu-crumb noise, and the cost of a wrong override is one click (~1s) while
40
+ // the cost of a wrong "no" is lost content (rule #1). RESIDUAL_WARN_CHARS: the
41
+ // exit audit warns when at least this much text is still hidden at the end.
42
+ export const PAYLOAD_MIN = 200;
43
+ export const RESIDUAL_WARN_CHARS = 1200;
44
+
45
+ /**
46
+ * #9 — the TRUTHFUL residual: the raw hidden-char count MINUS any hidden block whose
47
+ * text is already in the captured markdown. A mutually-exclusive panel (tab B once
48
+ * tab C is active) is hidden in the final state yet WAS captured when it was open —
49
+ * the exit audit's known false-positive. Conservative (rule #1 — never mask a real
50
+ * gap): only a strong verbatim match (≥60 chars) counts as captured, and text past
51
+ * the inspected sample cap stays counted. Pure; exported for the tests and reused by
52
+ * the a11y fallback's re-check.
53
+ */
54
+ export function truthfulResidual(rawResidual, hiddenTexts, markdown) {
55
+ if (!hiddenTexts || !hiddenTexts.length) return rawResidual;
56
+ const capturedNorm = String(markdown || '').replace(/\s+/g, ' ').toLowerCase();
57
+ const inspected = hiddenTexts.reduce((a, h) => a + (h.n || 0), 0);
58
+ let uncaptured = 0;
59
+ for (const h of hiddenTexts) {
60
+ const sample = String(h.s || '').replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 60);
61
+ if (sample.length >= 60 && capturedNorm.includes(sample)) continue; // captured in an earlier state
62
+ uncaptured += h.n || 0;
63
+ }
64
+ return uncaptured + Math.max(0, rawResidual - inspected); // uninspected chars stay counted
65
+ }
66
+
67
+ /**
68
+ * #21b — rank approved controls by MEASURED signals, so the action budget goes
69
+ * to provable content first: a closed disclosure (aria-expanded="false") is
70
+ * mechanical proof there is something to open; a measured hidden payload
71
+ * likewise (weighted by size); a specific kind (tab/expander/dropdown/loadmore)
72
+ * beats the generic 'control'; the label heuristic is only a last-place hint
73
+ * (English-biased, no longer load-bearing). A `chrome` control — a JS switcher in
74
+ * the site nav/header/footer, now surfaced so nothing clickable is missed —
75
+ * carries a penalty so the MAIN CONTENT always gets the budget first; chrome only
76
+ * leapfrogs it on HARD measured proof (a real payload / closed disclosure), never
77
+ * on a kind/label hint alone. Pure; exported for the tests.
78
+ */
79
+ export function revealPriority(c) {
80
+ return (
81
+ (c.expanded === 'false' ? 4 : 0) +
82
+ ((c.hiddenPayload || 0) > 0 ? 2 + Math.min(1, (c.hiddenPayload || 0) / 2000) : 0) +
83
+ (c.kind && c.kind !== 'control' ? 1 : 0) +
84
+ (c.heuristic ? 0.5 : 0) -
85
+ (c.chrome ? 1 : 0)
86
+ );
87
+ }
88
+
89
+ /**
90
+ * @param {import('playwright').Page} page
91
+ * @param {object} ctx crawl context (emit, shouldStop, options)
92
+ * @param {string} url the page URL (for events)
93
+ * @param {string} [task] the crawl task (context for the AI reveal triage)
94
+ * @returns {Promise<{ markdown, title, links, navLinks, routes, hitCap, hiddenResidualChars }>}
95
+ */
96
+ export async function revealAll(page, ctx, url, task) {
97
+ const acc = new BlockAccumulator();
98
+ const navLinks = new Set();
99
+ const decided = new Map(); // signature -> boolean: does this control hide content?
100
+
101
+ // Per-SCAN reveal-verdict cache (survives ACROSS pages, unlike `decided` which is
102
+ // per-page). A docs site repeats the same tabs/accordions/expanders on every page;
103
+ // without this the AI re-judges them on all N pages. This is the link-gate cache
104
+ // idea (decideFollow's _followCache) applied to reveal — the single biggest crawl
105
+ // token saving. Keyed by the control's stable HUMAN identity (role|label|kind),
106
+ // which is the same across pages (it excludes the per-page DOM id and the often
107
+ // auto-generated aria-controls that the full traversal `signature` carries).
108
+ const revealHost = ctx.currentScan || ctx;
109
+ const revealCache = revealHost._revealCache || (revealHost._revealCache = new Map());
110
+ // Unlabelled controls get NO cross-page key (judged per page) so distinct generic
111
+ // controls are never collapsed onto one shared verdict.
112
+ const revealKey = (c) => (c.label && c.label.trim().length >= 2 ? `${c.role}|${c.label.trim()}|${c.kind}` : null);
113
+
114
+ // Cross-page CHROME futility (measured · universal · re-verifying). The per-page
115
+ // shape-muting below RESETS each page, so the site's shared chrome (theme toggle,
116
+ // nav tabs, search) is re-clicked on EVERY page only to re-measure that it reveals
117
+ // nothing — on a large site that is ~88% of all clicks, each costing a settle wait.
118
+ // Carry a per-SCAN, MEASURED tally: a CHROME control (structurally OUTSIDE the main
119
+ // content, #28 — so never content itself) that added ZERO content on the last
120
+ // INERT_PAGES pages is SAMPLED DOWN — clicked once every RECHECK pages instead of on
121
+ // every page. It is re-verified on that cadence and RE-ARMED FOREVER the instant it
122
+ // EVER adds a block, so no content can be lost (anything that reveals content is
123
+ // never sampled) and it adapts to any site. Content controls, already-productive
124
+ // controls and unlabelled chrome (no cross-page key) are never sampled → precision
125
+ // stays identical; only proven waste is trimmed. The signal is CONTENT (added>0),
126
+ // NOT state change: a theme swap moves the fingerprint but adds no content, so it is
127
+ // correctly treated as inert. Keyed by revealKey (role|label|kind), stable per site.
128
+ const inert = revealHost._revealInert || (revealHost._revealInert = new Map());
129
+ const pageOrdinal = (revealHost._revealPageNo = (revealHost._revealPageNo || 0) + 1);
130
+ const INERT_PAGES = 4; // measured-inert pages required before sampling begins
131
+ const RECHECK = 5; // re-verify a proven-inert chrome control every Nth page
132
+ const sampledOut = (r) => {
133
+ if (!r.chrome) return false;
134
+ const k = revealKey(r);
135
+ if (!k) return false;
136
+ const rec = inert.get(k);
137
+ if (!rec || rec.productive || rec.streak < INERT_PAGES) return false;
138
+ return pageOrdinal % RECHECK !== 0; // outside the re-verify cadence → skip this page
139
+ };
140
+
141
+ const doneLeaf = new Set(); // signatures of leaf/in-place controls already clicked
142
+ const advancing = new Map(); // signature -> { appliedFrom: Set<fp>, uses: number }
143
+ const visited = new Set(); // state fingerprints already captured (cycle guard)
144
+
145
+ // MEASURED FUTILITY GUARD. Interactive apps attach click handlers to plain DATA
146
+ // rows (stat cards, table rows, chips — ripple frameworks do this by default),
147
+ // and no-AI mode approves every candidate, so the action budget can drain on
148
+ // dozens of identical-looking rows that reveal nothing — starving the controls
149
+ // that DO (an embedded app's Analytics/Chat views). Controls sharing a visual
150
+ // SHAPE (role|kind|class) are probed a few times; once SHAPE_DEAD consecutive
151
+ // members click to no effect (no new blocks, no state change, no navigation)
152
+ // the remaining look-alikes are muted for this page. One member with a real
153
+ // effect re-arms its shape (calendar days stay alive: the first day already
154
+ // reveals its slots). Measured, not judged — and bounded: a fruitless shape
155
+ // costs at most SHAPE_DEAD clicks instead of one per member.
156
+ const SHAPE_DEAD = 3;
157
+ const shapeKey = (r) => `${r.role}|${r.kind}|${r.cls || ''}`;
158
+ const shapeFails = new Map(); // shapeKey -> consecutive no-effect clicks
159
+ const shapeMuted = (r) => (shapeFails.get(shapeKey(r)) || 0) >= SHAPE_DEAD;
160
+ const maxActions = Math.max(8, ctx.options.maxActions || 40);
161
+ // Bound how many times a single view-advancing control may re-apply, so a
162
+ // bidirectional paginator (prev/next) can't loop forever within the budget.
163
+ const ADV_CAP = Math.max(12, maxActions);
164
+ // Bound the TARGETED WALK: how many times the AI-planned direction control may be
165
+ // clicked toward a target before giving up. Without this, a mis-planned direction
166
+ // (a control whose target marker never appears — e.g. a modal-opener) re-clicks
167
+ // forever and eats the whole action budget. Kept well below maxActions so a single
168
+ // control can never monopolise the crawl.
169
+ const NAV_CAP = Math.max(12, Math.ceil(maxActions / 3));
170
+
171
+ // Capture only the VISIBLE DOM of the current state. Interactive apps pre-render
172
+ // many hidden panels (modals, on-screen keyboards, loading/success placeholders);
173
+ // `page.content()` serializes them all and extractMarkdown can't see CSS
174
+ // visibility in Node, so it would dump them into the output. We mark non-visible
175
+ // elements in-browser (atomically: mark → serialize → unmark) and drop them in
176
+ // extract. This is exactly the reveal model: content surfaced by a click IS
177
+ // visible at capture time; mutually-exclusive tab variants are each visible when
178
+ // active (so all still accumulate); chrome never revealed stays out. Generic —
179
+ // no per-site logic. If reveal misses a control, raise --max-actions (the right
180
+ // knob), rather than leaking every hidden panel.
181
+ // #26 — the same atomic pass also stamps data-crawldna-heading on VISUAL
182
+ // headings (short lines whose font jumps vs the local body text): computed
183
+ // styles only exist here in the browser, and extract.mjs turns the marker
184
+ // into ##/###/####, giving the .md the skeleton the page painted. The
185
+ // function lives in perceive.mjs and is inlined via toString() — a string
186
+ // evaluate, not a nested eval, so page CSP never blocks it.
187
+ const captureHtml = async () => {
188
+ try {
189
+ return await page.evaluate(`(() => {
190
+ let headingMarked = [];
191
+ try {
192
+ headingMarked = (${markVisualHeadings.toString()})() || [];
193
+ } catch (e) {
194
+ headingMarked = document.querySelectorAll('[data-crawldna-heading]');
195
+ }
196
+ const isHidden = (el) => {
197
+ const s = getComputedStyle(el);
198
+ if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') return true;
199
+ const r = el.getBoundingClientRect();
200
+ return r.width <= 1 && r.height <= 1;
201
+ };
202
+ const marked = [];
203
+ for (const el of document.body.querySelectorAll('*')) {
204
+ if (isHidden(el)) {
205
+ el.setAttribute('data-crawldna-hidden', '1');
206
+ marked.push(el);
207
+ }
208
+ }
209
+ const out = document.documentElement.outerHTML;
210
+ for (const el of marked) el.removeAttribute('data-crawldna-hidden');
211
+ for (const el of headingMarked) el.removeAttribute('data-crawldna-heading');
212
+ return out;
213
+ })()`);
214
+ } catch {
215
+ return page.content();
216
+ }
217
+ };
218
+
219
+ // `label` is the tab-variant marker (toMarkdown); `provenance` is the richer
220
+ // reveal source carried to the layout router so tasks like "the dropdown
221
+ // results → dropdown.md" can route by HOW a block was surfaced.
222
+ // `order` (#27) is the revealing control's vertical position in the page — it
223
+ // sorts mutually-exclusive reveal states into REPRESENTATION order in the
224
+ // output (the app's nav rail order), instead of the order they were clicked.
225
+ // Baseline and lazy-scroll captures pass 0 (the skeleton comes first).
226
+ const capture = async (label, provenance = 'baseline', order = 0) => {
227
+ const html = await captureHtml();
228
+ const { markdown } = extractMarkdown(html, { baseUrl: page.url() });
229
+ return acc.add(markdown, { label, provenance, order });
230
+ };
231
+
232
+ // AI-driven discovery: let the model read the candidate controls and decide
233
+ // which actually hide content (catching non-obvious ones, rejecting demos),
234
+ // caching the verdict per signature so each control is judged once. #21b: the
235
+ // model's "no" is overridden by a MEASURED hidden payload, and with no model
236
+ // at all (no-AI, outage) every candidate is approved with the measured
237
+ // ordering deciding who gets the budget — so coverage never depends on any
238
+ // lexicon. New candidates that only appear AFTER a reveal are triaged in the
239
+ // next loop pass (a few batched calls per page, never a per-click model loop).
240
+ const triage = async (candidates) => {
241
+ // First resolve from the cross-page cache (no model call), and collect only the
242
+ // genuinely-new controls for the AI. On the 2nd+ page of a uniform docs site this
243
+ // empties the list, so most pages cost ZERO reveal-triage tokens.
244
+ const undecided = [];
245
+ for (const c of candidates) {
246
+ if (decided.has(c.signature)) continue;
247
+ const key = revealKey(c);
248
+ if (key && revealCache.has(key)) {
249
+ // #21b: even a cached "no" yields to THIS page's measured payload — the
250
+ // cache carries the model's judgment, the measurement is per-page.
251
+ decided.set(c.signature, revealCache.get(key) || (c.hiddenPayload || 0) >= PAYLOAD_MIN);
252
+ } else undecided.push(c);
253
+ }
254
+ if (!undecided.length) return;
255
+ // Judge EVERY undecided candidate, in batches of the model call's cap. A single
256
+ // truncated batch used to leave candidates past #100 unjudged — and if the loop
257
+ // then found nothing actionable and exited, they were never triaged at all
258
+ // (silent missed content on control-dense pages, against rule #1).
259
+ for (let i = 0; i < undecided.length; i += 100) {
260
+ if (ctx.shouldStop()) break;
261
+ const batch = undecided.slice(i, i + 100);
262
+ let chosen = null;
263
+ try {
264
+ chosen = await aiSelectRevealers({ llm: ctx.options.llm, task, candidates: batch });
265
+ } catch {
266
+ chosen = null;
267
+ }
268
+ for (const c of batch) {
269
+ const modelSays = chosen ? chosen.has(c.signature) : null;
270
+ // #21b — MEASUREMENT ARBITRATES THE JUDGE. An AI "no" on a control with
271
+ // real measured hidden payload is overridden: the judge's error becomes
272
+ // harmless (one extra click at worst, never lost content). And with no
273
+ // judge at all (no-AI mode, model outage) EVERY candidate is approved —
274
+ // each already survived perceive's mechanical gauntlet (interactive,
275
+ // visible, enabled, not a copy/share action); a wasted click costs ~1s,
276
+ // missed content is irrecoverable (rule #1). The measured ORDERING
277
+ // (revealPriority) decides who gets the budget first, so approving all
278
+ // never starves the provable payloads. This retires the English
279
+ // DISCLOSURE_LABEL heuristic as a gate — no more lexicon gaps in no-AI.
280
+ const verdict = chosen ? modelSays || (c.hiddenPayload || 0) >= PAYLOAD_MIN : true;
281
+ decided.set(c.signature, verdict);
282
+ // Persist only the RAW MODEL verdict for labelled controls — the payload
283
+ // override is a per-page measurement and must not leak across pages.
284
+ const key = revealKey(c);
285
+ if (key && chosen) revealCache.set(key, modelSays);
286
+ }
287
+ }
288
+ };
289
+
290
+ // Dismiss cookie/consent overlays once so they don't block content. perceive
291
+ // MEASURES the overlay buttons; pickConsent (#21a) DECIDES — multilingual
292
+ // micro-lexicon read off the banner itself, reject preferred over accept,
293
+ // primary-by-geometry only for consent banners with exotic wording.
294
+ const consentSeen = new Set();
295
+ const dismissConsent = async () => {
296
+ const p = await perceive(page);
297
+ for (const c of pickConsent(p.consentCandidates)) {
298
+ if (ctx.shouldStop()) break;
299
+ const sig = c.label.toLowerCase();
300
+ if (consentSeen.has(sig)) continue;
301
+ consentSeen.add(sig);
302
+ const r = await clickRevealer(page, c.id);
303
+ ctx.emit({ type: 'action', url, action: 'click', detail: `dismiss overlay: ${c.label}` });
304
+ if (!r.navigatedTo) await capture();
305
+ }
306
+ };
307
+
308
+ // Reload the original page. Used to recover from a click that navigated the view
309
+ // to a dead/already-seen sub-view (a true wizard branch). doneLeaf/advancing/
310
+ // visited persist (signatures + fingerprints are content-based, stable across
311
+ // reload), so the loop resumes with the next un-tried control.
312
+ const restoreBase = async () => {
313
+ try {
314
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
315
+ // Response-quiet instead of networkidle (#15): a held-open socket kept the
316
+ // idle signal from EVER firing, taxing every restore its full 5s timeout.
317
+ await settle(page, { maxMs: 5000 });
318
+ } catch {
319
+ return;
320
+ }
321
+ await dismissConsent();
322
+ };
323
+
324
+ await dismissConsent();
325
+
326
+ // Baseline (default state).
327
+ await capture();
328
+
329
+ let lastPerception = await perceive(page);
330
+ let title = lastPerception.title;
331
+ const allLinks = new Map(); // href -> label
332
+ const allRoutes = new Set();
333
+ let navPlan; // computed once: { directionSig, target } to walk toward a target, or null
334
+ let actions = 0;
335
+ let hitCap = false;
336
+ let scrolledOut = false;
337
+ let navStopped = false; // reached the target (or can't advance) — stop walking
338
+ let navUses = 0; // times the targeted-walk direction control has been applied
339
+ let navStall = 0; // consecutive targeted-walk clicks that made no progress
340
+
341
+ while (!ctx.shouldStop()) {
342
+ if (actions >= maxActions) {
343
+ hitCap = true;
344
+ break;
345
+ }
346
+
347
+ const perception = await perceive(page);
348
+ lastPerception = perception;
349
+ title = title || perception.title;
350
+ const fp = perception.fingerprint;
351
+ visited.add(fp);
352
+ for (const l of perception.links) if (!allLinks.has(l.href)) allLinks.set(l.href, l.label);
353
+ for (const r of perception.routes) allRoutes.add(r);
354
+
355
+ await triage(perception.revealers);
356
+ // #21b — measured signals order the work: provable payloads first, so a tight
357
+ // action budget is never spent on generic controls while a closed disclosure
358
+ // with real text behind it waits. Stable sort: ties keep perception order.
359
+ const approved = perception.revealers
360
+ .filter((r) => decided.get(r.signature))
361
+ .sort((a, b) => revealPriority(b) - revealPriority(a));
362
+
363
+ // Plan navigation ONCE (the crawl4ai-style split: AI plans, loop executes). The
364
+ // model names the control that advances toward the task's target and a literal
365
+ // marker for the target view; everything after is deterministic.
366
+ if (navPlan === undefined && approved.length) {
367
+ let plan = null;
368
+ try {
369
+ plan = await aiPlanNavigation({
370
+ llm: ctx.options.llm,
371
+ task,
372
+ current: { title: perception.title, snippet: perception.mainText },
373
+ controls: approved,
374
+ });
375
+ } catch {
376
+ plan = null;
377
+ }
378
+ // A plan needs BOTH halves: the direction control AND the target marker.
379
+ // A direction without a target can never be walked toward (the targeted
380
+ // walk (A) requires `navPlan.target`), yet it would still be EXCLUDED
381
+ // from the explore branch (B) — a control reserved for a walk that never
382
+ // runs is a control never clicked, i.e. silently lost content (rule #1).
383
+ navPlan =
384
+ plan && plan.direction != null && plan.target
385
+ ? { directionSig: approved[plan.direction].signature, target: plan.target }
386
+ : null; // null = open-ended / no targeted navigation
387
+ }
388
+
389
+ const mainLC = (perception.mainText || '').toLowerCase();
390
+ const targeting = !!(navPlan && navPlan.target);
391
+ const onTarget = targeting && mainLC.includes(navPlan.target.toLowerCase());
392
+
393
+ let next;
394
+ let aiNav = false;
395
+
396
+ // (A) TARGETED WALK: step toward the target with the planned control, SKIPPING
397
+ // this (non-target) view's leaves so the budget isn't spent on views we don't want.
398
+ if (targeting && !onTarget && !navStopped) {
399
+ next = approved.find((r) => r.signature === navPlan.directionSig);
400
+ aiNav = !!next;
401
+ if (!next) navStopped = true; // can't advance — fall back to exploring here
402
+ }
403
+
404
+ // (B) EXPLORE THE CURRENT VIEW: click each un-clicked leaf (in-place reveal) and
405
+ // classify controls. Runs for the target view (collect its content), open-ended
406
+ // tasks (extract everything), or when a targeted walk can't proceed. The planned
407
+ // direction paginator is excluded here so first-touch never overshoots the target
408
+ // (it's only ever applied by the targeted walk in (A) or the open-ended sweep in (C)).
409
+ if (!next) {
410
+ next = approved.find(
411
+ (r) =>
412
+ !doneLeaf.has(r.signature) &&
413
+ !advancing.has(r.signature) &&
414
+ !(navPlan && r.signature === navPlan.directionSig) &&
415
+ !shapeMuted(r) &&
416
+ !sampledOut(r),
417
+ );
418
+ }
419
+
420
+ // (C) No leaves left here.
421
+ if (!next && !navStopped) {
422
+ if (targeting && onTarget) {
423
+ navStopped = true; // reached the target and exhausted it — don't wander past it
424
+ } else if (!targeting) {
425
+ // Open-ended: keep walking via any advancing control not yet tried from here.
426
+ const navCands = approved.filter((r) => {
427
+ const a = advancing.get(r.signature);
428
+ return a && a.uses < ADV_CAP && !a.appliedFrom.has(fp);
429
+ });
430
+ next = navCands[0];
431
+ aiNav = !!next;
432
+ }
433
+ }
434
+
435
+ if (!next) {
436
+ // Nothing actionable in this view. Pull in lazy content with a scroll once;
437
+ // if that yields nothing either, we're done.
438
+ if (!scrolledOut) {
439
+ const grew = await scrollStep(page);
440
+ const added = await capture();
441
+ if (grew || added) {
442
+ if (!grew) scrolledOut = false;
443
+ continue;
444
+ }
445
+ scrolledOut = true;
446
+ continue;
447
+ }
448
+ break;
449
+ }
450
+
451
+ // Approved controls other than `next` — used to detect whether the click
452
+ // REPLACES the current view (they vanish) or reveals IN PLACE (they stay).
453
+ const siblingsBefore = approved.filter((r) => r.signature !== next.signature).map((r) => r.signature);
454
+ actions++;
455
+
456
+ if (next.kind === 'loadmore') {
457
+ // Exhaust: keep clicking the same control until it stops yielding content.
458
+ let tries = 0;
459
+ while (tries++ < 40 && actions < maxActions && !ctx.shouldStop()) {
460
+ const fresh = await perceive(page);
461
+ const same = fresh.revealers.find((r) => r.signature === next.signature);
462
+ if (!same) break;
463
+ const res = await clickRevealer(page, same.id);
464
+ actions++;
465
+ if (res.navigatedTo) {
466
+ navLinks.add(res.navigatedTo);
467
+ break;
468
+ }
469
+ const added = await capture(undefined, 'loadmore', next.top || 0);
470
+ ctx.emit({ type: 'action', url, action: 'click', detail: `load more — ${next.label}` });
471
+ if (!added) break;
472
+ }
473
+ doneLeaf.add(next.signature);
474
+ continue;
475
+ }
476
+
477
+ const res = await clickRevealer(page, next.id);
478
+ if (res.navigatedTo) {
479
+ navLinks.add(res.navigatedTo);
480
+ doneLeaf.add(next.signature);
481
+ ctx.emit({ type: 'action', url, action: 'follow', detail: next.label || res.navigatedTo });
482
+ continue;
483
+ }
484
+
485
+ // The visible variant marker's label. Tabs always carry it; a generic control
486
+ // or dropdown carries it too when its label is short (an app's view switcher —
487
+ // "Analytics", "90D" — not a data row's whole text): if the click adds blocks,
488
+ // the reader must see WHICH state produced them. Expanders/load-more stay
489
+ // unlabelled — their revealed content follows its own heading in the document.
490
+ const label =
491
+ ['tab', 'control', 'dropdown'].includes(next.kind) && next.label && next.label.length <= 32
492
+ ? next.label
493
+ : undefined;
494
+ const provenance = next.label ? `${next.kind}:${next.label}` : next.kind;
495
+ const added = await capture(label, provenance, next.top || 0);
496
+
497
+ // Cross-page chrome futility tally (CONTENT-only — a state change like a theme
498
+ // swap doesn't count): added>0 marks the control PRODUCTIVE forever (never sampled
499
+ // again, precision preserved); an inert chrome click grows its streak toward
500
+ // sampling. Announced once, when a control first crosses into "sampled site-wide".
501
+ if (next.chrome) {
502
+ const ck = revealKey(next);
503
+ if (ck) {
504
+ const rec = inert.get(ck) || { streak: 0, productive: false, announced: false };
505
+ if (added > 0) {
506
+ rec.productive = true;
507
+ rec.streak = 0;
508
+ } else if (!rec.productive) {
509
+ rec.streak++;
510
+ if (rec.streak === INERT_PAGES && !rec.announced) {
511
+ rec.announced = true;
512
+ ctx.emit({
513
+ type: 'action',
514
+ url,
515
+ action: 'skip',
516
+ detail: `sampling chrome control site-wide — added no content on ${INERT_PAGES} pages (${next.label || '(unlabelled)'})`,
517
+ });
518
+ }
519
+ }
520
+ inert.set(ck, rec);
521
+ }
522
+ }
523
+
524
+ // Classify the effect by comparing siblings + state fingerprint before/after.
525
+ const after = await perceive(page);
526
+ const afterSigs = new Set(after.revealers.map((r) => r.signature));
527
+ const retained = siblingsBefore.filter((s) => afterSigs.has(s)).length;
528
+ const replaced = siblingsBefore.length >= 2 && retained <= siblingsBefore.length * 0.5;
529
+ const newState = !visited.has(after.fingerprint);
530
+ // Does the control we just clicked still exist in the new view? This is the
531
+ // general discriminator between the two replace-the-view cases:
532
+ // - a PAGINATOR persists (a "next month" arrow is on every month) → keep
533
+ // re-applying it to walk the whole sequence;
534
+ // - a WIZARD OPTION disappears with its siblings (you picked one day; the day
535
+ // grid was swapped for that day's slots) → restore to try the next sibling.
536
+ const selfPresent = afterSigs.has(next.signature);
537
+
538
+ let kind = 'reveal';
539
+ if (replaced && selfPresent) {
540
+ // VIEW-ADVANCING paginator. Re-applied from each state it reaches (tracked by
541
+ // appliedFrom), never globally retired — that is what walks June→July→August.
542
+ let a = advancing.get(next.signature);
543
+ if (!a) {
544
+ a = { appliedFrom: new Set(), uses: 0 };
545
+ advancing.set(next.signature, a);
546
+ }
547
+ a.appliedFrom.add(fp);
548
+ a.uses++;
549
+ kind = 'advance';
550
+ if (newState) {
551
+ visited.add(after.fingerprint);
552
+ scrolledOut = false; // a fresh view may have its own lazy content
553
+ }
554
+ // Landed on an already-seen state: just move on. The appliedFrom guard stops
555
+ // re-clicking it from this fp; uses < ADV_CAP bounds any prev/next cycle.
556
+ } else if (replaced && !selfPresent) {
557
+ // WIZARD BRANCH: the picked option and its siblings were swapped out. Restore
558
+ // to recover the siblings and let the loop take the next un-clicked one.
559
+ doneLeaf.add(next.signature);
560
+ await restoreBase();
561
+ scrolledOut = false;
562
+ kind = 'branch';
563
+ } else {
564
+ // IN-PLACE reveal (tab/accordion/expander/slot panel): a leaf, done once.
565
+ doneLeaf.add(next.signature);
566
+ if (newState) visited.add(after.fingerprint);
567
+ // Futility bookkeeping: a click with NO effect at all (no new blocks AND
568
+ // the state fingerprint did not move) counts against its shape; any real
569
+ // effect re-arms the whole shape.
570
+ if (!added && after.fingerprint === fp) {
571
+ const sk = shapeKey(next);
572
+ const fails = (shapeFails.get(sk) || 0) + 1;
573
+ shapeFails.set(sk, fails);
574
+ if (fails === SHAPE_DEAD) {
575
+ const muted = perception.revealers.filter((r) => !doneLeaf.has(r.signature) && decided.get(r.signature) && shapeKey(r) === sk).length;
576
+ if (muted > 0) {
577
+ ctx.emit({
578
+ type: 'action',
579
+ url,
580
+ action: 'skip',
581
+ detail: `muting ${muted} look-alike control(s) — ${SHAPE_DEAD} identical clicks had no effect (e.g. ${next.label || '(unlabelled)'})`,
582
+ });
583
+ }
584
+ }
585
+ } else {
586
+ shapeFails.set(shapeKey(next), 0);
587
+ }
588
+ // #21c — BEHAVIOURAL load-more: a control that just ADDED content, still
589
+ // exists, and GREW the page (append — a tab swap keeps the height) behaves
590
+ // like "load more" whatever language its label is in. Keep re-clicking it
591
+ // until it stops yielding: the BlockAccumulator dedup makes re-shown
592
+ // content add 0 blocks, so an open/close toggle (accordion) stops after
593
+ // one probe click — the accepted ~1s price for never missing an
594
+ // incremental loader with a non-English label. Bounded by the action
595
+ // budget. The English LOADMORE label survives only as the fast path
596
+ // (kind 'loadmore' exhausts immediately, no growth evidence needed).
597
+ if (added && selfPresent && (after.scrollHeight || 0) > (perception.scrollHeight || 0) + 120) {
598
+ let tries = 0;
599
+ while (tries++ < 40 && actions < maxActions && !ctx.shouldStop()) {
600
+ const fresh = await perceive(page);
601
+ const same = fresh.revealers.find((r) => r.signature === next.signature);
602
+ if (!same) break;
603
+ const res2 = await clickRevealer(page, same.id);
604
+ actions++;
605
+ if (res2.navigatedTo) {
606
+ navLinks.add(res2.navigatedTo);
607
+ break;
608
+ }
609
+ const more = await capture(label, provenance, next.top || 0);
610
+ if (!more) break;
611
+ ctx.emit({ type: 'action', url, action: 'click', detail: `load more (measured): ${next.label || '(unlabelled)'} (+${more})` });
612
+ }
613
+ }
614
+ // STICKY-SELECTION RESTORE. Some views make a pick "stick": selecting one item
615
+ // changes the view's state and then the SIBLINGS stop responding until the view
616
+ // is re-rendered (this calendar — once day 2's slots open, clicking day 3/6/7…
617
+ // does nothing, so only day 2 would ever be captured). When a click both reveals
618
+ // content AND moves the state, and its group still has un-clicked members,
619
+ // restore the base view so the next sibling is selected on a fresh render; the
620
+ // loop re-navigates back and takes it. The group test masks digits, so it only
621
+ // fires for NUMBERED groups (calendar days, "Page 1/2/3", numbered items) that
622
+ // are prone to this — tabs/accordions with distinct names share no pattern and
623
+ // are left fast and reload-free.
624
+ const groupKey = (r) => `${r.role}|${r.kind}|${String(r.label || '').replace(/\d+/g, '#')}`;
625
+ const want = groupKey(next);
626
+ const groupHasMore = perception.revealers.some(
627
+ (r) => r.signature !== next.signature && decided.get(r.signature) && !doneLeaf.has(r.signature) && groupKey(r) === want,
628
+ );
629
+ if (added && after.fingerprint !== fp && groupHasMore) {
630
+ await restoreBase();
631
+ scrolledOut = false;
632
+ }
633
+ }
634
+
635
+ // SIBLING SWEEP — a control that revealed content proves its whole repeated
636
+ // GROUP is content: a calendar's days, a list of expanders, "Page 1 / 2 / 3"…
637
+ // The AI triage typically approves only a few representative items of such a
638
+ // group, so the rest are never captured. Read the group from the PRE-click
639
+ // perception (a branch's click REPLACES the group with the revealed view, so it
640
+ // is gone from `after`) and auto-approve every same-pattern sibling (same
641
+ // role+kind, label identical once its digits are masked). The loop then sweeps
642
+ // the ENTIRE group — works for in-place reveals AND wizard branches (each click
643
+ // restores, then the next approved sibling is taken). A general DOM/label-shape
644
+ // signal, no per-site rules; gated on real content and bounded by maxActions.
645
+ if (added) {
646
+ const groupKey = (r) => `${r.role}|${r.kind}|${String(r.label || '').replace(/\d+/g, '#')}`;
647
+ const want = groupKey(next);
648
+ for (const r of perception.revealers) {
649
+ if (groupKey(r) === want && !decided.get(r.signature)) decided.set(r.signature, true);
650
+ }
651
+ }
652
+
653
+ // Terminate the TARGETED WALK so the planned direction control can't be clicked
654
+ // forever. A real walk makes progress every step (each next-month is a NEW state),
655
+ // so progress resets the stall; a mis-planned direction that just re-opens the
656
+ // same view (or never reaches its target) makes no progress and is stopped fast,
657
+ // and NAV_CAP is a hard backstop regardless. This is what kept "Apri servizi" from
658
+ // burning all 60 actions on the booking page.
659
+ if (aiNav && navPlan && next.signature === navPlan.directionSig) {
660
+ navUses++;
661
+ navStall = newState || added ? 0 : navStall + 1;
662
+ if (navUses >= NAV_CAP || navStall >= 2) navStopped = true;
663
+ } else if (added) {
664
+ // A productive non-navigation click (e.g. a calendar day's slots captured):
665
+ // the reveal is still bearing fruit, so reset the targeted walk's give-up
666
+ // counters. Some views bounce you off the grid when you pick an item (the day
667
+ // grid is swapped for that day's slots), forcing a re-navigation back to it;
668
+ // that return revisits a SEEN state and must NOT be mistaken for a stall, or
669
+ // the sweep abandons the remaining items (days 8,9,10 …). The runaway guard
670
+ // still fires when navigation yields neither new state nor new content.
671
+ navStall = 0;
672
+ navUses = 0;
673
+ }
674
+
675
+ ctx.emit({
676
+ type: 'action',
677
+ url,
678
+ action: next.kind === 'tab' ? 'click' : next.kind === 'expander' ? 'expand' : 'click',
679
+ detail: `${aiNav ? 'navigate' : kind === 'advance' ? 'advance' : next.kind}: ${next.label || '(unlabelled)'}${added ? ` (+${added})` : ''}`,
680
+ // The STATE this action lands on (perception fingerprint). The UI keys
681
+ // view nodes by this, so the same control reaching different states
682
+ // (next-page → p1, p2 …; next-month → July, August) yields distinct nodes,
683
+ // while re-reaching a state collapses onto its existing node.
684
+ state: after.fingerprint,
685
+ });
686
+ }
687
+
688
+ // Final sweep of links/routes from the last state.
689
+ for (const l of lastPerception.links) if (!allLinks.has(l.href)) allLinks.set(l.href, l.label);
690
+ for (const r of lastPerception.routes) allRoutes.add(r);
691
+
692
+ // #21d — THE EXIT AUDIT. How much text is still hidden in the main content
693
+ // when the loop ends? residual ≈ 0 is a MEASURED completeness statement for
694
+ // this page's DOM (content that only exists after an un-clicked AJAX call is
695
+ // invisible to any static measure — that is what the click walk above is
696
+ // for). A large residual becomes a per-page, machine-readable number in
697
+ // page.meta / scan stats plus this warning — advisory, never blocking:
698
+ // skeleton/placeholder boilerplate can false-positive, and a warning that
699
+ // stopped the crawl would be worse than the gap it reports.
700
+ // #9 — TRUTHFUL residual (truthfulResidual): the audit counts every element hidden
701
+ // in the FINAL state, but a mutually-exclusive panel captured when it was open is a
702
+ // known false-positive, so blocks whose text is already in the output are subtracted.
703
+ let markdown = acc.toMarkdown();
704
+ let hiddenResidualChars = truthfulResidual(
705
+ lastPerception.hiddenResidualChars || 0,
706
+ lastPerception.hiddenTexts || [],
707
+ markdown,
708
+ );
709
+
710
+ // #9 Phase 1 — A11Y FALLBACK on a HIGH REAL residual. The strict perception drops
711
+ // interactive elements with no label and no aria-controls (a bare role=tab, a
712
+ // listener-only div, a hover-triggered toggle). On the RARE page where real text is
713
+ // still hidden after the loop (residual high AFTER subtracting captured states) AND
714
+ // the action budget was NOT the limit (hitCap ⇒ a --max-actions problem, not a
715
+ // detection one), those unlabelled controls are the prime suspect. Re-perceive with
716
+ // the label gate relaxed (a11y role / listener as a second element source — no vision
717
+ // call), triage + click each NEW candidate once, and re-capture. Deterministic and
718
+ // no-AI-safe (triage approves every mechanical candidate with no model), ADDITIVE
719
+ // (rule #1 — it can only add content), bounded by RELAX_CAP and the budget. Runs only
720
+ // here, so normal pages (low residual) never pay for it. Honest limit: it treats
721
+ // these as in-place reveals (their usual shape) — a relaxed paginator is not walked;
722
+ // that stays the vision half of #9, deliberately deferred (tiny, rare target).
723
+ const RELAX_CAP = 8;
724
+ if (!hitCap && actions < maxActions && !ctx.shouldStop() && hiddenResidualChars >= RESIDUAL_WARN_CHARS) {
725
+ let clicked = 0;
726
+ for (let tries = 0; tries < RELAX_CAP && actions < maxActions && !ctx.shouldStop() && hiddenResidualChars >= RESIDUAL_WARN_CHARS; tries++) {
727
+ const relaxedPerception = await perceive(page, { relaxLabels: true });
728
+ lastPerception = relaxedPerception; // the final residual/warning reflects this state
729
+ const fresh = relaxedPerception.revealers.filter((r) => r.relaxed);
730
+ if (!fresh.length) break;
731
+ await triage(fresh);
732
+ const next = fresh
733
+ .filter((r) => decided.get(r.signature) && !doneLeaf.has(r.signature) && !shapeMuted(r))
734
+ .sort((a, b) => revealPriority(b) - revealPriority(a))[0];
735
+ if (!next) break;
736
+ actions++;
737
+ const res = await clickRevealer(page, next.id);
738
+ doneLeaf.add(next.signature);
739
+ if (res.navigatedTo) {
740
+ navLinks.add(res.navigatedTo); // a nav-away is a link, captured by the frontier
741
+ break;
742
+ }
743
+ const label = next.label && next.label.length <= 32 ? next.label : undefined;
744
+ const added = await capture(label, `${next.kind}:${next.label || 'a11y'}`, next.top || 0);
745
+ if (added) {
746
+ clicked++;
747
+ shapeFails.set(shapeKey(next), 0);
748
+ } else {
749
+ shapeFails.set(shapeKey(next), (shapeFails.get(shapeKey(next)) || 0) + 1);
750
+ }
751
+ markdown = acc.toMarkdown();
752
+ hiddenResidualChars = truthfulResidual(
753
+ relaxedPerception.hiddenResidualChars || 0,
754
+ relaxedPerception.hiddenTexts || [],
755
+ markdown,
756
+ );
757
+ }
758
+ if (clicked) {
759
+ ctx.emit({
760
+ type: 'action',
761
+ url,
762
+ action: 'reveal',
763
+ detail: `a11y fallback: revealed ${clicked} control(s) behind unlabelled triggers (high residual)`,
764
+ });
765
+ }
766
+ }
767
+
768
+ if (hiddenResidualChars >= RESIDUAL_WARN_CHARS) {
769
+ const words = Math.round(hiddenResidualChars / 6);
770
+ ctx.emit({
771
+ type: 'warn',
772
+ url,
773
+ reason: 'reveal-residual',
774
+ message:
775
+ `~${words} words of text remain hidden ` +
776
+ (hitCap
777
+ ? 'behind controls the action budget did not reach — raise --max-actions for full coverage.'
778
+ : 'behind elements no detected control reveals (measured, not judged).'),
779
+ });
780
+ }
781
+
782
+ const links = [...allLinks.entries()].map(([href, label]) => ({ href, label }));
783
+ return {
784
+ markdown,
785
+ blocks: acc.toBlocks(), // raw { text, provenance } in capture order, for layout
786
+ // The FAITHFUL per-state record: every DISTINCT captured state, whole and
787
+ // verbatim (byte-identical repeats — a chrome click that changed no content —
788
+ // collapsed by states()). The consolidated markdown is compact (shared frame
789
+ // once); this keeps each state's full co-occurrence recoverable, so a partial
790
+ // change never loses its structure.
791
+ states: acc.states(),
792
+ title,
793
+ links,
794
+ navLinks: [...navLinks],
795
+ routes: [...allRoutes],
796
+ hitCap,
797
+ hiddenResidualChars,
798
+ };
799
+ }