@fastbrowse/darwin-arm64 0.7.1 → 0.7.4
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/fastbrowse +0 -0
- package/fastbrowse/fastbrowse-mcp +0 -0
- package/fastbrowse.node +0 -0
- package/package.json +3 -2
- package/fastbrowse/_internal/base_library.zip +0 -0
- package/fastbrowse/_internal/certifi/cacert.pem +0 -3959
- package/fastbrowse/_internal/certifi/py.typed +0 -0
- package/fastbrowse/_internal/fastbrowse/browser/autoconsent/LICENSE +0 -373
- package/fastbrowse/_internal/fastbrowse/browser/autoconsent/SOURCE.txt +0 -20
- package/fastbrowse/_internal/fastbrowse/browser/autoconsent/autoconsent.standalone.js +0 -3543
- package/fastbrowse/_internal/fastbrowse/browser/capture.js +0 -499
- package/fastbrowse/_internal/fastbrowse/browser/snapshot.js +0 -1098
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/INSTALLER +0 -1
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/METADATA +0 -616
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/RECORD +0 -14
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/REQUESTED +0 -0
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/WHEEL +0 -4
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/direct_url.json +0 -1
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/entry_points.txt +0 -3
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/licenses/LICENSE +0 -21
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/licenses/NOTICE +0 -33
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/uv_build.json +0 -1
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/uv_cache.json +0 -1
- package/fastbrowse/_internal/libpython3.13.dylib +0 -0
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/INSTALLER +0 -1
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/METADATA +0 -1294
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/RECORD +0 -113
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/REQUESTED +0 -0
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/WHEEL +0 -4
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/licenses/LICENSE +0 -21
- package/fastbrowse/_internal/pydantic_core/_pydantic_core.cpython-313-darwin.so +0 -0
- package/fastbrowse/_internal/setuptools/_vendor/jaraco/text/Lorem ipsum.txt +0 -2
- package/fastbrowse/_internal/websockets/speedups.cpython-313-darwin.so +0 -0
- package/fastbrowse/_internal/websockets-17.2.dist-info/INSTALLER +0 -1
- package/fastbrowse/_internal/websockets-17.2.dist-info/METADATA +0 -170
- package/fastbrowse/_internal/websockets-17.2.dist-info/RECORD +0 -65
- package/fastbrowse/_internal/websockets-17.2.dist-info/REQUESTED +0 -0
- package/fastbrowse/_internal/websockets-17.2.dist-info/WHEEL +0 -6
- package/fastbrowse/_internal/websockets-17.2.dist-info/entry_points.txt +0 -2
- package/fastbrowse/_internal/websockets-17.2.dist-info/licenses/LICENSE +0 -24
- package/fastbrowse/_internal/websockets-17.2.dist-info/top_level.txt +0 -1
|
@@ -1,499 +0,0 @@
|
|
|
1
|
-
// Complete structured text capture of one frame, as ordered blocks with heading paths.
|
|
2
|
-
// Unlike snapshot.js (bounded, for Jev's action choice) this reads the whole rendered document, on screen or not,
|
|
3
|
-
// so the reading path and answer-evidence quotes see everything a user could scroll to.
|
|
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 function inside an expression, where a semicolon is invalid.
|
|
6
|
-
((allowedOrigins = null) => {
|
|
7
|
-
if (!document.body) return null;
|
|
8
|
-
const HEADINGS = { H1: 1, H2: 2, H3: 3, H4: 4, H5: 5, H6: 6 };
|
|
9
|
-
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'CANVAS', 'IFRAME']);
|
|
10
|
-
const blocks = [];
|
|
11
|
-
let path = [];
|
|
12
|
-
let framePath = null;
|
|
13
|
-
let sourcePath = '';
|
|
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
|
-
};
|
|
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
|
-
};
|
|
43
|
-
const identity = e => {
|
|
44
|
-
if (!registry.ids.has(e)) registry.ids.set(e, registry.next++);
|
|
45
|
-
return registry.ids.get(e);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const clean = s =>
|
|
49
|
-
s
|
|
50
|
-
.replace(/[ \t\r\f\v]+/g, ' ')
|
|
51
|
-
.replace(/ *\n */g, '\n')
|
|
52
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
53
|
-
.trim();
|
|
54
|
-
const textOf = e => clean(e.innerText ?? e.textContent ?? '');
|
|
55
|
-
const hidden = e => e.hidden || getComputedStyle(e).display === 'none';
|
|
56
|
-
const isBlock = e => {
|
|
57
|
-
const display = getComputedStyle(e).display;
|
|
58
|
-
return !(display.startsWith('inline') || display === 'contents') || e.tagName === 'BR';
|
|
59
|
-
};
|
|
60
|
-
const hrefOf = a => {
|
|
61
|
-
const u = new URL(a.href, location.href);
|
|
62
|
-
return u.origin === location.origin ? u.pathname + u.search : u.href;
|
|
63
|
-
};
|
|
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 = []) => {
|
|
123
|
-
if (text)
|
|
124
|
-
blocks.push({ kind, text, heading_path: [...path], frame_path: framePath, source_path: sourcePath, control_context: controlOf(nodes), ...listContext(nodes), ...extra });
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// A cell can say what it says with an image or an icon alone: a flag marking the winner, a tick for "yes".
|
|
128
|
-
// Its text is then empty and the table reads as if the cell were blank, so name what is drawn there: the
|
|
129
|
-
// image's alt text or label, or failing that the icon font's class, which is how those fonts name a glyph.
|
|
130
|
-
const ICON_CLASS = /^(?:glyphicon|fa|fas|far|bi|mdi|icon|ti|la)-(.+)$/;
|
|
131
|
-
// Font sizes, weights and effects share the prefix without naming a glyph.
|
|
132
|
-
const ICON_STYLE =
|
|
133
|
-
/^(?:solid|regular|light|thin|duotone|brands|xs|sm|lg|xl|\d+x|fw|spin|pulse|border|inverse|stack.*|pull-.*|rotate-.*|flip-.*)$/;
|
|
134
|
-
const drawn = c => {
|
|
135
|
-
for (const e of c.querySelectorAll('img,svg,i,span,[role="img"]')) {
|
|
136
|
-
if (hidden(e)) continue;
|
|
137
|
-
const named =
|
|
138
|
-
e.getAttribute('alt') ||
|
|
139
|
-
e.getAttribute('aria-label') ||
|
|
140
|
-
e.getAttribute('title') ||
|
|
141
|
-
e.querySelector?.(':scope > title')?.textContent;
|
|
142
|
-
if (named?.trim()) return `[${clean(named)}]`;
|
|
143
|
-
for (const token of e.classList) {
|
|
144
|
-
const glyph = ICON_CLASS.exec(token);
|
|
145
|
-
if (glyph && !ICON_STYLE.test(glyph[1])) return `[${glyph[1].replace(/-/g, ' ')} icon]`;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return '';
|
|
149
|
-
};
|
|
150
|
-
// A rating widget states its value only in markup a reader cannot see: a `star-rating` class carrying a
|
|
151
|
-
// One..Five word token, or an accessible label the icons alone do not carry. Counting icons never works,
|
|
152
|
-
// since a site colours a fixed row of them by class instead of drawing one per star (books.toscrape.com:
|
|
153
|
-
// `<p class="star-rating Five">`, five identical `<i class="icon-star">` children whatever the rating).
|
|
154
|
-
// Only these two explicit sources are trusted; a class that merely mentions "star" or an icon glyph is not
|
|
155
|
-
// read as a rating, and a hidden widget states nothing a reader could see either.
|
|
156
|
-
const RATING_WORD = { one: 'One', two: 'Two', three: 'Three', four: 'Four', five: 'Five' };
|
|
157
|
-
const RATING_LABEL = /\b[1-5]\s*(?:\/|out of)\s*5\b|\b[1-5]\s*stars?\b/i;
|
|
158
|
-
const ratingOf = e => {
|
|
159
|
-
if (hidden(e) || getComputedStyle(e).visibility === 'hidden') return '';
|
|
160
|
-
const classes = [...e.classList].map(c => c.toLowerCase());
|
|
161
|
-
if (classes.includes('star-rating')) {
|
|
162
|
-
for (const token of classes) if (RATING_WORD[token]) return `${RATING_WORD[token]} stars`;
|
|
163
|
-
}
|
|
164
|
-
const label = e.getAttribute('aria-label') || e.getAttribute('title');
|
|
165
|
-
return label && RATING_LABEL.test(label) ? clean(label) : '';
|
|
166
|
-
};
|
|
167
|
-
// Checked on the element itself, then any descendant a class or role names as the widget, since a
|
|
168
|
-
// role="img" label commonly sits one level below the card holding the title and price. A widget hidden
|
|
169
|
-
// by an ancestor (rather than itself) states nothing a reader could see either, so ratingOf's own
|
|
170
|
-
// `hidden` check is not enough: walk up to the search root and reject any candidate under a hidden one.
|
|
171
|
-
const ratingIn = c =>
|
|
172
|
-
ratingOf(c) ||
|
|
173
|
-
[...c.querySelectorAll('[class*="star" i], [class*="rating" i], [role="img"]')]
|
|
174
|
-
.filter(e => {
|
|
175
|
-
for (let a = e.parentElement; a && a !== c; a = a.parentElement) if (hidden(a)) return false;
|
|
176
|
-
return true;
|
|
177
|
-
})
|
|
178
|
-
.map(ratingOf)
|
|
179
|
-
.find(Boolean) ||
|
|
180
|
-
'';
|
|
181
|
-
const withRating = (text, c) => {
|
|
182
|
-
const rating = ratingIn(c);
|
|
183
|
-
if (!rating || text.includes(rating)) return text;
|
|
184
|
-
return text ? `${text} (${rating})` : rating;
|
|
185
|
-
};
|
|
186
|
-
const cellText = c => withRating(textOf(c) || drawn(c), c);
|
|
187
|
-
|
|
188
|
-
// What a form holds is in its controls' values, which no text node carries: a task that asked for dates to be
|
|
189
|
-
// chosen was refused as unanswered because "the dates chosen" had nothing to quote. A filled control reads as
|
|
190
|
-
// "Label: value" after the text around it. Password and agent-typed secret values never leave the page.
|
|
191
|
-
const CONTROLS = new Set(['INPUT', 'SELECT', 'TEXTAREA']);
|
|
192
|
-
const UNFILLED = new Set(['hidden', 'password', 'checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file']);
|
|
193
|
-
const nameOf = (e, seen = new Set()) => {
|
|
194
|
-
if (!e || seen.has(e)) return '';
|
|
195
|
-
seen.add(e);
|
|
196
|
-
const root = e.getRootNode?.() ?? document;
|
|
197
|
-
const byId = id => (root.getElementById ? root : document).getElementById(id);
|
|
198
|
-
const referenced = (e.getAttribute('aria-labelledby') || '')
|
|
199
|
-
.split(/\s+/)
|
|
200
|
-
.filter(Boolean)
|
|
201
|
-
.map(id => nameOf(byId(id), seen))
|
|
202
|
-
.filter(Boolean)
|
|
203
|
-
.join(' ');
|
|
204
|
-
return clean(
|
|
205
|
-
referenced ||
|
|
206
|
-
e.getAttribute('aria-label') ||
|
|
207
|
-
[...(e.labels || [])]
|
|
208
|
-
.map(l => nameOf(l, seen))
|
|
209
|
-
.filter(Boolean)
|
|
210
|
-
.join(' ') ||
|
|
211
|
-
(CONTROLS.has(e.tagName)
|
|
212
|
-
? ''
|
|
213
|
-
: [...e.childNodes]
|
|
214
|
-
.map(n =>
|
|
215
|
-
n.nodeType === 3
|
|
216
|
-
? n.textContent
|
|
217
|
-
: n.nodeType === 1 && !SKIP.has(n.tagName) && !CONTROLS.has(n.tagName)
|
|
218
|
-
? nameOf(n, seen)
|
|
219
|
-
: '',
|
|
220
|
-
)
|
|
221
|
-
.join(' ')) ||
|
|
222
|
-
e.getAttribute('title') ||
|
|
223
|
-
e.getAttribute('placeholder') ||
|
|
224
|
-
e.getAttribute('name') ||
|
|
225
|
-
'',
|
|
226
|
-
);
|
|
227
|
-
};
|
|
228
|
-
const fieldValue = e => {
|
|
229
|
-
if (e.tagName === 'SELECT') return [...e.selectedOptions].map(o => clean(o.label || o.text)).join(', ');
|
|
230
|
-
if (UNFILLED.has(e.type) || e.dataset?.fastbrowseSecret === '1') return '';
|
|
231
|
-
return clean(e.value ?? '');
|
|
232
|
-
};
|
|
233
|
-
const fields = el =>
|
|
234
|
-
(CONTROLS.has(el.tagName) ? [el] : [...el.querySelectorAll('input,select,textarea')])
|
|
235
|
-
.filter(e => (e.checkVisibility ? e.checkVisibility({ visibilityProperty: true }) : !hidden(e)))
|
|
236
|
-
.map(e => [nameOf(e), fieldValue(e)])
|
|
237
|
-
.filter(([, value]) => value)
|
|
238
|
-
.map(([name, value]) => (name ? `${name}: ${value}` : value));
|
|
239
|
-
|
|
240
|
-
const renderTable = table => {
|
|
241
|
-
// A table filter hides what it excludes with `hidden`, display or visibility (`collapse` is the one CSS made
|
|
242
|
-
// for rows), and a column toggle hides cells; read as rows, a filtered table answered from rows not shown.
|
|
243
|
-
// Visibility inherits, so the row's own style covers a collapsed section; display does not, so its section
|
|
244
|
-
// is checked too. A cell hidden by visibility still holds its column, so it stays, blank, and the cells
|
|
245
|
-
// after it keep their headers.
|
|
246
|
-
const rows = [
|
|
247
|
-
...table.querySelectorAll(':scope > tr, :scope > thead > tr, :scope > tbody > tr, :scope > tfoot > tr'),
|
|
248
|
-
]
|
|
249
|
-
.filter(row => !hidden(row) && getComputedStyle(row).visibility === 'visible' && !hidden(row.parentElement))
|
|
250
|
-
.map(row => ({ row, cells: [...row.querySelectorAll(':scope > th, :scope > td')].filter(c => !hidden(c)) }))
|
|
251
|
-
.filter(({ cells }) => cells.length);
|
|
252
|
-
if (!rows.length) return [];
|
|
253
|
-
let headers = rows.filter(({ row }) => row.parentElement.tagName === 'THEAD');
|
|
254
|
-
if (!headers.length && rows[0].cells.every(c => c.tagName === 'TH')) headers = [rows[0]];
|
|
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)));
|
|
258
|
-
const prefix = headers.map(line);
|
|
259
|
-
if (headers.length) prefix.push('| ' + headers[0].cells.map(() => '---').join(' | ') + ' |');
|
|
260
|
-
const data = rows.filter(row => !headers.includes(row));
|
|
261
|
-
return data.length ? data.map(row => [...prefix, line(row)].join('\n')) : [prefix.join('\n')];
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
function flush(run) {
|
|
265
|
-
if (!run.length) return;
|
|
266
|
-
const elements = run.filter(n => n.nodeType === 1);
|
|
267
|
-
let text = clean(run.map(n => (n.nodeType === 3 ? n.textContent : (n.innerText ?? ''))).join(''));
|
|
268
|
-
const onlyLink =
|
|
269
|
-
elements.length === 1 &&
|
|
270
|
-
elements[0].tagName === 'A' &&
|
|
271
|
-
elements[0].href &&
|
|
272
|
-
clean(elements[0].innerText ?? '') === text;
|
|
273
|
-
for (const e of elements) text = withRating(text, e);
|
|
274
|
-
if (onlyLink) push('link', text, { href: hrefOf(elements[0]) }, run);
|
|
275
|
-
else push('paragraph', text, {}, run);
|
|
276
|
-
for (const e of elements) for (const field of fields(e)) push('paragraph', field);
|
|
277
|
-
run.length = 0;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function leaf(el) {
|
|
281
|
-
return ![...el.children].some(c => !SKIP.has(c.tagName) && !hidden(c) && isBlock(c));
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// A classless div repeated three times is usually page layout; a record is a styled unit or a semantic item.
|
|
285
|
-
function repeated(el) {
|
|
286
|
-
if (!el.classList.length && el.tagName !== 'ARTICLE' && el.tagName !== 'LI') return false;
|
|
287
|
-
const siblings = [...(el.parentNode?.children ?? [])].filter(
|
|
288
|
-
sibling =>
|
|
289
|
-
sibling.tagName === el.tagName &&
|
|
290
|
-
sibling.classList.length === el.classList.length &&
|
|
291
|
-
[...el.classList].every(token => sibling.classList.contains(token)),
|
|
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)))
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const records = new WeakMap();
|
|
307
|
-
function recordText(el) {
|
|
308
|
-
if (records.has(el)) return records.get(el);
|
|
309
|
-
records.set(el, null);
|
|
310
|
-
if (!repeated(el) || el.shadowRoot || el.querySelector('table,iframe,frame')) return null;
|
|
311
|
-
const descendants = [...el.querySelectorAll('*')];
|
|
312
|
-
if (descendants.some(c => c.shadowRoot) || descendants.filter(c => HEADINGS[c.tagName]).length > 1) return null;
|
|
313
|
-
if (!descendants.some(c => !SKIP.has(c.tagName) && !hidden(c) && isBlock(c))) return null;
|
|
314
|
-
const text = withRating(textOf(el), el);
|
|
315
|
-
if (!text || text.length > 1500) return null;
|
|
316
|
-
// A repeated wrapper around another list is a group, so keep the inner records separately citable.
|
|
317
|
-
if (descendants.some(c => (c.tagName === 'LI' && leaf(c)) || recordText(c) !== null)) return null;
|
|
318
|
-
records.set(el, text);
|
|
319
|
-
return text;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function walk(el) {
|
|
323
|
-
const run = [];
|
|
324
|
-
for (const node of el.childNodes) {
|
|
325
|
-
if (node.nodeType === 3) {
|
|
326
|
-
run.push(node);
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
329
|
-
if (node.nodeType !== 1 || hidden(node)) continue;
|
|
330
|
-
if (FRAMES.has(node.tagName)) {
|
|
331
|
-
flush(run);
|
|
332
|
-
enter(node, path);
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
if (SKIP.has(node.tagName)) continue;
|
|
336
|
-
if (!node.matches('nav,[role="navigation"]') && !isBlock(node) && recordText(node) === null) {
|
|
337
|
-
run.push(node);
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
flush(run);
|
|
341
|
-
block(node);
|
|
342
|
-
}
|
|
343
|
-
flush(run);
|
|
344
|
-
}
|
|
345
|
-
|
|
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
|
-
}
|
|
354
|
-
const level = HEADINGS[el.tagName];
|
|
355
|
-
if (level) {
|
|
356
|
-
const text = textOf(el);
|
|
357
|
-
while (path.length >= level) path.pop();
|
|
358
|
-
if (text) {
|
|
359
|
-
push('heading', text, {}, [el]);
|
|
360
|
-
path.push(text);
|
|
361
|
-
}
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
if (el.tagName === 'TABLE') {
|
|
365
|
-
for (const text of renderTable(el)) push('table', text, {}, [el]);
|
|
366
|
-
// A form laid out in a table: its cells render as text, which holds none of the controls' values.
|
|
367
|
-
for (const field of fields(el)) push('paragraph', field);
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
if (el.tagName === 'PRE') return push('code', textOf(el), {}, [el]);
|
|
371
|
-
const record = recordText(el);
|
|
372
|
-
if ((el.tagName === 'LI' && leaf(el)) || record !== null) {
|
|
373
|
-
const links = el.matches('a[href]') ? [el] : el.querySelectorAll('a[href]');
|
|
374
|
-
push(
|
|
375
|
-
el.tagName === 'LI' ? 'list_item' : 'record',
|
|
376
|
-
record ?? withRating(textOf(el), el),
|
|
377
|
-
links.length === 1 ? { href: hrefOf(links[0]) } : {},
|
|
378
|
-
[el],
|
|
379
|
-
);
|
|
380
|
-
for (const field of fields(el)) push('paragraph', field);
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
if (CONTROLS.has(el.tagName)) {
|
|
384
|
-
for (const field of fields(el)) push('paragraph', field);
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
walk(el);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// A same-origin frame is read where it stands, under the headings above it: read after the whole page, an
|
|
391
|
-
// embedded subscription form's heading followed the footer, and the reader, asked for the heading the
|
|
392
|
-
// frame shows, answered with the page's heading over the frame instead.
|
|
393
|
-
const FRAMES = new Set(['IFRAME', 'FRAME']);
|
|
394
|
-
const entered = new WeakSet();
|
|
395
|
-
function enter(e, headings) {
|
|
396
|
-
entered.add(e);
|
|
397
|
-
let inner = null;
|
|
398
|
-
try {
|
|
399
|
-
inner = e.contentDocument;
|
|
400
|
-
} catch {
|
|
401
|
-
inner = null;
|
|
402
|
-
}
|
|
403
|
-
if (!inner?.body || !granted(inner)) {
|
|
404
|
-
inaccessible++;
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
const child = `${framePath || 'root'}/${identity(e)}`;
|
|
408
|
-
scope(inner.body, child, child, headings);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function scope(root, frame, source, headings = []) {
|
|
412
|
-
const previous = [path, framePath, sourcePath];
|
|
413
|
-
path = [...headings];
|
|
414
|
-
framePath = frame;
|
|
415
|
-
sourcePath = source;
|
|
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
|
-
}
|
|
488
|
-
for (const e of root.querySelectorAll('*')) {
|
|
489
|
-
if (e.shadowRoot) scope(e.shadowRoot, frame, `${source}/shadow:${identity(e)}`);
|
|
490
|
-
// A frame the walk never reached (hidden, or inside a record or table read as one block) is still read.
|
|
491
|
-
if (FRAMES.has(e.tagName) && !entered.has(e)) enter(e, []);
|
|
492
|
-
}
|
|
493
|
-
[path, framePath, sourcePath] = previous;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
scope(document.body, null, '');
|
|
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
|
-
})
|