@fastbrowse/win32-x64 0.5.19 → 0.5.23
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/fastbrowse/_internal/_asyncio.pyd +0 -0
- package/fastbrowse/_internal/_bz2.pyd +0 -0
- package/fastbrowse/_internal/_ctypes.pyd +0 -0
- package/fastbrowse/_internal/_decimal.pyd +0 -0
- package/fastbrowse/_internal/_hashlib.pyd +0 -0
- package/fastbrowse/_internal/_lzma.pyd +0 -0
- package/fastbrowse/_internal/_multiprocessing.pyd +0 -0
- package/fastbrowse/_internal/_overlapped.pyd +0 -0
- package/fastbrowse/_internal/_queue.pyd +0 -0
- package/fastbrowse/_internal/_socket.pyd +0 -0
- package/fastbrowse/_internal/_ssl.pyd +0 -0
- package/fastbrowse/_internal/_uuid.pyd +0 -0
- package/fastbrowse/_internal/_wmi.pyd +0 -0
- package/fastbrowse/_internal/_zoneinfo.pyd +0 -0
- package/fastbrowse/_internal/base_library.zip +0 -0
- package/fastbrowse/_internal/fastbrowse/browser/capture.js +193 -17
- package/fastbrowse/_internal/fastbrowse/browser/snapshot.js +150 -13
- package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/METADATA +117 -60
- package/fastbrowse/_internal/fastbrowse-0.5.23.dist-info/RECORD +14 -0
- package/fastbrowse/_internal/fastbrowse-0.5.23.dist-info/uv_cache.json +1 -0
- package/fastbrowse/_internal/libcrypto-3.dll +0 -0
- package/fastbrowse/_internal/libssl-3.dll +0 -0
- package/fastbrowse/_internal/pyexpat.pyd +0 -0
- package/fastbrowse/_internal/python313.dll +0 -0
- package/fastbrowse/_internal/select.pyd +0 -0
- package/fastbrowse/_internal/unicodedata.pyd +0 -0
- package/fastbrowse/_internal/websockets/speedups.cp313-win_amd64.pyd +0 -0
- package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/METADATA +2 -2
- package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/RECORD +22 -22
- package/fastbrowse/fastbrowse.exe +0 -0
- package/package.json +1 -1
- package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/RECORD +0 -14
- package/fastbrowse/_internal/fastbrowse-0.5.19.dist-info/uv_cache.json +0 -1
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/INSTALLER +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/REQUESTED +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/WHEEL +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/direct_url.json +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/entry_points.txt +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/licenses/LICENSE +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/licenses/NOTICE +0 -0
- /package/fastbrowse/_internal/{fastbrowse-0.5.19.dist-info → fastbrowse-0.5.23.dist-info}/uv_build.json +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/INSTALLER +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/REQUESTED +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/WHEEL +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/entry_points.txt +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/licenses/LICENSE +0 -0
- /package/fastbrowse/_internal/{websockets-17.1.dist-info → websockets-17.2.dist-info}/top_level.txt +0 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Unlike snapshot.js (bounded, for Jev's action choice) this reads the whole rendered document, on screen or not,
|
|
3
3
|
// so the reading path and answer-evidence quotes see everything a user could scroll to.
|
|
4
4
|
// Adjacent inline content (text, <strong>, <a>, <span>...) is one block, so a value inside markup stays with its label.
|
|
5
|
-
// biome-ignore format: Python embeds this
|
|
6
|
-
(() => {
|
|
5
|
+
// biome-ignore format: Python embeds this function inside an expression, where a semicolon is invalid.
|
|
6
|
+
((allowedOrigins = null) => {
|
|
7
7
|
if (!document.body) return null;
|
|
8
8
|
const HEADINGS = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 };
|
|
9
9
|
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME']);
|
|
@@ -12,7 +12,34 @@
|
|
|
12
12
|
let framePath = null;
|
|
13
13
|
let sourcePath = '';
|
|
14
14
|
let inaccessible = 0;
|
|
15
|
+
let visibleH1 = false;
|
|
16
|
+
let controlObservations = 0;
|
|
17
|
+
let imageObservations = 0;
|
|
18
|
+
let imagesOmitted = false;
|
|
19
|
+
let linkObservations = 0;
|
|
20
|
+
let linksOmitted = false;
|
|
21
|
+
let controlsOmitted = false;
|
|
22
|
+
const imageSource = source => {
|
|
23
|
+
if (!source) return source;
|
|
24
|
+
if (source.startsWith('data:')) {
|
|
25
|
+
const comma = source.indexOf(',');
|
|
26
|
+
return `${source.slice(0, comma < 0 ? 128 : Math.min(comma, 128))},[payload omitted]`;
|
|
27
|
+
}
|
|
28
|
+
if (source.startsWith('blob:')) return 'blob:[identifier omitted]';
|
|
29
|
+
return source.length > 1024 ? `${source.slice(0, 1024)}[truncated]` : source;
|
|
30
|
+
};
|
|
15
31
|
const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
|
|
32
|
+
const granted = doc => {
|
|
33
|
+
if (allowedOrigins === null) return true;
|
|
34
|
+
const frame = doc.defaultView.frameElement;
|
|
35
|
+
if (frame) {
|
|
36
|
+
if (frame.hasAttribute('srcdoc') || !frame.getAttribute('src')) return false;
|
|
37
|
+
const source = new URL(frame.src);
|
|
38
|
+
if (!['http:', 'https:'].includes(source.protocol) || !allowedOrigins.includes(source.origin)) return false;
|
|
39
|
+
}
|
|
40
|
+
const url = new URL(doc.URL);
|
|
41
|
+
return ['http:', 'https:'].includes(url.protocol) && allowedOrigins.includes(url.origin);
|
|
42
|
+
};
|
|
16
43
|
const identity = e => {
|
|
17
44
|
if (!registry.ids.has(e)) registry.ids.set(e, registry.next++);
|
|
18
45
|
return registry.ids.get(e);
|
|
@@ -34,9 +61,67 @@
|
|
|
34
61
|
const u = new URL(a.href, location.href);
|
|
35
62
|
return u.origin === location.origin ? u.pathname + u.search : u.href;
|
|
36
63
|
};
|
|
37
|
-
const
|
|
64
|
+
const controlOf = nodes => {
|
|
65
|
+
let found = null;
|
|
66
|
+
for (const node of nodes) {
|
|
67
|
+
if (!clean(node.nodeType === 3 ? node.textContent : (node.innerText ?? node.textContent ?? ''))) continue;
|
|
68
|
+
let host = node.nodeType === 1 ? node : node.parentElement;
|
|
69
|
+
while (host && !registry.hoverContexts?.has(host)) host = host.parentElement;
|
|
70
|
+
if (!host?.isConnected || !host.matches(':hover')) return null;
|
|
71
|
+
const binding = registry.hoverContexts.get(host);
|
|
72
|
+
const siblings = [...(host.parentNode?.children || [])];
|
|
73
|
+
const labelAttributes = [host.getAttribute('role'), host.getAttribute('aria-label'),
|
|
74
|
+
host.getAttribute('title'), host.querySelector('img[alt]')?.getAttribute('alt') || null];
|
|
75
|
+
if (host.innerText !== binding.text ||
|
|
76
|
+
labelAttributes.some((value, index) => value !== binding.labelAttributes[index])) return null;
|
|
77
|
+
// A reordered or replaced sibling set invalidates the observed ordinal before the quote is taken.
|
|
78
|
+
if (host.parentNode !== binding.parent || siblings.length !== binding.siblings.length ||
|
|
79
|
+
siblings.some((sibling, index) => sibling !== binding.siblings[index])) return null;
|
|
80
|
+
if (found && found !== host) return null;
|
|
81
|
+
found = host;
|
|
82
|
+
}
|
|
83
|
+
return found ? registry.hoverContexts.get(found).control : null;
|
|
84
|
+
};
|
|
85
|
+
const listIds = new WeakMap();
|
|
86
|
+
const listFrameless = new WeakMap();
|
|
87
|
+
let nextListId = 0;
|
|
88
|
+
const listContext = nodes => {
|
|
89
|
+
const lists = new Set();
|
|
90
|
+
for (const node of nodes) {
|
|
91
|
+
let element = node.nodeType === 1 ? node : node.parentElement;
|
|
92
|
+
let list = null;
|
|
93
|
+
for (let parent = element; parent; parent = parent.parentElement) {
|
|
94
|
+
if (parent.matches('nav,[role="navigation"]')) return {};
|
|
95
|
+
if (parent.matches('ul,ol,[role="list"]')) list = parent;
|
|
96
|
+
}
|
|
97
|
+
if (!list) {
|
|
98
|
+
const nested = [...(element?.querySelectorAll('ul,ol,[role="list"]') ?? [])];
|
|
99
|
+
const outer = nested.filter(candidate => !nested.some(other => other !== candidate && other.contains(candidate)));
|
|
100
|
+
if (outer.length === 1) list = outer[0];
|
|
101
|
+
}
|
|
102
|
+
if (list) lists.add(list);
|
|
103
|
+
}
|
|
104
|
+
if (lists.size !== 1) return {};
|
|
105
|
+
const list = [...lists][0];
|
|
106
|
+
if (!listIds.has(list)) {
|
|
107
|
+
listIds.set(list, String(nextListId++));
|
|
108
|
+
// A frame the capture cannot open elsewhere on the page, such as an advertisement, cannot hold a member of a
|
|
109
|
+
// list whose element contains no frame; one inside the list, or behind a shadow root the list or anything in it
|
|
110
|
+
// holds, could.
|
|
111
|
+
const framed = e => e.tagName === 'IFRAME' || e.tagName === 'FRAME' || !!e.shadowRoot;
|
|
112
|
+
listFrameless.set(list, !framed(list) && ![...list.querySelectorAll('*')].some(framed));
|
|
113
|
+
}
|
|
114
|
+
const members = [...list.querySelectorAll('li,[role="listitem"]')].filter(member => {
|
|
115
|
+
for (let parent = member; parent && parent !== list; parent = parent.parentElement) {
|
|
116
|
+
if (hidden(parent)) return false;
|
|
117
|
+
}
|
|
118
|
+
return !!textOf(member);
|
|
119
|
+
});
|
|
120
|
+
return { list_id: listIds.get(list), list_count: members.length, list_frameless: listFrameless.get(list) };
|
|
121
|
+
};
|
|
122
|
+
const push = (kind, text, extra = {}, nodes = []) => {
|
|
38
123
|
if (text)
|
|
39
|
-
blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, ...extra });
|
|
124
|
+
blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, control_context: controlOf(nodes), ...listContext(nodes), ...extra });
|
|
40
125
|
};
|
|
41
126
|
|
|
42
127
|
// A cell can say what it says with an image or an icon alone: a flag marking the winner, a tick for "yes".
|
|
@@ -168,6 +253,8 @@
|
|
|
168
253
|
let headers = rows.filter(({ row }) => row.parentElement.tagName === 'THEAD');
|
|
169
254
|
if (!headers.length && rows[0].cells.every(c => c.tagName === 'TH')) headers = [rows[0]];
|
|
170
255
|
const line = ({ cells }) => '| ' + cells.map(c => cellText(c).replace(/\|/g, '\\|')).join(' | ') + ' |';
|
|
256
|
+
// Headerless comparison rows need leading cells for identity and separate source spans for counting.
|
|
257
|
+
if (!headers.length) return rows.map((row, index) => (index ? [line(rows[0]), line(row)].join('\n') : line(row)));
|
|
171
258
|
const prefix = headers.map(line);
|
|
172
259
|
if (headers.length) prefix.push('| ' + headers[0].cells.map(() => '---').join(' | ') + ' |');
|
|
173
260
|
const data = rows.filter(row => !headers.includes(row));
|
|
@@ -184,8 +271,8 @@
|
|
|
184
271
|
elements[0].href &&
|
|
185
272
|
clean(elements[0].innerText ?? '') === text;
|
|
186
273
|
for (const e of elements) text = withRating(text, e);
|
|
187
|
-
if (onlyLink) push('link', text, { href: hrefOf(elements[0]) });
|
|
188
|
-
else push('paragraph', text);
|
|
274
|
+
if (onlyLink) push('link', text, { href: hrefOf(elements[0]) }, run);
|
|
275
|
+
else push('paragraph', text, {}, run);
|
|
189
276
|
for (const e of elements) for (const field of fields(e)) push('paragraph', field);
|
|
190
277
|
run.length = 0;
|
|
191
278
|
}
|
|
@@ -197,13 +284,22 @@
|
|
|
197
284
|
// A classless div repeated three times is usually page layout; a record is a styled unit or a semantic item.
|
|
198
285
|
function repeated(el) {
|
|
199
286
|
if (!el.classList.length && el.tagName !== 'ARTICLE' && el.tagName !== 'LI') return false;
|
|
200
|
-
|
|
201
|
-
[...(el.parentNode?.children ?? [])].filter(
|
|
287
|
+
const siblings = [...(el.parentNode?.children ?? [])].filter(
|
|
202
288
|
sibling =>
|
|
203
289
|
sibling.tagName === el.tagName &&
|
|
204
290
|
sibling.classList.length === el.classList.length &&
|
|
205
291
|
[...el.classList].every(token => sibling.classList.contains(token)),
|
|
206
|
-
)
|
|
292
|
+
);
|
|
293
|
+
if (siblings.length >= 3) return true;
|
|
294
|
+
// Linked cards can sit inside an unlabelled list wrapper, so distinct destinations establish the pair.
|
|
295
|
+
if (siblings.length === 2 && el.tagName === 'A' && siblings.every(sibling => sibling.hasAttribute('href'))) {
|
|
296
|
+
return new Set(siblings.map(sibling => sibling.href)).size === siblings.length;
|
|
297
|
+
}
|
|
298
|
+
// A styled pair can be a two-column layout, so require a heading outside both items to name their group.
|
|
299
|
+
const section = el.parentElement;
|
|
300
|
+
return (
|
|
301
|
+
siblings.length === 2 &&
|
|
302
|
+
[...(section?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? [])].some(heading => siblings.every(sibling => !sibling.contains(heading)))
|
|
207
303
|
);
|
|
208
304
|
}
|
|
209
305
|
|
|
@@ -237,7 +333,7 @@
|
|
|
237
333
|
continue;
|
|
238
334
|
}
|
|
239
335
|
if (SKIP.has(node.tagName)) continue;
|
|
240
|
-
if (!isBlock(node) && recordText(node) === null) {
|
|
336
|
+
if (!node.matches('nav,[role="navigation"]') && !isBlock(node) && recordText(node) === null) {
|
|
241
337
|
run.push(node);
|
|
242
338
|
continue;
|
|
243
339
|
}
|
|
@@ -248,30 +344,38 @@
|
|
|
248
344
|
}
|
|
249
345
|
|
|
250
346
|
function block(el) {
|
|
347
|
+
if (el.matches('nav,[role="navigation"]')) {
|
|
348
|
+
// Navigation help headings describe their menu, not product facts in the following main content.
|
|
349
|
+
const surrounding = [...path];
|
|
350
|
+
walk(el);
|
|
351
|
+
path = surrounding;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
251
354
|
const level = HEADINGS[el.tagName];
|
|
252
355
|
if (level) {
|
|
253
356
|
const text = textOf(el);
|
|
254
357
|
while (path.length >= level) path.pop();
|
|
255
358
|
if (text) {
|
|
256
|
-
push('heading', text);
|
|
359
|
+
push('heading', text, {}, [el]);
|
|
257
360
|
path.push(text);
|
|
258
361
|
}
|
|
259
362
|
return;
|
|
260
363
|
}
|
|
261
364
|
if (el.tagName === 'TABLE') {
|
|
262
|
-
for (const text of renderTable(el)) push('table', text);
|
|
365
|
+
for (const text of renderTable(el)) push('table', text, {}, [el]);
|
|
263
366
|
// A form laid out in a table: its cells render as text, which holds none of the controls' values.
|
|
264
367
|
for (const field of fields(el)) push('paragraph', field);
|
|
265
368
|
return;
|
|
266
369
|
}
|
|
267
|
-
if (el.tagName === 'PRE') return push('code', textOf(el));
|
|
370
|
+
if (el.tagName === 'PRE') return push('code', textOf(el), {}, [el]);
|
|
268
371
|
const record = recordText(el);
|
|
269
372
|
if ((el.tagName === 'LI' && leaf(el)) || record !== null) {
|
|
270
|
-
const links = el.querySelectorAll('a[href]');
|
|
373
|
+
const links = el.matches('a[href]') ? [el] : el.querySelectorAll('a[href]');
|
|
271
374
|
push(
|
|
272
375
|
el.tagName === 'LI' ? 'list_item' : 'record',
|
|
273
376
|
record ?? withRating(textOf(el), el),
|
|
274
377
|
links.length === 1 ? { href: hrefOf(links[0]) } : {},
|
|
378
|
+
[el],
|
|
275
379
|
);
|
|
276
380
|
for (const field of fields(el)) push('paragraph', field);
|
|
277
381
|
return;
|
|
@@ -296,7 +400,7 @@
|
|
|
296
400
|
} catch {
|
|
297
401
|
inner = null;
|
|
298
402
|
}
|
|
299
|
-
if (!inner?.body) {
|
|
403
|
+
if (!inner?.body || !granted(inner)) {
|
|
300
404
|
inaccessible++;
|
|
301
405
|
return;
|
|
302
406
|
}
|
|
@@ -310,6 +414,77 @@
|
|
|
310
414
|
framePath = frame;
|
|
311
415
|
sourcePath = source;
|
|
312
416
|
walk(root);
|
|
417
|
+
path = [...headings];
|
|
418
|
+
const visible = e => e.checkVisibility ? e.checkVisibility({ visibilityProperty: true }) : !hidden(e);
|
|
419
|
+
for (const e of [...root.querySelectorAll('input,select,textarea,button,[role="button"]')].filter(visible)) {
|
|
420
|
+
if (controlObservations >= 64) {
|
|
421
|
+
if (!controlsOmitted) push('observation', 'Additional control state observations omitted.');
|
|
422
|
+
controlsOmitted = true;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
// Placeholders suggest input; they do not label a value or prove an empty named field.
|
|
426
|
+
if (CONTROLS.has(e.tagName) && !e.labels?.length && !e.hasAttribute('aria-label') && !e.hasAttribute('aria-labelledby')) continue;
|
|
427
|
+
const name = nameOf(e) || clean(e.innerText ?? '');
|
|
428
|
+
if (!name || e.dataset?.fastbrowseSecret === '1') continue;
|
|
429
|
+
if (CONTROLS.has(e.tagName) && !UNFILLED.has(e.type) && !fieldValue(e)) {
|
|
430
|
+
push('observation', `${name.slice(0, 1024)}: [empty]`);
|
|
431
|
+
controlObservations++;
|
|
432
|
+
}
|
|
433
|
+
if (controlObservations < 64 && e.matches(':disabled,[aria-disabled="true"]')) {
|
|
434
|
+
push('observation', `${name.slice(0, 1024)}: disabled`);
|
|
435
|
+
controlObservations++;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Images carry no innerText, so a text-only capture cannot distinguish their presence from absence.
|
|
439
|
+
for (const image of [...root.querySelectorAll('img')].filter(visible)) {
|
|
440
|
+
if (imageObservations++ >= 32) {
|
|
441
|
+
if (!imagesOmitted) push('observation', 'Additional visible image metadata omitted.');
|
|
442
|
+
imagesOmitted = true;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
// Loading changes without a page edit, so captures report presence rather than transient decode state.
|
|
446
|
+
const metadata = { alt: image.getAttribute('alt')?.slice(0, 1024) ?? null,
|
|
447
|
+
src: imageSource(image.getAttribute('src') ?? image.getAttribute('srcset')) };
|
|
448
|
+
push('observation', `Image element DOM metadata: ${JSON.stringify(metadata)}. Pixels are not described.`);
|
|
449
|
+
}
|
|
450
|
+
// A link drawn only as an icon has no text, so its destination, the one thing it says, was nowhere a reader
|
|
451
|
+
// could quote: asked for a release's download URL beside an icon-only download link, a run read every other
|
|
452
|
+
// field of the row and then clicked the link again and again looking for the address. The row's text names
|
|
453
|
+
// which item the link belongs to, since the observation is not read where the link stands.
|
|
454
|
+
for (const link of [...root.querySelectorAll('a[href]')].filter(visible)) {
|
|
455
|
+
if (clean(link.innerText ?? '')) continue;
|
|
456
|
+
// An icon button written as `href="#"` or a script URL runs code here and leads nowhere to report. A bare
|
|
457
|
+
// `#` parses to an empty hash, so the raw attribute says it is a fragment link, not the parsed URL.
|
|
458
|
+
let target;
|
|
459
|
+
try {
|
|
460
|
+
target = new URL(link.getAttribute('href'), location.href);
|
|
461
|
+
} catch {
|
|
462
|
+
// One malformed address must not cost the page its whole capture.
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const page = location.href.split('#')[0];
|
|
466
|
+
const fragment = link.getAttribute('href').trim().startsWith('#') || (target.hash && target.href.split('#')[0] === page);
|
|
467
|
+
if (target.protocol === 'javascript:' || fragment) continue;
|
|
468
|
+
if (linkObservations++ >= 32) {
|
|
469
|
+
if (!linksOmitted) push('observation', 'Additional link destinations omitted.');
|
|
470
|
+
linksOmitted = true;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
let row = link.parentElement;
|
|
474
|
+
while (row && row !== root && !clean(row.innerText ?? '')) row = row.parentElement;
|
|
475
|
+
const metadata = {
|
|
476
|
+
label: (nameOf(link) || drawn(link)).slice(0, 256) || null,
|
|
477
|
+
href: imageSource(link.href),
|
|
478
|
+
near: row ? textOf(row).replace(/\s+/g, ' ').slice(0, 256) || null : null,
|
|
479
|
+
};
|
|
480
|
+
push('observation', `Link element DOM metadata: ${JSON.stringify(metadata)}. The link has no text.`);
|
|
481
|
+
}
|
|
482
|
+
const h1 = [...root.querySelectorAll('h1,[role="heading"][aria-level="1"]')].filter(visible);
|
|
483
|
+
visibleH1 ||= h1.length > 0;
|
|
484
|
+
if (root === document.body) {
|
|
485
|
+
const doc = document.documentElement;
|
|
486
|
+
push('observation', `Observed viewport: Horizontal overflow: ${doc.scrollWidth > doc.clientWidth ? 'yes' : 'no'}`);
|
|
487
|
+
}
|
|
313
488
|
for (const e of root.querySelectorAll('*')) {
|
|
314
489
|
if (e.shadowRoot) scope(e.shadowRoot, frame, `${source}/shadow:${identity(e)}`);
|
|
315
490
|
// A frame the walk never reached (hidden, or inside a record or table read as one block) is still read.
|
|
@@ -319,5 +494,6 @@
|
|
|
319
494
|
}
|
|
320
495
|
|
|
321
496
|
scope(document.body, null, '');
|
|
322
|
-
|
|
323
|
-
}
|
|
497
|
+
if (!visibleH1) push('observation', 'Observed accessible DOM: Visible h1 headings: 0');
|
|
498
|
+
return { url: location.href, title: document.title, blocks, inaccessible_frames: inaccessible, visible_h1: visibleH1 };
|
|
499
|
+
})
|
|
@@ -3,7 +3,18 @@
|
|
|
3
3
|
// Adapted to fastbrowse's Control shape (role + operation set, not per-kind actions) and to traverse
|
|
4
4
|
// open shadow roots. Runs once per frame session (main frame or an OOPIF); the Python side merges frames.
|
|
5
5
|
// biome-ignore format: Python appends mode arguments to this parenthesized expression.
|
|
6
|
-
(mode => {
|
|
6
|
+
((mode, allowedOrigins = null) => {
|
|
7
|
+
const granted = doc => {
|
|
8
|
+
if (allowedOrigins === null) return true;
|
|
9
|
+
const frame = doc.defaultView.frameElement;
|
|
10
|
+
if (frame) {
|
|
11
|
+
if (frame.hasAttribute('srcdoc') || !frame.getAttribute('src')) return false;
|
|
12
|
+
const source = new URL(frame.src);
|
|
13
|
+
if (!['http:', 'https:'].includes(source.protocol) || !allowedOrigins.includes(source.origin)) return false;
|
|
14
|
+
}
|
|
15
|
+
const url = new URL(doc.URL);
|
|
16
|
+
return ['http:', 'https:'].includes(url.protocol) && allowedOrigins.includes(url.origin);
|
|
17
|
+
};
|
|
7
18
|
// Form context is repeated for each submit-capable input in safety prompts.
|
|
8
19
|
const FORM_TEXT_CHARS = 2000;
|
|
9
20
|
// Freshness guards bound comparison work on large cards; this text is never sent to a model.
|
|
@@ -30,6 +41,10 @@
|
|
|
30
41
|
return true;
|
|
31
42
|
};
|
|
32
43
|
const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
|
|
44
|
+
// Keep the page's own timer overrides from holding a browser-side frame wait open.
|
|
45
|
+
registry.nativeSetTimeout ||= window.setTimeout.bind(window);
|
|
46
|
+
registry.nativeClearTimeout ||= window.clearTimeout.bind(window);
|
|
47
|
+
registry.nativeRequestAnimationFrame ||= window.requestAnimationFrame.bind(window);
|
|
33
48
|
// What was offered to be acted on; `ids` also names hover targets and fingerprinted nodes.
|
|
34
49
|
registry.controls ||= new WeakSet();
|
|
35
50
|
// A visible loading indicator means the page is still fetching what it will draw, which network idle and a
|
|
@@ -117,7 +132,7 @@
|
|
|
117
132
|
} catch {
|
|
118
133
|
inner = null;
|
|
119
134
|
}
|
|
120
|
-
if (inner?.body) include(inner);
|
|
135
|
+
if (inner?.body && granted(inner)) include(inner);
|
|
121
136
|
}
|
|
122
137
|
}
|
|
123
138
|
};
|
|
@@ -165,10 +180,56 @@
|
|
|
165
180
|
registry.visible = visible;
|
|
166
181
|
// Styled checkboxes and radios often hide the native input. Its visible label is the click
|
|
167
182
|
// target, but the input still owns the checked/disabled state and must participate in freshness.
|
|
168
|
-
const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio'].includes(e.control?.type) ? e.control : e);
|
|
183
|
+
const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio', 'file'].includes(e.control?.type) ? e.control : e);
|
|
169
184
|
|
|
170
185
|
// Their text is code, not a name: Amazon nests a <style> inside a result card's link.
|
|
171
186
|
const CODE = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
|
|
187
|
+
// Icon-only buttons hide their glyph from accessibility text, but the icon library still names it: in a class,
|
|
188
|
+
// a sprite reference, a data attribute, an svg title or a ligature. A gallery's trash control otherwise reads
|
|
189
|
+
// "button" beside an edit link that reads "link", and the choice cannot tell removing an image from editing it.
|
|
190
|
+
const ICON_CLASS =
|
|
191
|
+
/^(?:icon-tabler|tabler-icon|lucide|glyphicon|fa|fas|far|bi|mdi|icon|ti|la|ri|ion|feather)-([a-z][a-z0-9-]{0,47})$/;
|
|
192
|
+
// Sizes, weights and effects share an icon library's prefix without naming a glyph, as does the library itself.
|
|
193
|
+
const ICON_STYLE =
|
|
194
|
+
/^(?:icon|tabler|solid|regular|light|thin|duotone|brands|xs|sm|lg|xl|\d+x|fw|spin|pulse|border|inverse|stack.*|pull-.*|rotate-.*|flip-.*)$/;
|
|
195
|
+
const glyphName = text => {
|
|
196
|
+
const name = (text || '').trim().toLowerCase().replace(/^(?:icons?|ico|ic|svg|sprite)[-_]/, '');
|
|
197
|
+
return /^[a-z][a-z0-9_-]{0,47}$/.test(name) && !ICON_STYLE.test(name) ? name : '';
|
|
198
|
+
};
|
|
199
|
+
const iconLabel = e => {
|
|
200
|
+
const glyphs = new Set();
|
|
201
|
+
for (const icon of e.querySelectorAll('svg,i,span,[role="img"]')) {
|
|
202
|
+
if (!icon.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
|
|
203
|
+
const named = [];
|
|
204
|
+
for (const token of icon.classList) {
|
|
205
|
+
const glyph = ICON_CLASS.exec(token);
|
|
206
|
+
if (glyph && !ICON_STYLE.test(glyph[1])) named.push(glyph[1]);
|
|
207
|
+
}
|
|
208
|
+
const use = icon.localName === 'svg' && icon.querySelector('use');
|
|
209
|
+
const sprite = use && (use.getAttribute('href') || use.getAttribute('xlink:href') || '');
|
|
210
|
+
if (sprite) named.push(glyphName(sprite.split('#').pop().split('/').pop().replace(/\.svg$/, '')));
|
|
211
|
+
named.push(glyphName(icon.getAttribute('data-icon')));
|
|
212
|
+
// A ligature font draws the glyph its text names, and the icon is hidden so readers skip that word.
|
|
213
|
+
if ([...icon.classList].some(token => /^material-(?:icons|symbols)/.test(token))) {
|
|
214
|
+
named.push(glyphName(icon.textContent.replace(/\s+/g, '_')));
|
|
215
|
+
}
|
|
216
|
+
const title =
|
|
217
|
+
icon.getAttribute('aria-label') || (icon.localName === 'svg' ? icon.querySelector(':scope > title')?.textContent : '');
|
|
218
|
+
if (title?.trim()) glyphs.add(title.replace(/\s+/g, ' ').trim());
|
|
219
|
+
else for (const name of named) if (name) glyphs.add(`${name.replace(/[-_]/g, ' ')} icon`);
|
|
220
|
+
}
|
|
221
|
+
return glyphs.size === 1 ? [...glyphs][0] : '';
|
|
222
|
+
};
|
|
223
|
+
// An accessible description is what an unnamed control has left that says what it does.
|
|
224
|
+
const describedBy = e => {
|
|
225
|
+
const root = typeof e.getRootNode === 'function' ? e.getRootNode() : document;
|
|
226
|
+
return (e.getAttribute('aria-describedby') || '')
|
|
227
|
+
.split(/\s+/)
|
|
228
|
+
.map(id => (id && (root.getElementById ? root.getElementById(id) : document.getElementById(id))?.textContent) || '')
|
|
229
|
+
.map(text => text.replace(/\s+/g, ' ').trim())
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.join(' ');
|
|
232
|
+
};
|
|
172
233
|
const labelOf = (e, seen = new Set()) => {
|
|
173
234
|
if (!e || seen.has(e)) return '';
|
|
174
235
|
seen.add(e);
|
|
@@ -233,7 +294,7 @@
|
|
|
233
294
|
const DATE_TYPES = ['date', 'datetime-local', 'month', 'week', 'time'];
|
|
234
295
|
|
|
235
296
|
const roleOf = e => {
|
|
236
|
-
if (sourceOf(e) !== e) return sourceOf(e).type;
|
|
297
|
+
if (sourceOf(e) !== e) return sourceOf(e).type === 'file' ? 'textbox' : sourceOf(e).type;
|
|
237
298
|
const explicit = e.getAttribute('role');
|
|
238
299
|
if (ARIA_ROLES.includes(explicit)) return explicit;
|
|
239
300
|
if (e.tagName === 'BUTTON' || e.tagName === 'SUMMARY') return 'button';
|
|
@@ -259,6 +320,7 @@
|
|
|
259
320
|
|
|
260
321
|
const submitSemantics = e => {
|
|
261
322
|
const form = e.form;
|
|
323
|
+
const explicit = ['BUTTON', 'INPUT'].includes(e.tagName) && ['submit', 'image'].includes(e.type);
|
|
262
324
|
const implicit = [
|
|
263
325
|
'text',
|
|
264
326
|
'search',
|
|
@@ -273,8 +335,8 @@
|
|
|
273
335
|
'datetime-local',
|
|
274
336
|
'number',
|
|
275
337
|
];
|
|
276
|
-
if (!form || e.tagName !== 'INPUT' || !implicit.includes(e.type)) return null;
|
|
277
|
-
const submit = [...form.getRootNode().querySelectorAll('button,input')].find(
|
|
338
|
+
if (!form || (!explicit && (e.tagName !== 'INPUT' || !implicit.includes(e.type)))) return null;
|
|
339
|
+
const submit = explicit ? e : [...form.getRootNode().querySelectorAll('button,input')].find(
|
|
278
340
|
c => c.form === form && (c.type === 'submit' || c.type === 'image'),
|
|
279
341
|
);
|
|
280
342
|
if (submit?.matches(':disabled')) return null;
|
|
@@ -311,7 +373,7 @@
|
|
|
311
373
|
} catch {
|
|
312
374
|
inner = null;
|
|
313
375
|
}
|
|
314
|
-
if (inner && inner.body) yield* walk(inner);
|
|
376
|
+
if (inner && inner.body && granted(inner)) yield* walk(inner);
|
|
315
377
|
else inaccessible++;
|
|
316
378
|
}
|
|
317
379
|
}
|
|
@@ -342,11 +404,15 @@
|
|
|
342
404
|
e.ownerDocument.location.origin,
|
|
343
405
|
e.ownerDocument.defaultView.performance.timeOrigin,
|
|
344
406
|
submitSemantics(e),
|
|
407
|
+
source.getAttribute('name'),
|
|
408
|
+
source.getAttribute('autocomplete'),
|
|
345
409
|
identity(source),
|
|
346
410
|
];
|
|
347
411
|
};
|
|
348
412
|
|
|
349
413
|
const controls = [];
|
|
414
|
+
// Controls with no accessible name of their own, whose label is an icon's or only their role.
|
|
415
|
+
const unnamed = new Set();
|
|
350
416
|
const fieldScope = e => {
|
|
351
417
|
if (!e.matches('input,textarea,select')) return null;
|
|
352
418
|
const form = e.form || e.closest('form,fieldset,[role="form"]');
|
|
@@ -376,16 +442,28 @@
|
|
|
376
442
|
rname = roleOf(e);
|
|
377
443
|
if (!rname || r.width <= 0 || r.height <= 0 || x < 0 || x >= innerWidth) continue;
|
|
378
444
|
if (rname === 'gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
445
|
+
// A button-styled link offers the same label twice, but its inner button owns every useful click point.
|
|
446
|
+
if (rname === 'link') {
|
|
447
|
+
const nested = e.querySelectorAll(SELECTOR);
|
|
448
|
+
const button = nested.length === 1 ? nested[0] : null;
|
|
449
|
+
if (button?.tagName === 'BUTTON' && visible(button) && !button.matches(':disabled') &&
|
|
450
|
+
!button.closest('[aria-disabled="true"],[inert]') && labelOf(button) === labelOf(e)) continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
379
453
|
const id = identity(e);
|
|
380
454
|
registry.controls.add(e);
|
|
455
|
+
const named = labelOf(e);
|
|
456
|
+
if (!named) unnamed.add(id);
|
|
381
457
|
const base = {
|
|
382
458
|
id,
|
|
383
459
|
role: rname,
|
|
384
|
-
label:
|
|
460
|
+
label: named || iconLabel(e) || describedBy(e) || rname,
|
|
385
461
|
offscreen: y < 0 || y >= innerHeight,
|
|
386
462
|
distance: y < 0 || y >= innerHeight ? 1 + Math.abs(y - innerHeight / 2) : 0,
|
|
387
463
|
sensitive: secret(source),
|
|
388
464
|
input_type: source.type || null,
|
|
465
|
+
input_name: source.getAttribute('name') || null,
|
|
466
|
+
autocomplete: source.getAttribute('autocomplete') || null,
|
|
389
467
|
frame_origin: e.ownerDocument.location.origin,
|
|
390
468
|
frame_path: framePath(e.ownerDocument),
|
|
391
469
|
form_id: fieldScope(source),
|
|
@@ -396,6 +474,8 @@
|
|
|
396
474
|
base.href = u.origin === location.origin ? u.pathname + u.search : u.host + u.pathname;
|
|
397
475
|
// The page's own word for "the next page of this list", which survives an icon label or another language.
|
|
398
476
|
if (e.rel && e.rel.split(/\s+/).includes('next')) base.next_page = true;
|
|
477
|
+
// The page's own word that this link saves a file, which a task ruling out downloads has to see before a click.
|
|
478
|
+
if (e.hasAttribute('download')) base.download = true;
|
|
399
479
|
}
|
|
400
480
|
for (const key of ['checked', 'selected', 'expanded']) {
|
|
401
481
|
const value = e.getAttribute('aria-' + key);
|
|
@@ -419,7 +499,7 @@
|
|
|
419
499
|
base.operations = ['select'];
|
|
420
500
|
base.options = [...e.options].filter(o => !o.disabled && !o.closest('optgroup[disabled]')).map(o => o.label);
|
|
421
501
|
base.value = [...e.selectedOptions].map(o => o.label).join(', ');
|
|
422
|
-
} else if (
|
|
502
|
+
} else if (source.type === 'file') {
|
|
423
503
|
base.operations = ['upload'];
|
|
424
504
|
base.value = null;
|
|
425
505
|
} else {
|
|
@@ -496,6 +576,9 @@
|
|
|
496
576
|
for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
|
|
497
577
|
if (twins.some(twin => twin !== element && e.contains(twin))) break;
|
|
498
578
|
scope = e;
|
|
579
|
+
// A lone open reply has no form twin nearby; crossing its record picks a different commenter's name.
|
|
580
|
+
if (e.matches('article,li,[role="listitem"]') ||
|
|
581
|
+
(e.matches('tr,[role="row"]') && e.querySelector('input,textarea,select,[role="textbox"]'))) break;
|
|
499
582
|
}
|
|
500
583
|
return scope ? excerpt(nameOf(scope, element, label, nearest), CONTROL_CONTEXT_CHARS) : '';
|
|
501
584
|
};
|
|
@@ -503,7 +586,7 @@
|
|
|
503
586
|
// operation. An element whose hover rule would reveal something now hidden is offered as a hover target.
|
|
504
587
|
const docs = [];
|
|
505
588
|
const addDoc = doc => {
|
|
506
|
-
if (!doc?.body || docs.includes(doc)) return;
|
|
589
|
+
if (!doc?.body || !granted(doc) || docs.includes(doc)) return;
|
|
507
590
|
docs.push(doc);
|
|
508
591
|
for (const frame of doc.querySelectorAll('iframe,frame')) {
|
|
509
592
|
try {
|
|
@@ -581,12 +664,14 @@
|
|
|
581
664
|
} catch {
|
|
582
665
|
continue;
|
|
583
666
|
}
|
|
584
|
-
|
|
667
|
+
// Removing the active hover target renumbers its identical siblings and sends the next choice
|
|
668
|
+
// to a different element. Keep it offered while the pointer still reveals its content.
|
|
669
|
+
if (!hidden && !e.matches(':hover')) continue;
|
|
585
670
|
const image = e.querySelector('img[alt]');
|
|
586
671
|
const label =
|
|
587
|
-
firstLine(e.innerText) ||
|
|
588
672
|
e.getAttribute('aria-label') ||
|
|
589
673
|
image?.getAttribute('alt') ||
|
|
674
|
+
firstLine(e.innerText) ||
|
|
590
675
|
e.getAttribute('title') ||
|
|
591
676
|
e.tagName.toLowerCase();
|
|
592
677
|
const role =
|
|
@@ -664,6 +749,58 @@
|
|
|
664
749
|
}
|
|
665
750
|
}
|
|
666
751
|
|
|
752
|
+
// An unnamed control the page draws once per card, a trash icon beside each image, is a twin only while another
|
|
753
|
+
// card shows the same icon: one left alone, or drawn by another icon set, would name no card and leave the choice
|
|
754
|
+
// guessing which image it removes. It is named by the card that holds it, or failing one by the nearest ancestor
|
|
755
|
+
// that says anything besides it.
|
|
756
|
+
// A card is an item, a row, or an element the page repeats beside itself with the same tag and class.
|
|
757
|
+
const isCard = e =>
|
|
758
|
+
e.matches('article,li,[role="listitem"],tr,[role="row"]') ||
|
|
759
|
+
(!!e.getAttribute('class') &&
|
|
760
|
+
[...(e.parentElement?.children || [])].some(
|
|
761
|
+
s => s !== e && s.localName === e.localName && s.getAttribute('class') === e.getAttribute('class'),
|
|
762
|
+
));
|
|
763
|
+
const nearestName = (element, label) => {
|
|
764
|
+
let first = '';
|
|
765
|
+
for (let e = element.parentElement, path = element; e && e !== e.ownerDocument.body; path = e, e = e.parentElement) {
|
|
766
|
+
first ||= nameOf(e, element, label, true);
|
|
767
|
+
if (!isCard(e)) continue;
|
|
768
|
+
// An action row's own caption ("Actions") is the first text the climb meets and names no item, so the card
|
|
769
|
+
// is named by what it holds outside the row that holds the control.
|
|
770
|
+
const aria = (e.getAttribute('aria-label') || '').trim();
|
|
771
|
+
const outside = [...e.children]
|
|
772
|
+
.filter(child => child !== path)
|
|
773
|
+
.map(child => firstLine(child.innerText))
|
|
774
|
+
.find(line => line && line !== label && !repeated.has(line));
|
|
775
|
+
return excerpt((aria !== label && aria) || outside || first, CONTROL_CONTEXT_CHARS);
|
|
776
|
+
}
|
|
777
|
+
return excerpt(first, CONTROL_CONTEXT_CHARS);
|
|
778
|
+
};
|
|
779
|
+
for (const c of controls) {
|
|
780
|
+
if (!c.context && unnamed.has(c.id)) {
|
|
781
|
+
const context = nearestName(registry.nodes.get(c.id), c.label);
|
|
782
|
+
if (context) c.context = context;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// A revealed caption needs its actual container association, not merely the most recent action.
|
|
787
|
+
registry.hoverContexts = new WeakMap();
|
|
788
|
+
for (const c of controls) {
|
|
789
|
+
if (!c.operations.includes('hover')) continue;
|
|
790
|
+
const element = registry.nodes.get(c.id);
|
|
791
|
+
registry.hoverContexts.set(element, {
|
|
792
|
+
control: {
|
|
793
|
+
role: c.role, label: c.label,
|
|
794
|
+
context: /^\d+ of \d+$/.test(c.context || '') ? c.context : null,
|
|
795
|
+
},
|
|
796
|
+
labelAttributes: [element.getAttribute('role'), element.getAttribute('aria-label'),
|
|
797
|
+
element.getAttribute('title'), element.querySelector('img[alt]')?.getAttribute('alt') || null],
|
|
798
|
+
text: element.innerText,
|
|
799
|
+
parent: element.parentNode,
|
|
800
|
+
siblings: [...(element.parentNode?.children || [])],
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
667
804
|
// A date field's own label ("Start Date") rarely says which picker it belongs to when a page offers several
|
|
668
805
|
// ("Date Picker 3"): unlike a colliding label, a uniquely named one never reaches the loop above, so the
|
|
669
806
|
// nearest ancestor that names a section is attached here on its own, without the twin-avoidance `contextOf`
|
|
@@ -777,7 +914,7 @@
|
|
|
777
914
|
} catch {
|
|
778
915
|
inner = null;
|
|
779
916
|
}
|
|
780
|
-
if (!inner) continue;
|
|
917
|
+
if (!inner || !granted(inner)) continue;
|
|
781
918
|
const f = frame.getBoundingClientRect();
|
|
782
919
|
readText(inner, dx + f.left + frame.clientLeft, dy + f.top + frame.clientTop);
|
|
783
920
|
}
|