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,647 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Bogdan Marian Vasaiu
|
|
3
|
+
// Behaviour-aware perception. Casts a wide net for anything that could hide
|
|
4
|
+
// content (tabs/accordions/"load more"/JS widgets). It scans the MAIN CONTENT
|
|
5
|
+
// first, then the site chrome (nav/header/footer) for its JS view-switchers — an
|
|
6
|
+
// SPA nav or app rail that swaps the view WITHOUT a URL — so no clickable that
|
|
7
|
+
// surfaces content is ever missed; the main content still gets the budget first
|
|
8
|
+
// (a chrome penalty in revealPriority). Plain <a href> nav stays a LINK (crawled
|
|
9
|
+
// as its own page), never an in-page revealer. Also surfaces in-content links,
|
|
10
|
+
// route candidates mined from page scripts, and one-time consent dismissals.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
export async function perceive(page, { maxText = 2500, maxRevealers = 150, maxLinks = 400, relaxLabels = false } = {}) {
|
|
15
|
+
const data = await page.evaluate(
|
|
16
|
+
({ maxRevealers, maxLinks, relaxLabels }) => {
|
|
17
|
+
// Clear the markers left by PREVIOUS perceive passes first. Ids restart from 0
|
|
18
|
+
// on every pass; without this, an element stamped in an earlier pass keeps its
|
|
19
|
+
// stale id while a different element gets the same number this pass — and the
|
|
20
|
+
// actuator's [data-crawldna-id="N"] locator (.first()) can then click the
|
|
21
|
+
// WRONG element. One wrong click corrupts the whole reveal walk.
|
|
22
|
+
for (const el of document.querySelectorAll('[data-crawldna-id]')) {
|
|
23
|
+
el.removeAttribute('data-crawldna-id');
|
|
24
|
+
}
|
|
25
|
+
const isVisible = (el) => {
|
|
26
|
+
const r = el.getBoundingClientRect();
|
|
27
|
+
if (r.width <= 1 || r.height <= 1) return false;
|
|
28
|
+
const s = getComputedStyle(el);
|
|
29
|
+
return s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0';
|
|
30
|
+
};
|
|
31
|
+
const labelOf = (el) =>
|
|
32
|
+
(
|
|
33
|
+
el.getAttribute('aria-label') ||
|
|
34
|
+
(el.innerText || '').trim() ||
|
|
35
|
+
el.value ||
|
|
36
|
+
el.getAttribute('title') ||
|
|
37
|
+
el.getAttribute('placeholder') ||
|
|
38
|
+
el.getAttribute('data-title') ||
|
|
39
|
+
''
|
|
40
|
+
)
|
|
41
|
+
.replace(/\s+/g, ' ')
|
|
42
|
+
.trim()
|
|
43
|
+
.slice(0, 100);
|
|
44
|
+
|
|
45
|
+
// Nearest preceding heading — gives the AI human-like context for judging a
|
|
46
|
+
// control ("this button sits under 'Installation'"). Bounded walk.
|
|
47
|
+
const nearestHeading = (el) => {
|
|
48
|
+
let n = el;
|
|
49
|
+
for (let hops = 0; n && hops < 6; hops++, n = n.parentElement) {
|
|
50
|
+
let p = n.previousElementSibling;
|
|
51
|
+
let scans = 0;
|
|
52
|
+
while (p && scans++ < 10) {
|
|
53
|
+
if (/^H[1-6]$/.test(p.tagName)) return (p.innerText || '').trim().slice(0, 80);
|
|
54
|
+
const h = p.querySelector && p.querySelector('h1,h2,h3,h4,h5,h6');
|
|
55
|
+
if (h) return (h.innerText || '').trim().slice(0, 80);
|
|
56
|
+
p = p.previousElementSibling;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ---- pick the main content container (densest text) ------------------
|
|
63
|
+
const CONTENT_CANDIDATES = [
|
|
64
|
+
'main article', 'article', 'main', '[role=main]',
|
|
65
|
+
'.devsite-article-body', '.markdown', '.markdown-body', '.content',
|
|
66
|
+
'.doc-content', '#content', '.article-body',
|
|
67
|
+
];
|
|
68
|
+
let mainEl = document.body;
|
|
69
|
+
let bestLen = (document.body.innerText || '').length * 0.4; // bias toward a real container
|
|
70
|
+
for (const sel of CONTENT_CANDIDATES) {
|
|
71
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
72
|
+
const len = (el.innerText || '').length;
|
|
73
|
+
if (len > bestLen) {
|
|
74
|
+
bestLen = len;
|
|
75
|
+
mainEl = el;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// "Site chrome" (global nav/header/footer) is detected from SEMANTIC LANDMARKS
|
|
81
|
+
// only — the <nav>/<header>/<footer>/<aside> tags and ARIA landmark roles —
|
|
82
|
+
// because those are universal, spec-defined signals. We deliberately do NOT
|
|
83
|
+
// classify chrome from class-name SUBSTRINGS: a generic word like "header",
|
|
84
|
+
// "toolbar", "menu" or "tab" routinely appears inside a CONTENT widget's
|
|
85
|
+
// markup (e.g. a calendar's prev/next arrows live in `.monthly-header`, an
|
|
86
|
+
// editor has a `.toolbar`), and a substring filter there silently hides real,
|
|
87
|
+
// clickable content controls from the AI before it can judge them. Only a few
|
|
88
|
+
// unambiguous, never-in-content chrome words are matched on class as a final
|
|
89
|
+
// backstop. Everything else is surfaced and the AI decides (it already rejects
|
|
90
|
+
// plain navigation). This keeps perception universal — no structure guessing.
|
|
91
|
+
const CHROME_TAGS = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']);
|
|
92
|
+
const CHROME_RE = /(^|\s|[-_])(navbar|sidebar|breadcrumb|masthead|cookie|consent)(\s|[-_]|$)/i;
|
|
93
|
+
const isChrome = (el) => {
|
|
94
|
+
let n = el;
|
|
95
|
+
while (n && n !== document.body) {
|
|
96
|
+
if (CHROME_TAGS.has(n.tagName)) return true;
|
|
97
|
+
const role = n.getAttribute && n.getAttribute('role');
|
|
98
|
+
if (role && /navigation|banner|contentinfo/.test(role)) return true;
|
|
99
|
+
const cls = (n.getAttribute && n.getAttribute('class')) || '';
|
|
100
|
+
if (CHROME_RE.test(cls)) return true;
|
|
101
|
+
n = n.parentElement;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const LOADMORE = /load\s*more|show\s*more|view\s*more|see\s*more|load\s*all|show\s*all|view\s*all|read\s*more|see\s*all|expand all/i;
|
|
107
|
+
// `tab(?!le)` on purpose: a bare /tab/ also matches "table"/"tables" (Bootstrap's
|
|
108
|
+
// `.table`, `.table-responsive`, data grids …), flooding the candidate list with
|
|
109
|
+
// every styled table on the page — and the list is CAPPED (maxRevealers), so the
|
|
110
|
+
// noise can crowd real tabs/accordions out of the AI triage entirely.
|
|
111
|
+
const INTERACTIVE_CLASS = /(tab(?!le)|accordion|toggle|expand|collaps|dropdown|selector|switch|segment|pill|chip|disclosure|details|reveal)/i;
|
|
112
|
+
// Article-toolbar actions that never reveal content (generic doc-UI verbs).
|
|
113
|
+
const NON_CONTENT = /^(copy|copied|copy code|copy page|share|print|edit|edit this|report|feedback|send feedback|bookmark|explain|explain this|dark code|light code|theme|download|like|dislike|rate|thumb|subscribe|run in|try it|open in)\b/i;
|
|
114
|
+
|
|
115
|
+
const candidateSel = [
|
|
116
|
+
'button', 'summary', 'details', 'select',
|
|
117
|
+
'[role=button]', '[role=tab]', '[role=menuitemradio]', '[role=switch]',
|
|
118
|
+
'[role=option]', '[role=checkbox]', '[role=combobox]',
|
|
119
|
+
'[aria-expanded]', '[aria-controls]', '[aria-selected]', '[aria-pressed]',
|
|
120
|
+
'[onclick]', '[data-crawldna-listener]',
|
|
121
|
+
'a[href^="#"]', 'a:not([href])',
|
|
122
|
+
'[class*=tab]', '[class*=accordion]', '[class*=toggle]', '[class*=expand]',
|
|
123
|
+
'[class*=collaps]', '[class*=disclosure]', '[class*=segment]',
|
|
124
|
+
].join(',');
|
|
125
|
+
|
|
126
|
+
const considered = new Set();
|
|
127
|
+
const revealers = [];
|
|
128
|
+
let rid = 0;
|
|
129
|
+
|
|
130
|
+
// ---- consent / overlay dismissals (page-wide, one-time) -------------
|
|
131
|
+
// #21a — this block only MEASURES; the decision (which button to click,
|
|
132
|
+
// reject-first, multilingual lexicon, primary-by-geometry) lives in
|
|
133
|
+
// engine/consent.mjs where it is pure and unit-tested. A candidate must
|
|
134
|
+
// live in a real OVERLAY (position:fixed/sticky, a dialog role, or
|
|
135
|
+
// aria-modal): generic labels ("Continue", "OK", "Close") also caption
|
|
136
|
+
// wizard steps and forms in the page flow, and clicking those BEFORE the
|
|
137
|
+
// baseline capture mutates the very state the reveal pass is about to
|
|
138
|
+
// explore. Banners are essentially always fixed or modal, so the overlay
|
|
139
|
+
// test is the universal signal that separates them from content controls.
|
|
140
|
+
const overlayRootOf = (el) => {
|
|
141
|
+
let n = el;
|
|
142
|
+
while (n && n !== document.documentElement) {
|
|
143
|
+
const role = (n.getAttribute && n.getAttribute('role')) || '';
|
|
144
|
+
if (/^(dialog|alertdialog)$/i.test(role)) return n;
|
|
145
|
+
if (n.getAttribute && n.getAttribute('aria-modal') === 'true') return n;
|
|
146
|
+
const pos = getComputedStyle(n).position;
|
|
147
|
+
if (pos === 'fixed' || pos === 'sticky') return n;
|
|
148
|
+
n = n.parentElement;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
const consentCandidates = [];
|
|
153
|
+
const overlayTexts = new Map(); // overlay element -> its text sample (computed once)
|
|
154
|
+
for (const el of document.querySelectorAll('button, [role=button], input[type=button], input[type=submit], a')) {
|
|
155
|
+
if (consentCandidates.length >= 24) break;
|
|
156
|
+
if (!isVisible(el)) continue;
|
|
157
|
+
const label = labelOf(el);
|
|
158
|
+
if (!label || label.length >= 60) continue;
|
|
159
|
+
const overlay = overlayRootOf(el);
|
|
160
|
+
if (!overlay) continue;
|
|
161
|
+
let overlayText = overlayTexts.get(overlay);
|
|
162
|
+
if (overlayText === undefined) {
|
|
163
|
+
overlayText = ((overlay.innerText || '').replace(/\s+/g, ' ').trim()).slice(0, 400);
|
|
164
|
+
overlayTexts.set(overlay, overlayText);
|
|
165
|
+
}
|
|
166
|
+
const r = el.getBoundingClientRect();
|
|
167
|
+
el.setAttribute('data-crawldna-id', String(rid));
|
|
168
|
+
consentCandidates.push({
|
|
169
|
+
id: rid,
|
|
170
|
+
label,
|
|
171
|
+
area: Math.round(r.width * r.height),
|
|
172
|
+
overlayText,
|
|
173
|
+
href: el.tagName === 'A' ? el.getAttribute('href') || '' : '',
|
|
174
|
+
});
|
|
175
|
+
rid++;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- revealers: main content FIRST, then the rest of the page -------
|
|
179
|
+
// Two lists over the SAME gauntlet, concatenated main-first. Pass 1 (main)
|
|
180
|
+
// therefore wins the candidate cap (maxRevealers) and — via the chrome
|
|
181
|
+
// penalty in revealPriority — the ACTION budget. Pass 2 (the whole body)
|
|
182
|
+
// sweeps site chrome (nav/header/footer) for its JS view-switchers: an SPA
|
|
183
|
+
// top-nav or app rail whose buttons swap the main view without a URL used to
|
|
184
|
+
// be dropped entirely (it lives outside `mainEl` AND trips isChrome), so
|
|
185
|
+
// every non-default view was silently lost (rule #1 — "prima mancava il
|
|
186
|
+
// nav"). `considered` dedups the overlap, so main is processed once, first.
|
|
187
|
+
const realMain = mainEl !== document.body;
|
|
188
|
+
const candidates = realMain
|
|
189
|
+
? [...mainEl.querySelectorAll(candidateSel), ...document.body.querySelectorAll(candidateSel)]
|
|
190
|
+
: [...document.body.querySelectorAll(candidateSel)];
|
|
191
|
+
for (const el of candidates) {
|
|
192
|
+
if (revealers.length >= maxRevealers) break;
|
|
193
|
+
if (considered.has(el)) continue;
|
|
194
|
+
considered.add(el);
|
|
195
|
+
if (!isVisible(el)) continue;
|
|
196
|
+
|
|
197
|
+
const tag = el.tagName.toLowerCase();
|
|
198
|
+
const role = el.getAttribute('role') || tag;
|
|
199
|
+
const label = labelOf(el);
|
|
200
|
+
const cls = el.getAttribute('class') || '';
|
|
201
|
+
const style = getComputedStyle(el);
|
|
202
|
+
const href = el.getAttribute('href') || '';
|
|
203
|
+
const hasListener = el.hasAttribute('data-crawldna-listener') || el.hasAttribute('onclick');
|
|
204
|
+
const ariaExpanded = el.getAttribute('aria-expanded');
|
|
205
|
+
const ariaControls = el.getAttribute('aria-controls') || '';
|
|
206
|
+
const ariaSelected = el.getAttribute('aria-selected');
|
|
207
|
+
const ariaPressed = el.getAttribute('aria-pressed');
|
|
208
|
+
|
|
209
|
+
// A JS CONTROL: a sniffed listener or an interactive tag/role — never a
|
|
210
|
+
// plain <a> (that stays a link for the frontier gate). This is what may
|
|
211
|
+
// survive the chrome check below, wherever on the page it lives.
|
|
212
|
+
const jsControl =
|
|
213
|
+
tag !== 'a' &&
|
|
214
|
+
(hasListener ||
|
|
215
|
+
['button', 'summary', 'select', 'details'].includes(tag) ||
|
|
216
|
+
['button', 'tab', 'switch', 'option', 'checkbox', 'combobox', 'menuitemradio'].includes(role));
|
|
217
|
+
|
|
218
|
+
// SITE CHROME. isChrome walks to <body>; an element NESTED in a REAL main
|
|
219
|
+
// container sits under a landmark that is really app content (an embedded
|
|
220
|
+
// app's own rail — #25), NOT site chrome. TRUE site chrome is what lies
|
|
221
|
+
// OUTSIDE the real main (or the whole page under the <body> fallback). We
|
|
222
|
+
// no longer drop it wholesale: a JS control (an SPA top-nav / app rail that
|
|
223
|
+
// swaps the view — "prima mancava il nav") or a MEASURED disclosure (a
|
|
224
|
+
// footer <details> FAQ) is kept and tagged `chrome`, so revealPriority
|
|
225
|
+
// spends the action budget on content first while nothing clickable that
|
|
226
|
+
// surfaces content is missed. main-internal keeps its exact #25 rule (a JS
|
|
227
|
+
// control only). Works with no model at all — the closed-loop guards
|
|
228
|
+
// (shape-muting, measured ordering) bound any chrome noise.
|
|
229
|
+
const nestedInMain = realMain && mainEl.contains(el);
|
|
230
|
+
const chrome = isChrome(el) && !nestedInMain;
|
|
231
|
+
if (isChrome(el)) {
|
|
232
|
+
const keep = nestedInMain ? jsControl : jsControl || ariaExpanded != null || !!ariaControls;
|
|
233
|
+
if (!keep) continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Skip DISABLED controls — clicking them reveals nothing, so they only burn
|
|
237
|
+
// the action budget (e.g. a calendar's non-selectable days, a greyed-out
|
|
238
|
+
// "next" at the last page). Universal UX signals only — not site structure:
|
|
239
|
+
// the disabled property, aria-disabled, a disabled/inactive/not-selectable
|
|
240
|
+
// class token, or a not-allowed cursor / pointer-events:none on the element.
|
|
241
|
+
const ariaDisabled = (el.getAttribute('aria-disabled') || '').toLowerCase() === 'true';
|
|
242
|
+
const classDisabled = /(^|[\s_-])(disabled|inactive|not[-_]?selectable)([\s_-]|$)/i.test(cls);
|
|
243
|
+
if (el.disabled === true || ariaDisabled || classDisabled || style.cursor === 'not-allowed' || style.pointerEvents === 'none') {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Any <a> with a real destination is a NAVIGATION candidate — it's
|
|
248
|
+
// discovered as a link and the AI decides whether it's a real page.
|
|
249
|
+
// It's never treated as an in-page revealer. Only href-less <a> and a
|
|
250
|
+
// bare "#" stay as possible JS toggles. No URL-shape assumptions here.
|
|
251
|
+
const hasNavHref = tag === 'a' && href && href !== '#' && !/^javascript:/i.test(href);
|
|
252
|
+
if (hasNavHref) continue;
|
|
253
|
+
|
|
254
|
+
const pointer = style.cursor === 'pointer';
|
|
255
|
+
const interactiveClass = INTERACTIVE_CLASS.test(cls);
|
|
256
|
+
|
|
257
|
+
const interactive =
|
|
258
|
+
['button', 'summary', 'details', 'select'].includes(tag) ||
|
|
259
|
+
['button', 'tab', 'switch', 'option', 'checkbox', 'combobox', 'menuitemradio'].includes(role) ||
|
|
260
|
+
ariaExpanded != null || ariaControls || ariaSelected != null || ariaPressed != null ||
|
|
261
|
+
hasListener || pointer || interactiveClass;
|
|
262
|
+
if (!interactive) continue;
|
|
263
|
+
// #9 Phase 1 — the strict pass DROPS an interactive element that has no
|
|
264
|
+
// label and no aria-controls (a bare role=tab, a listener-only div, a
|
|
265
|
+
// hover-triggered toggle). Almost always redundant, so they stay out by
|
|
266
|
+
// default — that keeps normal pages lean. When the CALLER escalates on a
|
|
267
|
+
// HIGH REAL residual (`relaxLabels`, the reveal loop's last-ditch fallback),
|
|
268
|
+
// admit those that still carry a MECHANICAL accessibility signal — an
|
|
269
|
+
// interactive ARIA/native role, a sniffed listener, or an aria-*
|
|
270
|
+
// expanded/pressed/selected state (the accessibility-tree signal, read off
|
|
271
|
+
// the DOM, no extra AX snapshot / vision call). A weak signal alone (pointer
|
|
272
|
+
// cursor or a class-name match) is NOT enough here: it would flood the
|
|
273
|
+
// fallback with decorative rows. Admitted candidates are tagged `relaxed` so
|
|
274
|
+
// the loop clicks only the genuinely-new ones.
|
|
275
|
+
let relaxed = false;
|
|
276
|
+
if (!label && !ariaControls && tag !== 'details' && tag !== 'summary') {
|
|
277
|
+
const a11ySignal =
|
|
278
|
+
['button', 'summary', 'select'].includes(tag) ||
|
|
279
|
+
['button', 'tab', 'switch', 'option', 'checkbox', 'combobox', 'menuitemradio'].includes(role) ||
|
|
280
|
+
ariaExpanded != null || ariaSelected != null || ariaPressed != null ||
|
|
281
|
+
hasListener;
|
|
282
|
+
if (!(relaxLabels && a11ySignal)) continue;
|
|
283
|
+
relaxed = true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let kind = 'control';
|
|
287
|
+
if (LOADMORE.test(label)) kind = 'loadmore';
|
|
288
|
+
else if (tag === 'select' || role === 'combobox' || role === 'listbox' || /(^|\s|[-_])(dropdown|combobox|listbox|select)(\s|[-_]|$)/i.test(cls)) kind = 'dropdown';
|
|
289
|
+
else if (role === 'tab' || ariaSelected != null || ariaPressed != null || /(^|\s|-)tab(\s|-|$)/i.test(cls)) kind = 'tab';
|
|
290
|
+
else if (ariaExpanded != null || ariaControls || tag === 'summary' || tag === 'details' || /accordion|collaps|expand|disclosure/i.test(cls)) kind = 'expander';
|
|
291
|
+
|
|
292
|
+
// Pure UI actions (copy/share/print/feedback/…) never reveal content —
|
|
293
|
+
// drop them deterministically so neither the AI nor the fallback wastes a
|
|
294
|
+
// click. This is universal (not per-site), so it stays a hard filter.
|
|
295
|
+
if (NON_CONTENT.test(label)) continue;
|
|
296
|
+
|
|
297
|
+
// Heuristic guess "this likely reveals content" — since #21b no longer a
|
|
298
|
+
// gate (no-AI approves every candidate), only an ORDERING hint plus extra
|
|
299
|
+
// context for the model. English-biased by nature, which is exactly why
|
|
300
|
+
// it stopped being load-bearing.
|
|
301
|
+
const DISCLOSURE_LABEL =
|
|
302
|
+
/\b(show|view|see|load|read|expand)\b[^.]{0,20}\b(more|all|less|api|details?|code|example|source|reference)\b|toggle|inline api|reveal|full (?:api|list)/i;
|
|
303
|
+
const heuristic = kind !== 'control' || DISCLOSURE_LABEL.test(label);
|
|
304
|
+
|
|
305
|
+
// #21b — MEASURE the hidden payload behind this control instead of
|
|
306
|
+
// guessing from words: the text mass of its aria-controls target when
|
|
307
|
+
// that target is currently invisible, else of a hidden sibling panel
|
|
308
|
+
// (accordion body next to its header), else the unopened remainder of a
|
|
309
|
+
// <details>. A measured payload is mechanical proof the control hides
|
|
310
|
+
// content — it later overrides a wrong "no" from any judge (AI or
|
|
311
|
+
// heuristic), and ranks candidates when there is no judge at all.
|
|
312
|
+
let hiddenPayload = 0;
|
|
313
|
+
if (ariaControls) {
|
|
314
|
+
for (const tid of ariaControls.split(/\s+/)) {
|
|
315
|
+
const t = document.getElementById(tid);
|
|
316
|
+
if (t && !isVisible(t)) hiddenPayload += ((t.textContent || '').replace(/\s+/g, ' ').trim()).length;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!hiddenPayload) {
|
|
320
|
+
const sib = el.nextElementSibling;
|
|
321
|
+
if (sib && !isVisible(sib)) hiddenPayload += ((sib.textContent || '').replace(/\s+/g, ' ').trim()).length;
|
|
322
|
+
}
|
|
323
|
+
if (!hiddenPayload && tag === 'summary' && el.parentElement && el.parentElement.tagName === 'DETAILS' && !el.parentElement.open) {
|
|
324
|
+
const whole = ((el.parentElement.textContent || '').replace(/\s+/g, ' ').trim()).length;
|
|
325
|
+
const own = ((el.textContent || '').replace(/\s+/g, ' ').trim()).length;
|
|
326
|
+
hiddenPayload += Math.max(0, whole - own);
|
|
327
|
+
}
|
|
328
|
+
const expanded = ariaExpanded; // raw aria-expanded value ('false' = closed disclosure)
|
|
329
|
+
|
|
330
|
+
const signature = `${role}|${label}|${ariaControls}|${kind}`;
|
|
331
|
+
// REUSE an id already stamped by the consent block above. Now that pass 2
|
|
332
|
+
// scans the whole page, a sticky-header / banner button can be BOTH a
|
|
333
|
+
// consentCandidate and a revealer; overwriting its id with a fresh rid
|
|
334
|
+
// would strand the consentCandidate reference and break dismissal. Consent
|
|
335
|
+
// ids [0,k) and revealer ids [k,…) share one counter and never collide, so
|
|
336
|
+
// a reused id stays unique. Unmarked → take (and stamp) the next fresh id.
|
|
337
|
+
const marked = el.getAttribute('data-crawldna-id');
|
|
338
|
+
const cid = marked != null ? Number(marked) : rid++;
|
|
339
|
+
if (marked == null) el.setAttribute('data-crawldna-id', String(cid));
|
|
340
|
+
// #27 — the control's ABSOLUTE vertical position in the page. It orders the
|
|
341
|
+
// reveal states in the output by REPRESENTATION (proximity to the base): an
|
|
342
|
+
// app's nav rail runs Dashboard→Analytics→Chat→Settings top-to-bottom, so
|
|
343
|
+
// the view each item opens lands in that order — not in the order the engine
|
|
344
|
+
// happened to click them. Horizontal tab strips share a `top`, so they keep
|
|
345
|
+
// their left-to-right discovery order (stable). Read once, here.
|
|
346
|
+
const rct = el.getBoundingClientRect();
|
|
347
|
+
const top = Math.round(rct.top + (window.scrollY || window.pageYOffset || 0));
|
|
348
|
+
revealers.push({ id: cid, kind, label, role, cls: cls.slice(0, 60), context: nearestHeading(el), heuristic, signature, hiddenPayload, expanded, top, chrome, relaxed });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---- links: every destination on the page (nav/footer/app-bar included)
|
|
352
|
+
// Surface ALL of them with labels — real URLs, fragment routes (#/contact),
|
|
353
|
+
// query routes (?view=pricing), same-page anchors, anything. The algorithm
|
|
354
|
+
// makes NO judgement about URL shape; the frontier scopes them and the AI
|
|
355
|
+
// gate decides which are real pages worth following (vs same-page anchors
|
|
356
|
+
// or off-task links). This is what lets non-obvious / framework-specific
|
|
357
|
+
// navigation be caught without per-case rules. Only mailto/tel/javascript
|
|
358
|
+
// and a bare "#" are dropped (never pages).
|
|
359
|
+
const links = [];
|
|
360
|
+
const seenHref = new Set();
|
|
361
|
+
for (const a of document.querySelectorAll('a[href]')) {
|
|
362
|
+
if (links.length >= maxLinks) break;
|
|
363
|
+
const href = a.getAttribute('href');
|
|
364
|
+
if (!href || href === '#' || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
|
|
365
|
+
let abs;
|
|
366
|
+
try {
|
|
367
|
+
abs = new URL(href, location.href).toString();
|
|
368
|
+
} catch (e) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (seenHref.has(abs)) continue;
|
|
372
|
+
seenHref.add(abs);
|
|
373
|
+
links.push({ label: labelOf(a), href: abs });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---- #21d: audit of the STILL-HIDDEN text in the main content -------
|
|
377
|
+
// The closed loop's measure: how many characters of real text remain
|
|
378
|
+
// invisible right now? Counted on the TOPMOST hidden element only (a
|
|
379
|
+
// hidden subtree is one payload, not N), skipping <template>/<script>/
|
|
380
|
+
// <style> and aria-hidden=true (decorative/duplicated boilerplate by
|
|
381
|
+
// contract) and ignoring crumbs. Revealing content makes this number
|
|
382
|
+
// FALL — so "residual ≈ 0" is a measured completeness statement, not a
|
|
383
|
+
// judgment. It travels to page.meta / stats / a warning in the caller.
|
|
384
|
+
let hiddenResidualChars = 0;
|
|
385
|
+
// A text sample per topmost-hidden block, so the caller can subtract text that was
|
|
386
|
+
// ALREADY captured in an earlier state: a mutually-exclusive tab/panel is hidden
|
|
387
|
+
// again once its sibling is active, but its content was captured when it was open —
|
|
388
|
+
// counting it as "still hidden" is the audit's known false-positive (#9).
|
|
389
|
+
const hiddenTexts = [];
|
|
390
|
+
{
|
|
391
|
+
const skip = (el) =>
|
|
392
|
+
el.tagName === 'TEMPLATE' || el.tagName === 'SCRIPT' || el.tagName === 'STYLE' ||
|
|
393
|
+
el.getAttribute('aria-hidden') === 'true';
|
|
394
|
+
const stack = [...mainEl.children];
|
|
395
|
+
let visits = 0;
|
|
396
|
+
while (stack.length && visits++ < 20000) {
|
|
397
|
+
const el = stack.pop();
|
|
398
|
+
if (skip(el)) continue;
|
|
399
|
+
if (!isVisible(el)) {
|
|
400
|
+
const t = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
401
|
+
if (t.length > 40) {
|
|
402
|
+
hiddenResidualChars += t.length;
|
|
403
|
+
if (hiddenTexts.length < 80) hiddenTexts.push({ n: t.length, s: t.slice(0, 140) });
|
|
404
|
+
}
|
|
405
|
+
continue; // topmost hidden only — never double-count its children
|
|
406
|
+
}
|
|
407
|
+
for (const c of el.children) stack.push(c);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---- routes mined from scripts/JSON ---------------------------------
|
|
412
|
+
const routes = new Set();
|
|
413
|
+
const origin = location.origin;
|
|
414
|
+
let blob = '';
|
|
415
|
+
for (const s of document.querySelectorAll('script[type="application/json"], script[id="__NEXT_DATA__"]')) {
|
|
416
|
+
blob += '\n' + (s.textContent || '');
|
|
417
|
+
}
|
|
418
|
+
let inlineBudget = 200000;
|
|
419
|
+
for (const s of document.querySelectorAll('script:not([src])')) {
|
|
420
|
+
if (inlineBudget <= 0) break;
|
|
421
|
+
const t = s.textContent || '';
|
|
422
|
+
blob += '\n' + t.slice(0, inlineBudget);
|
|
423
|
+
inlineBudget -= t.length;
|
|
424
|
+
}
|
|
425
|
+
const pathRe = /["'`](\/[A-Za-z0-9._~\-/]+)["'`]/g;
|
|
426
|
+
let mm;
|
|
427
|
+
while ((mm = pathRe.exec(blob)) && routes.size < 800) {
|
|
428
|
+
const p = mm[1];
|
|
429
|
+
if (/\.(js|css|png|jpe?g|svg|gif|webp|ico|woff2?|ttf|map|json|mjs)$/i.test(p)) continue;
|
|
430
|
+
if (p.length < 2 || p.startsWith('//')) continue;
|
|
431
|
+
try {
|
|
432
|
+
routes.add(new URL(p, origin).toString());
|
|
433
|
+
} catch (e) {}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const mainText = ((mainEl && mainEl.innerText) || document.body.innerText || '')
|
|
437
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
438
|
+
.trim();
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
url: location.href,
|
|
442
|
+
title: document.title,
|
|
443
|
+
mainText,
|
|
444
|
+
revealers,
|
|
445
|
+
consentCandidates,
|
|
446
|
+
links,
|
|
447
|
+
hiddenResidualChars,
|
|
448
|
+
hiddenTexts,
|
|
449
|
+
routes: [...routes],
|
|
450
|
+
scrollHeight: document.body.scrollHeight,
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
{ maxRevealers, maxLinks, relaxLabels },
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
data.mainText = data.mainText.slice(0, maxText);
|
|
457
|
+
data.fingerprint = fingerprintOf(data);
|
|
458
|
+
return data;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// #26 — mark VISUAL headings: elements the page styles AS titles (a short
|
|
462
|
+
// standalone line whose font JUMPS vs the local body text) without using <h*>.
|
|
463
|
+
// Apps mark titles visually, not semantically — Turndown only trusts <h1>–<h6>,
|
|
464
|
+
// so every card/section title painted with a bigger font flattens to anonymous
|
|
465
|
+
// text and the page loses its skeleton. Runs IN THE BROWSER (getComputedStyle):
|
|
466
|
+
// self-contained ON PURPOSE — reveal's captureHtml inlines it via .toString()
|
|
467
|
+
// into its evaluate, atomically with the data-crawldna-hidden pass (mark →
|
|
468
|
+
// serialize → unmark), so no marker ever leaks into the live DOM of the next
|
|
469
|
+
// state. The signal is a RATIO, never a class name (rule #2): fontSize ≥ 1.15×
|
|
470
|
+
// the LOCAL body font, or bold (≥600) at ≥ body size. The level maps from the
|
|
471
|
+
// jump vs the PAGE body font (≥1.8→h2, ≥1.35→h3, else h4) — never h1, and real
|
|
472
|
+
// <h*>/[role=heading] are never touched or re-levelled: their semantics win, we
|
|
473
|
+
// only ADD structure the page painted (rule #1: nothing removed or rewritten).
|
|
474
|
+
// extract.mjs converts the marker to ##/###/#### and has a Node twin of this
|
|
475
|
+
// heuristic for inline styles (static path) — keep the two in sync.
|
|
476
|
+
export function markVisualHeadings() {
|
|
477
|
+
const marked = [];
|
|
478
|
+
if (!document.body) return marked;
|
|
479
|
+
// Clear stale markers first (same defensive pattern as data-crawldna-id).
|
|
480
|
+
for (const el of document.querySelectorAll('[data-crawldna-heading]')) {
|
|
481
|
+
el.removeAttribute('data-crawldna-heading');
|
|
482
|
+
}
|
|
483
|
+
const norm = (t) => (t || '').replace(/\s+/g, ' ').trim();
|
|
484
|
+
const styleCache = new Map();
|
|
485
|
+
const styleOf = (el) => {
|
|
486
|
+
let s = styleCache.get(el);
|
|
487
|
+
if (!s) {
|
|
488
|
+
const cs = getComputedStyle(el);
|
|
489
|
+
s = {
|
|
490
|
+
size: parseFloat(cs.fontSize) || 0,
|
|
491
|
+
weight: parseInt(cs.fontWeight, 10) || 400,
|
|
492
|
+
display: cs.display,
|
|
493
|
+
};
|
|
494
|
+
styleCache.set(el, s);
|
|
495
|
+
}
|
|
496
|
+
return s;
|
|
497
|
+
};
|
|
498
|
+
const SKIP_TAGS = { SCRIPT: 1, STYLE: 1, TEMPLATE: 1, NOSCRIPT: 1 };
|
|
499
|
+
// Text metrics of a subtree (optionally excluding one branch): VISIBLE text
|
|
500
|
+
// only, char-weighted size histogram + extremes. Budgeted — titles are tiny,
|
|
501
|
+
// and the ancestor scans below break as soon as they have enough chars.
|
|
502
|
+
const statsOf = (root, excl) => {
|
|
503
|
+
const st = { chars: 0, bySize: new Map(), maxSize: 0, minSize: Infinity, maxWeight: 0 };
|
|
504
|
+
const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
505
|
+
let n;
|
|
506
|
+
let visits = 0;
|
|
507
|
+
while ((n = w.nextNode()) && visits++ < 400) {
|
|
508
|
+
const el = n.parentElement;
|
|
509
|
+
if (!el || SKIP_TAGS[el.tagName]) continue;
|
|
510
|
+
if (excl && excl.contains(n)) continue;
|
|
511
|
+
const t = norm(n.nodeValue);
|
|
512
|
+
if (!t) continue;
|
|
513
|
+
if (!el.getClientRects().length) continue; // display:none text doesn't vote
|
|
514
|
+
const s = styleOf(el);
|
|
515
|
+
if (!s.size) continue;
|
|
516
|
+
const key = Math.round(s.size * 2) / 2;
|
|
517
|
+
st.chars += t.length;
|
|
518
|
+
st.bySize.set(key, (st.bySize.get(key) || 0) + t.length);
|
|
519
|
+
if (key > st.maxSize) st.maxSize = key;
|
|
520
|
+
if (key < st.minSize) st.minSize = key;
|
|
521
|
+
if (s.weight > st.maxWeight) st.maxWeight = s.weight;
|
|
522
|
+
}
|
|
523
|
+
return st;
|
|
524
|
+
};
|
|
525
|
+
const dominant = (bySize, fallback) => {
|
|
526
|
+
let best = fallback;
|
|
527
|
+
let chars = 0;
|
|
528
|
+
for (const e of bySize) {
|
|
529
|
+
if (e[1] > chars) {
|
|
530
|
+
chars = e[1];
|
|
531
|
+
best = e[0];
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return best;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// ONE pass over the page's text: the char-weighted font histogram (its mode
|
|
538
|
+
// is the page BODY font) and, per text line, the block that owns it (the
|
|
539
|
+
// candidate set — nearest non-inline ancestor, so a big <span> inside a
|
|
540
|
+
// wrapper <div> still surfaces the wrapper as the title block).
|
|
541
|
+
const pageSizes = new Map();
|
|
542
|
+
const blocks = [];
|
|
543
|
+
const seenBlocks = new Set();
|
|
544
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
545
|
+
let node;
|
|
546
|
+
let visits = 0;
|
|
547
|
+
while ((node = walker.nextNode()) && visits++ < 20000) {
|
|
548
|
+
const el = node.parentElement;
|
|
549
|
+
if (!el || SKIP_TAGS[el.tagName]) continue;
|
|
550
|
+
const t = norm(node.nodeValue);
|
|
551
|
+
if (t.length < 2) continue;
|
|
552
|
+
if (!el.getClientRects().length) continue;
|
|
553
|
+
const s = styleOf(el);
|
|
554
|
+
if (!s.size) continue;
|
|
555
|
+
const key = Math.round(s.size * 2) / 2;
|
|
556
|
+
pageSizes.set(key, (pageSizes.get(key) || 0) + t.length);
|
|
557
|
+
let b = el;
|
|
558
|
+
while (b && b !== document.body && styleOf(b).display === 'inline') b = b.parentElement;
|
|
559
|
+
if (b && b !== document.body && !seenBlocks.has(b)) {
|
|
560
|
+
seenBlocks.add(b);
|
|
561
|
+
blocks.push(b); // document order — outer titles mark before inner dupes
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const body = dominant(pageSizes, 16);
|
|
565
|
+
|
|
566
|
+
// Never a heading: interactive/label surfaces, cells, list items (#25), code,
|
|
567
|
+
// real headings — and anything under an already-marked title (no nesting).
|
|
568
|
+
const BANNED =
|
|
569
|
+
'a,button,h1,h2,h3,h4,h5,h6,table,th,td,li,ul,ol,dl,pre,code,kbd,samp,label,select,option,textarea,input,summary,figcaption,blockquote,nav,aside,footer,[role=heading],[role=button],[role=tab],[role=list],[role=listitem],[data-crawldna-heading]';
|
|
570
|
+
// [data-crawldna-heading]: an element already CONTAINING a marked title is not
|
|
571
|
+
// itself marked — blocks marked pairs nesting into `#### #### …`. Marking here is
|
|
572
|
+
// inner-first (document order of the first text node), so the outer, evaluated
|
|
573
|
+
// later, sees the inner marker and skips.
|
|
574
|
+
const STRUCTURAL = 'h1,h2,h3,h4,h5,h6,table,ul,ol,pre,blockquote,button,a,input,select,textarea,[data-crawldna-heading]';
|
|
575
|
+
for (const el of blocks) {
|
|
576
|
+
const text = norm(el.textContent);
|
|
577
|
+
if (text.length < 2 || text.length > 60) continue; // a title is one short line
|
|
578
|
+
if (!/\p{L}/u.test(text)) continue; // bare numbers/prices are data, not titles
|
|
579
|
+
if (el.closest(BANNED)) continue;
|
|
580
|
+
if (el.querySelector(STRUCTURAL)) continue;
|
|
581
|
+
// A title INSIDE a repeated row that #25 flattens to a bullet must not be
|
|
582
|
+
// marked (the marker would collapse into the bullet as a stray `- #### …`).
|
|
583
|
+
// The candidate is often a title-wrapper NESTED in the card, so test
|
|
584
|
+
// ANCESTORS, not just the element's own siblings: the 8 gallery tiles, the
|
|
585
|
+
// 4 stat cards and the colour swatches all sit one level up. Same shape
|
|
586
|
+
// signal as extract's shapedRowItem (tag + first class token, ≥3 siblings,
|
|
587
|
+
// short, no block content). Summary/Transactions/Recent Orders survive:
|
|
588
|
+
// their cards carry a table/list or have <3 same-shape siblings.
|
|
589
|
+
const firstCls = (n) => ((n.getAttribute && n.getAttribute('class')) || '').split(/\s+/)[0] || '';
|
|
590
|
+
const isFlattenedRow = (a) => {
|
|
591
|
+
if (!a || a === document.body) return false;
|
|
592
|
+
if ((a.getAttribute && a.getAttribute('role')) === 'listitem') return true;
|
|
593
|
+
if (a.tagName !== 'DIV') return false;
|
|
594
|
+
const c0 = firstCls(a);
|
|
595
|
+
if (!c0) return false;
|
|
596
|
+
const t = norm(a.textContent);
|
|
597
|
+
if (!t || t.length > 200) return false;
|
|
598
|
+
if (a.querySelector && a.querySelector('h1,h2,h3,h4,h5,h6,table,pre,ul,ol')) return false;
|
|
599
|
+
const par = a.parentElement;
|
|
600
|
+
if (!par) return false;
|
|
601
|
+
let alike = 0;
|
|
602
|
+
for (const sib of par.children) if (sib.tagName === a.tagName && firstCls(sib) === c0) alike++;
|
|
603
|
+
return alike >= 3;
|
|
604
|
+
};
|
|
605
|
+
let inRow = false;
|
|
606
|
+
for (let a = el, hops = 0; a && a !== document.body && hops < 6; a = a.parentElement, hops++) {
|
|
607
|
+
if (isFlattenedRow(a)) {
|
|
608
|
+
inRow = true;
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (inRow) continue;
|
|
613
|
+
const st = statsOf(el);
|
|
614
|
+
if (!st.chars || st.maxSize < body) continue; // never smaller than the page body font
|
|
615
|
+
if (st.minSize < 0.75 * st.maxSize) continue; // mixed sizes = composite (stat value + caption), not a title
|
|
616
|
+
// LOCAL body font: the dominant size of the SURROUNDING text (nearest
|
|
617
|
+
// ancestor with enough of it) — a block that is ALL large text (a hero)
|
|
618
|
+
// must not promote its own lines.
|
|
619
|
+
let local = body;
|
|
620
|
+
let anc = el.parentElement;
|
|
621
|
+
for (let hops = 0; anc && anc !== document.documentElement && hops < 6; hops++, anc = anc.parentElement) {
|
|
622
|
+
const around = statsOf(anc, el);
|
|
623
|
+
if (around.chars >= 40) {
|
|
624
|
+
local = dominant(around.bySize, body);
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const jump = st.maxSize >= 1.15 * local || (st.maxWeight >= 600 && st.maxSize >= local);
|
|
629
|
+
if (!jump) continue;
|
|
630
|
+
const ratio = st.maxSize / body;
|
|
631
|
+
const level = ratio >= 1.8 ? 2 : ratio >= 1.35 ? 3 : 4;
|
|
632
|
+
el.setAttribute('data-crawldna-heading', String(level));
|
|
633
|
+
marked.push(el);
|
|
634
|
+
}
|
|
635
|
+
return marked;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function fingerprintOf(data) {
|
|
639
|
+
const basis = [
|
|
640
|
+
Math.round((data.mainText.length || 0) / 40),
|
|
641
|
+
data.revealers.length,
|
|
642
|
+
Math.round((data.scrollHeight || 0) / 200),
|
|
643
|
+
data.revealers.map((r) => r.signature).join('§'),
|
|
644
|
+
data.mainText.slice(0, 400),
|
|
645
|
+
].join('#');
|
|
646
|
+
return createHash('sha1').update(basis).digest('hex').slice(0, 16);
|
|
647
|
+
}
|