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