barebrowse 0.14.0 → 0.17.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/CHANGELOG.md +157 -0
- package/README.md +33 -1
- package/barebrowse.context.md +5 -2
- package/cli.js +10 -2
- package/mcp-server.js +14 -2
- package/package.json +1 -1
- package/src/auth.js +27 -13
- package/src/ax-snapshot.js +287 -0
- package/src/bidi.js +143 -0
- package/src/consent-firefox.js +125 -0
- package/src/consent-patterns.js +154 -0
- package/src/consent.js +5 -144
- package/src/daemon.js +162 -51
- package/src/firefox-page.js +445 -0
- package/src/firefox.js +203 -0
- package/src/index.js +88 -6
- package/src/network-idle.js +66 -28
- package/src/readable.js +32 -20
- package/src/stealth-firefox.js +42 -0
- package/src/stealth.js +107 -69
- package/types/auth.d.ts +18 -1
- package/types/ax-snapshot.d.ts +35 -0
- package/types/bidi.d.ts +10 -0
- package/types/consent-firefox.d.ts +9 -0
- package/types/consent-patterns.d.ts +12 -0
- package/types/daemon.d.ts +34 -0
- package/types/firefox-page.d.ts +21 -0
- package/types/firefox.d.ts +41 -0
- package/types/index.d.ts +13 -2
- package/types/network-idle.d.ts +16 -8
- package/types/readable.d.ts +8 -0
- package/types/stealth-firefox.d.ts +9 -0
- package/types/stealth.d.ts +31 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// @ts-nocheck — AX_FN below is browser-context code (document, getComputedStyle,
|
|
2
|
+
// CSS, real regex literals) serialized via .toString() and run inside Firefox,
|
|
3
|
+
// never in Node. tsc's Node lib (no DOM) can't meaningfully check it, and the
|
|
4
|
+
// readable.js string convention would corrupt its regex backslashes, so the
|
|
5
|
+
// file opts out of type-checking. The Node-side exports are trivial.
|
|
6
|
+
/**
|
|
7
|
+
* ax-snapshot.js — Reconstruct a CDP-shaped accessibility tree in-page.
|
|
8
|
+
*
|
|
9
|
+
* CDP hands you Accessibility.getFullAXTree; BiDi has no equivalent, so on
|
|
10
|
+
* Firefox we rebuild an equivalent tree inside the page via script.evaluate.
|
|
11
|
+
* The output tree uses the SAME node shape and role vocabulary as
|
|
12
|
+
* buildTree()'s CDP output, so prune.js and aria.js consume it unchanged:
|
|
13
|
+
*
|
|
14
|
+
* { nodeId, role, name, properties, children, ignored }
|
|
15
|
+
*
|
|
16
|
+
* where `role` is CDP/ARIA vocabulary ('RootWebArea', 'StaticText', 'heading',
|
|
17
|
+
* 'link', 'button', 'img', 'paragraph', 'list', 'navigation', …), NOT tag
|
|
18
|
+
* names. The three things getFullAXTree gave us for free and we reimplement:
|
|
19
|
+
* 1. implicit ARIA role mapping (HTML element → role)
|
|
20
|
+
* 2. accessible-name computation (aria-labelledby → aria-label → native
|
|
21
|
+
* label/alt/legend/title → text content) — the POC proved textContent
|
|
22
|
+
* alone is NOT enough (img alt, <label>, aria-labelledby all missed).
|
|
23
|
+
* 3. hidden-subtree filtering (aria-hidden, display:none, visibility:hidden,
|
|
24
|
+
* the hidden attribute).
|
|
25
|
+
*
|
|
26
|
+
* Each kept element is tagged with a data-bb-ref attribute carrying its ref,
|
|
27
|
+
* so firefox-page.js (resolveRef) can resolve a ref back to its element via
|
|
28
|
+
* querySelector.
|
|
29
|
+
* Refs are assigned from a caller-supplied `base` so they stay globally unique
|
|
30
|
+
* across browsing contexts (iframes), matching CDP's flat integer refs.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Attribute used to tag elements for ref → element resolution. */
|
|
34
|
+
export const REF_ATTR = 'data-bb-ref';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The in-page reconstruction function, as source text. Evaluated in a browsing
|
|
38
|
+
* context with a `base` ref offset; returns JSON { tree, count }. Written as a
|
|
39
|
+
* single self-contained function (no closures over module scope) because it
|
|
40
|
+
* runs in the page, not in Node.
|
|
41
|
+
*/
|
|
42
|
+
const AX_FN = function reconstructAX(base, REF_ATTR) {
|
|
43
|
+
let ref = base;
|
|
44
|
+
|
|
45
|
+
// Roles whose accessible name comes from descendant text (so we must NOT
|
|
46
|
+
// also emit child StaticText nodes — CDP folds the text into the name).
|
|
47
|
+
const NAME_FROM_CONTENT = new Set([
|
|
48
|
+
'button', 'link', 'heading', 'cell', 'columnheader', 'rowheader',
|
|
49
|
+
'tab', 'menuitem', 'option', 'treeitem', 'switch', 'checkbox', 'radio',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// HTML tag → implicit ARIA role (the common, high-value subset).
|
|
53
|
+
const TAG_ROLE = {
|
|
54
|
+
A: (el) => (el.hasAttribute('href') ? 'link' : 'generic'),
|
|
55
|
+
BUTTON: () => 'button',
|
|
56
|
+
NAV: () => 'navigation', MAIN: () => 'main', ASIDE: () => 'complementary',
|
|
57
|
+
HEADER: (el) => (isTopLevelLandmark(el) ? 'banner' : 'generic'),
|
|
58
|
+
FOOTER: (el) => (isTopLevelLandmark(el) ? 'contentinfo' : 'generic'),
|
|
59
|
+
FORM: () => 'form', SEARCH: () => 'search',
|
|
60
|
+
SECTION: (el) => (accName(el) ? 'region' : 'generic'),
|
|
61
|
+
ARTICLE: () => 'article',
|
|
62
|
+
H1: () => 'heading', H2: () => 'heading', H3: () => 'heading',
|
|
63
|
+
H4: () => 'heading', H5: () => 'heading', H6: () => 'heading',
|
|
64
|
+
P: () => 'paragraph',
|
|
65
|
+
UL: () => 'list', OL: () => 'list', LI: () => 'listitem',
|
|
66
|
+
DL: () => 'list', DT: () => 'term', DD: () => 'definition',
|
|
67
|
+
IMG: (el) => (el.getAttribute('alt') === '' ? 'none' : 'image'),
|
|
68
|
+
FIGURE: () => 'figure', FIGCAPTION: () => 'Figcaption',
|
|
69
|
+
TABLE: () => 'table', TR: () => 'row', TD: () => 'cell',
|
|
70
|
+
TH: (el) => (el.getAttribute('scope') === 'row' ? 'rowheader' : 'columnheader'),
|
|
71
|
+
THEAD: () => 'rowgroup', TBODY: () => 'rowgroup', TFOOT: () => 'rowgroup',
|
|
72
|
+
SELECT: (el) => (el.multiple ? 'listbox' : 'combobox'),
|
|
73
|
+
TEXTAREA: () => 'textbox',
|
|
74
|
+
OPTION: () => 'option',
|
|
75
|
+
LABEL: () => 'LabelText', SPAN: () => 'generic', DIV: () => 'generic',
|
|
76
|
+
STRONG: () => 'strong', EM: () => 'emphasis', B: () => 'strong', I: () => 'emphasis',
|
|
77
|
+
BLOCKQUOTE: () => 'blockquote', CODE: () => 'code', PRE: () => 'generic',
|
|
78
|
+
HR: () => 'separator',
|
|
79
|
+
IFRAME: () => 'Iframe', FRAME: () => 'Iframe',
|
|
80
|
+
INPUT: (el) => {
|
|
81
|
+
const t = (el.getAttribute('type') || 'text').toLowerCase();
|
|
82
|
+
return ({
|
|
83
|
+
button: 'button', submit: 'button', reset: 'button', image: 'button',
|
|
84
|
+
checkbox: 'checkbox', radio: 'radio', range: 'slider',
|
|
85
|
+
search: 'searchbox', email: 'textbox', tel: 'textbox', url: 'textbox',
|
|
86
|
+
text: 'textbox', password: 'textbox', number: 'spinbutton',
|
|
87
|
+
hidden: 'none',
|
|
88
|
+
})[t] || 'textbox';
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function isTopLevelLandmark(el) {
|
|
93
|
+
// <header>/<footer> are landmarks only when not scoped inside article/section/main/aside.
|
|
94
|
+
for (let p = el.parentElement; p; p = p.parentElement) {
|
|
95
|
+
if (/^(ARTICLE|SECTION|MAIN|ASIDE|NAV)$/.test(p.tagName)) return false;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function roleOf(el) {
|
|
101
|
+
const explicit = el.getAttribute('role');
|
|
102
|
+
if (explicit) return explicit.trim().split(/\s+/)[0];
|
|
103
|
+
const fn = TAG_ROLE[el.tagName];
|
|
104
|
+
return fn ? fn(el) : 'generic';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isHidden(el) {
|
|
108
|
+
if (el.getAttribute('aria-hidden') === 'true') return true;
|
|
109
|
+
if (el.hasAttribute('hidden')) return true;
|
|
110
|
+
const s = getComputedStyle(el);
|
|
111
|
+
if (s.display === 'none' || s.visibility === 'hidden' || s.visibility === 'collapse') return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Accessible-name computation (compact subset of the accname spec, covering
|
|
116
|
+
// the cases the POC proved textContent misses).
|
|
117
|
+
function accName(el, depth) {
|
|
118
|
+
depth = depth || 0;
|
|
119
|
+
// 1. aria-labelledby (resolve id refs → their text)
|
|
120
|
+
const lb = el.getAttribute && el.getAttribute('aria-labelledby');
|
|
121
|
+
if (lb && depth < 2) {
|
|
122
|
+
const txt = lb.split(/\s+/).map((id) => {
|
|
123
|
+
const t = document.getElementById(id);
|
|
124
|
+
return t ? accName(t, depth + 1) || t.textContent.trim() : '';
|
|
125
|
+
}).filter(Boolean).join(' ').trim();
|
|
126
|
+
if (txt) return txt;
|
|
127
|
+
}
|
|
128
|
+
// 2. aria-label
|
|
129
|
+
const al = el.getAttribute && el.getAttribute('aria-label');
|
|
130
|
+
if (al && al.trim()) return al.trim();
|
|
131
|
+
const tag = el.tagName;
|
|
132
|
+
// 3. native naming
|
|
133
|
+
if (tag === 'IMG' || tag === 'AREA') {
|
|
134
|
+
const alt = el.getAttribute('alt');
|
|
135
|
+
if (alt) return alt.trim();
|
|
136
|
+
}
|
|
137
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
|
138
|
+
// associated <label> (for= or wrapping ancestor)
|
|
139
|
+
if (el.id) {
|
|
140
|
+
const lab = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
|
|
141
|
+
if (lab) return lab.textContent.trim();
|
|
142
|
+
}
|
|
143
|
+
const wrap = el.closest && el.closest('label');
|
|
144
|
+
if (wrap) return wrap.textContent.trim();
|
|
145
|
+
const ph = el.getAttribute('placeholder');
|
|
146
|
+
if (ph && ph.trim()) return ph.trim();
|
|
147
|
+
}
|
|
148
|
+
if (tag === 'FIELDSET') {
|
|
149
|
+
const lg = el.querySelector && el.querySelector('legend');
|
|
150
|
+
if (lg) return lg.textContent.trim();
|
|
151
|
+
}
|
|
152
|
+
if (tag === 'TABLE') {
|
|
153
|
+
const cap = el.querySelector && el.querySelector('caption');
|
|
154
|
+
if (cap) return cap.textContent.trim();
|
|
155
|
+
}
|
|
156
|
+
// 4. title attribute
|
|
157
|
+
const title = el.getAttribute && el.getAttribute('title');
|
|
158
|
+
if (title && title.trim()) return title.trim();
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function props(el, role) {
|
|
163
|
+
const p = {};
|
|
164
|
+
if (role === 'checkbox' || role === 'radio' || role === 'switch') p.checked = !!el.checked;
|
|
165
|
+
if (el.disabled || el.getAttribute('aria-disabled') === 'true') p.disabled = true;
|
|
166
|
+
const exp = el.getAttribute('aria-expanded');
|
|
167
|
+
if (exp !== null) p.expanded = exp === 'true';
|
|
168
|
+
if (role === 'heading') p.level = Number(el.getAttribute('aria-level')) || Number(el.tagName[1]) || 2;
|
|
169
|
+
if (el.getAttribute('aria-selected') === 'true') p.selected = true;
|
|
170
|
+
if (el.required || el.getAttribute('aria-required') === 'true') p.required = true;
|
|
171
|
+
if ((role === 'textbox' || role === 'searchbox' || role === 'combobox' || role === 'spinbutton') && el.value) {
|
|
172
|
+
p.value = el.value;
|
|
173
|
+
}
|
|
174
|
+
return p;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function makeNode(role, name, properties) {
|
|
178
|
+
return { nodeId: String(++ref), role, name: name || '', properties: properties || {}, children: [], ignored: false };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Walk a DOM element → AX node (or null if hidden/irrelevant). Text runs
|
|
182
|
+
// become StaticText children unless the parent names from content.
|
|
183
|
+
function walk(el) {
|
|
184
|
+
if (isHidden(el)) return null;
|
|
185
|
+
let role = roleOf(el);
|
|
186
|
+
if (role === 'none') return collapseChildren(el); // presentational: keep kids
|
|
187
|
+
|
|
188
|
+
// Accessible name: explicit sources first, then fall back to descendant
|
|
189
|
+
// text for roles that name from content (button/link/heading/…), matching
|
|
190
|
+
// CDP — otherwise those nodes come back nameless.
|
|
191
|
+
let name = accName(el);
|
|
192
|
+
if (!name && NAME_FROM_CONTENT.has(role)) {
|
|
193
|
+
name = el.textContent.replace(/\s+/g, ' ').trim();
|
|
194
|
+
}
|
|
195
|
+
const node = makeNode(role, name, props(el, role));
|
|
196
|
+
el.setAttribute(REF_ATTR, node.nodeId);
|
|
197
|
+
|
|
198
|
+
// A collapsed native <select> exposes its current value via props/name;
|
|
199
|
+
// don't expand its <option> list into the tree (a 200-item country select
|
|
200
|
+
// would otherwise flood every snapshot). A multi/size listbox keeps them.
|
|
201
|
+
if (el.tagName === 'SELECT' && !el.multiple) return node;
|
|
202
|
+
|
|
203
|
+
if (!NAME_FROM_CONTENT.has(role)) {
|
|
204
|
+
for (const child of kidsOf(el)) {
|
|
205
|
+
if (child.nodeType === 3) {
|
|
206
|
+
const t = child.textContent.replace(/\s+/g, ' ').trim();
|
|
207
|
+
if (t) node.children.push(makeNode('StaticText', t, {}));
|
|
208
|
+
} else if (child.nodeType === 1) {
|
|
209
|
+
const c = walk(child);
|
|
210
|
+
if (c) Array.isArray(c) ? node.children.push(...c) : node.children.push(c);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
// name-from-content: still recurse into element children for nested
|
|
215
|
+
// interactives (e.g. a link inside a heading), but drop bare text.
|
|
216
|
+
for (const child of kidsOf(el)) {
|
|
217
|
+
if (child.nodeType !== 1) continue;
|
|
218
|
+
const c = walk(child);
|
|
219
|
+
if (c) Array.isArray(c) ? node.children.push(...c) : node.children.push(c);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return node;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Effective children to traverse: shadow-root content when the element hosts
|
|
226
|
+
// an open shadow tree (that is what actually renders — CDP's AX tree includes
|
|
227
|
+
// it), assigned light nodes for a <slot>, else plain light-DOM childNodes.
|
|
228
|
+
function kidsOf(el) {
|
|
229
|
+
if (el.tagName === 'SLOT') {
|
|
230
|
+
const assigned = el.assignedNodes ? el.assignedNodes({ flatten: true }) : [];
|
|
231
|
+
return assigned.length ? assigned : [...el.childNodes];
|
|
232
|
+
}
|
|
233
|
+
if (el.shadowRoot) return [...el.shadowRoot.childNodes];
|
|
234
|
+
return [...el.childNodes];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Presentational element (role=none/presentation): emit its children only.
|
|
238
|
+
function collapseChildren(el) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const child of kidsOf(el)) {
|
|
241
|
+
if (child.nodeType === 3) {
|
|
242
|
+
const t = child.textContent.replace(/\s+/g, ' ').trim();
|
|
243
|
+
if (t) out.push(makeNode('StaticText', t, {}));
|
|
244
|
+
} else if (child.nodeType === 1 && !isHidden(child)) {
|
|
245
|
+
const c = walk(child);
|
|
246
|
+
if (c) Array.isArray(c) ? out.push(...c) : out.push(c);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Clear stale refs from a prior snapshot so resolution never hits a ghost.
|
|
253
|
+
for (const old of document.querySelectorAll('[' + REF_ATTR + ']')) old.removeAttribute(REF_ATTR);
|
|
254
|
+
|
|
255
|
+
// Wrap body content in an ignored 'none' node, mirroring the html/body
|
|
256
|
+
// wrappers CDP's getFullAXTree emits. prune.js's region extraction only
|
|
257
|
+
// inspects RootWebArea's DIRECT children for landmarks, so without this
|
|
258
|
+
// wrapper a top-level <form>/<nav> (no <main>) would be treated as a
|
|
259
|
+
// directly-extractable region and dropped in act mode — a divergence from
|
|
260
|
+
// CDP, where the same landmark is buried under body and flows through.
|
|
261
|
+
const root = makeNode('RootWebArea', document.title || '', {});
|
|
262
|
+
const body = makeNode('none', '', {});
|
|
263
|
+
body.ignored = true;
|
|
264
|
+
// childNodes (not .children) so bare text directly under <body> is kept as
|
|
265
|
+
// StaticText, matching walk()/CDP; .children would silently drop it.
|
|
266
|
+
for (const child of document.body ? document.body.childNodes : []) {
|
|
267
|
+
if (child.nodeType === 3) {
|
|
268
|
+
const t = child.textContent.replace(/\s+/g, ' ').trim();
|
|
269
|
+
if (t) body.children.push(makeNode('StaticText', t, {}));
|
|
270
|
+
} else if (child.nodeType === 1) {
|
|
271
|
+
const c = walk(child);
|
|
272
|
+
if (c) Array.isArray(c) ? body.children.push(...c) : body.children.push(c);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
root.children.push(body);
|
|
276
|
+
return JSON.stringify({ tree: root, count: ref - base });
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Build the script.evaluate expression that reconstructs the AX tree in a
|
|
281
|
+
* context, assigning refs from `base`.
|
|
282
|
+
* @param {number} base - Starting ref offset (exclusive; first ref is base+1)
|
|
283
|
+
* @returns {string} JS expression returning JSON { tree, count }
|
|
284
|
+
*/
|
|
285
|
+
export function axSnapshotExpression(base) {
|
|
286
|
+
return `(${AX_FN.toString()})(${Number(base)}, ${JSON.stringify(REF_ATTR)})`;
|
|
287
|
+
}
|
package/src/bidi.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bidi.js — Minimal WebDriver BiDi client over WebSocket (Firefox transport).
|
|
3
|
+
*
|
|
4
|
+
* The W3C-standard successor to CDP. CDP was deprecated in Firefox, so Firefox
|
|
5
|
+
* is driven over BiDi instead. This is the BiDi analogue of cdp.js: same
|
|
6
|
+
* JSON-RPC-over-WebSocket shape (id/method/params → result), same `ws`
|
|
7
|
+
* dependency and 256 MB maxPayload (a full AX snapshot of a large page is
|
|
8
|
+
* returned as one big string from script.evaluate — see ax-snapshot.js — and
|
|
9
|
+
* would blow the built-in WebSocket cap exactly as getFullAXTree did on CDP).
|
|
10
|
+
*
|
|
11
|
+
* Differences from CDP that shape this client:
|
|
12
|
+
* - A session must be created explicitly (`session.new`) before any command.
|
|
13
|
+
* - Events must be subscribed to (`session.subscribe`); we lean on
|
|
14
|
+
* command results (navigate's `wait:'complete'`) instead where possible.
|
|
15
|
+
* - Errors arrive as `{ type:'error', error, message }`, not `{ error:{} }`.
|
|
16
|
+
* - Everything is scoped by `context` (a browsing-context id), not sessionId.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import WebSocket from 'ws';
|
|
20
|
+
|
|
21
|
+
/** Lift the message ceiling well past any realistic AX/DOM payload. */
|
|
22
|
+
const MAX_PAYLOAD = 256 * 1024 * 1024; // 256 MB
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a BiDi client connected to the given /session WebSocket URL and open
|
|
26
|
+
* a session. Firefox prints its BiDi endpoint to stderr as
|
|
27
|
+
* "WebDriver BiDi listening on ws://HOST:PORT"; the direct-connection socket
|
|
28
|
+
* (no WebDriver-classic handshake) lives at that URL + "/session".
|
|
29
|
+
*
|
|
30
|
+
* @param {string} wsUrl - BiDi session WebSocket URL (ws://HOST:PORT/session)
|
|
31
|
+
* @returns {Promise<object>} BiDi client ({ send, evaluate, on, once, subscribe, sessionId, close })
|
|
32
|
+
*/
|
|
33
|
+
export async function createBiDi(wsUrl) {
|
|
34
|
+
const ws = new WebSocket(wsUrl, { maxPayload: MAX_PAYLOAD, perMessageDeflate: false });
|
|
35
|
+
let nextId = 1;
|
|
36
|
+
const pending = new Map(); // id → { resolve, reject }
|
|
37
|
+
const listeners = new Map(); // "method" → Set<callback>
|
|
38
|
+
|
|
39
|
+
/** @type {Promise<void>} */
|
|
40
|
+
const connected = new Promise((resolve, reject) => {
|
|
41
|
+
const timeout = setTimeout(() => reject(new Error('BiDi connection timeout (5s)')), 5000);
|
|
42
|
+
ws.onopen = () => { clearTimeout(timeout); resolve(); };
|
|
43
|
+
ws.onerror = (e) => {
|
|
44
|
+
clearTimeout(timeout);
|
|
45
|
+
reject(new Error(`BiDi WebSocket connection failed: ${e.message || 'unknown error'}`));
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
await connected;
|
|
49
|
+
|
|
50
|
+
ws.onmessage = (event) => {
|
|
51
|
+
const msg = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
|
|
52
|
+
|
|
53
|
+
// Command response (has id + type 'success' | 'error')
|
|
54
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
55
|
+
const handler = pending.get(msg.id);
|
|
56
|
+
pending.delete(msg.id);
|
|
57
|
+
if (msg.type === 'error') {
|
|
58
|
+
handler.reject(new Error(`BiDi error: ${msg.error} — ${msg.message || ''}`.trim()));
|
|
59
|
+
} else {
|
|
60
|
+
handler.resolve(msg.result);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Event (type 'event', has method + params)
|
|
66
|
+
if (msg.type === 'event' && msg.method) {
|
|
67
|
+
const set = listeners.get(msg.method);
|
|
68
|
+
if (set) for (const cb of set) cb(msg.params);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
ws.onclose = () => {
|
|
73
|
+
for (const [id, handler] of pending) {
|
|
74
|
+
handler.reject(new Error('BiDi WebSocket closed'));
|
|
75
|
+
pending.delete(id);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const client = {
|
|
80
|
+
/**
|
|
81
|
+
* Send a BiDi command and wait for its result.
|
|
82
|
+
* @param {string} method - e.g. 'browsingContext.navigate'
|
|
83
|
+
* @param {object} [params]
|
|
84
|
+
* @returns {Promise<object>} result
|
|
85
|
+
*/
|
|
86
|
+
send(method, params = {}) {
|
|
87
|
+
const id = nextId++;
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
pending.set(id, { resolve, reject });
|
|
90
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Evaluate an expression in a browsing context and return its value.
|
|
96
|
+
* Throws on an in-page exception (BiDi reports these as a `success`
|
|
97
|
+
* envelope with `type:'exception'`, unlike a protocol error).
|
|
98
|
+
* @param {string} context - browsing-context id
|
|
99
|
+
* @param {string} expression - JS source to evaluate
|
|
100
|
+
* @param {boolean} [awaitPromise=true]
|
|
101
|
+
* @returns {Promise<*>} the deserialized primitive value (result.value)
|
|
102
|
+
*/
|
|
103
|
+
async evaluate(context, expression, awaitPromise = true) {
|
|
104
|
+
const res = await client.send('script.evaluate', {
|
|
105
|
+
expression, target: { context }, awaitPromise,
|
|
106
|
+
});
|
|
107
|
+
if (res.type === 'exception') {
|
|
108
|
+
const d = res.exceptionDetails;
|
|
109
|
+
throw new Error(`BiDi script exception: ${d?.text || d?.exception?.value || 'unknown'}`);
|
|
110
|
+
}
|
|
111
|
+
return res.result?.value;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/** Subscribe to BiDi events by method name (required before events fire). */
|
|
115
|
+
async subscribe(events) {
|
|
116
|
+
await client.send('session.subscribe', { events });
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/** Register an event listener. Returns an unsubscribe function. */
|
|
120
|
+
on(method, callback) {
|
|
121
|
+
if (!listeners.has(method)) listeners.set(method, new Set());
|
|
122
|
+
listeners.get(method).add(callback);
|
|
123
|
+
return () => listeners.get(method)?.delete(callback);
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** Resolve once a named event fires (or reject on timeout). */
|
|
127
|
+
once(method, timeout = 30000) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const timer = setTimeout(() => { unsub(); reject(new Error(`Timeout waiting for BiDi event: ${method}`)); }, timeout);
|
|
130
|
+
const unsub = client.on(method, (params) => { clearTimeout(timer); unsub(); resolve(params); });
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
sessionId: null,
|
|
135
|
+
close() { ws.close(); },
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Open the session. capabilities:{} accepts Firefox's defaults.
|
|
139
|
+
const session = await client.send('session.new', { capabilities: {} });
|
|
140
|
+
client.sessionId = session.sessionId;
|
|
141
|
+
client.capabilities = session.capabilities;
|
|
142
|
+
return client;
|
|
143
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consent-firefox.js — Auto-dismiss cookie consent dialogs on the Firefox/BiDi
|
|
3
|
+
* engine, by walking the reconstructed AX tree (ax-snapshot.js).
|
|
4
|
+
*
|
|
5
|
+
* The CDP walker (consent.js) can't be reused verbatim: it consumes CDP's flat
|
|
6
|
+
* getFullAXTree (nodes[] with parentId / role.value / backendDOMNodeId) and
|
|
7
|
+
* clicks via DOM.resolveNode + Input.dispatchMouseEvent. The Firefox tree is a
|
|
8
|
+
* DIFFERENT shape — nested `children`, string `role`/`name`, and each node's
|
|
9
|
+
* `nodeId` IS its ref (stamped as data-bb-ref) — and clicks go through
|
|
10
|
+
* input.performActions. So this is a parallel walker sharing only the language
|
|
11
|
+
* patterns (consent-patterns.js).
|
|
12
|
+
*
|
|
13
|
+
* It's written as a PURE function over (root tree, click(ref)) so it can be
|
|
14
|
+
* unit-tested against fixture trees with no browser: the caller (firefox-page)
|
|
15
|
+
* injects a real click that routes ref → pointerClick. The nested tree also
|
|
16
|
+
* makes "descendant of dialog" a plain subtree walk — no parentMap needed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ACCEPT_PATTERNS, DIALOG_ROLES, CONSENT_DIALOG_HINTS } from './consent-patterns.js';
|
|
20
|
+
|
|
21
|
+
/** Roles whose text confirms a container is a consent dialog. */
|
|
22
|
+
const CONSENT_TEXT_ROLES = new Set(['heading', 'StaticText', 'generic']);
|
|
23
|
+
|
|
24
|
+
/** Depth-first walk yielding every node in the subtree rooted at `node`. */
|
|
25
|
+
function* walk(node) {
|
|
26
|
+
if (!node) return;
|
|
27
|
+
yield node;
|
|
28
|
+
for (const child of node.children || []) yield* walk(child);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True if any node in this subtree carries consent-hint text. */
|
|
32
|
+
function hasConsentContent(dialog) {
|
|
33
|
+
for (const node of walk(dialog)) {
|
|
34
|
+
if (node === dialog) continue;
|
|
35
|
+
if (CONSENT_TEXT_ROLES.has(node.role) && CONSENT_DIALOG_HINTS.some((p) => p.test(node.name || ''))) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Find the best "accept" button inside a dialog subtree, honouring
|
|
44
|
+
* ACCEPT_PATTERNS priority (most specific first).
|
|
45
|
+
*/
|
|
46
|
+
function findAcceptButton(dialog) {
|
|
47
|
+
for (const pattern of ACCEPT_PATTERNS) {
|
|
48
|
+
for (const node of walk(dialog)) {
|
|
49
|
+
if (node.role === 'button' && node.name && pattern.test(node.name)) return node;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fallback when no consent dialog container is found: scan the whole tree for a
|
|
57
|
+
* button matching a STRONG (multi-word) accept pattern. Excludes the bare
|
|
58
|
+
* ^accept$/^agree$/^ok$ fallbacks (last 3) so we don't false-match unrelated
|
|
59
|
+
* page buttons — mirrors consent.js's tryGlobalConsentButton.
|
|
60
|
+
*/
|
|
61
|
+
function findGlobalAcceptButton(root) {
|
|
62
|
+
const safePatterns = ACCEPT_PATTERNS.slice(0, -3);
|
|
63
|
+
for (const pattern of safePatterns) {
|
|
64
|
+
for (const node of walk(root)) {
|
|
65
|
+
if (node.role === 'button' && node.name && pattern.test(node.name)) return node;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Try to dismiss a cookie consent dialog in a reconstructed Firefox AX tree.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} root - Spliced AX tree root (from firefox-page's buildTree).
|
|
75
|
+
* @param {(ref: string) => Promise<void>} click - Clicks the element for a ref
|
|
76
|
+
* (firefox-page injects one that routes to pointerClick / performActions).
|
|
77
|
+
* @returns {Promise<boolean>} true if an accept button was found and clicked.
|
|
78
|
+
*/
|
|
79
|
+
export async function dismissConsentFirefox(root, click) {
|
|
80
|
+
if (!root) return false;
|
|
81
|
+
|
|
82
|
+
// Find dialog containers that look like consent dialogs.
|
|
83
|
+
const consentDialogs = [];
|
|
84
|
+
for (const node of walk(root)) {
|
|
85
|
+
if (!DIALOG_ROLES.has(node.role)) continue;
|
|
86
|
+
const name = node.name || '';
|
|
87
|
+
if (CONSENT_DIALOG_HINTS.some((p) => p.test(name)) || hasConsentContent(node)) {
|
|
88
|
+
consentDialogs.push(node);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Accept button inside a consent dialog (preferred — scoped, low false-positive).
|
|
93
|
+
for (const dialog of consentDialogs) {
|
|
94
|
+
const button = findAcceptButton(dialog);
|
|
95
|
+
if (button?.nodeId) {
|
|
96
|
+
try {
|
|
97
|
+
await click(button.nodeId);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
// Click failed (stale ref / navigated) — try the next dialog.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Banner-style consent (no dialog container at all): scan the page for a
|
|
106
|
+
// strong accept button. We deliberately DO NOT run this page-wide scan when a
|
|
107
|
+
// consent dialog WAS detected but had no in-scope accept button — a page-wide
|
|
108
|
+
// match there can click an UNRELATED "Accept all …" control elsewhere (e.g. a
|
|
109
|
+
// ToS/signup button), an automatic wrong mutation on goto(). The trade-off is
|
|
110
|
+
// losing the rare "accept button rendered outside its own dialog" pattern; we
|
|
111
|
+
// accept that miss rather than risk a wrong click. See the PRD "known
|
|
112
|
+
// limitations" note for the validated false-positive this guards against.
|
|
113
|
+
if (consentDialogs.length === 0) {
|
|
114
|
+
const global = findGlobalAcceptButton(root);
|
|
115
|
+
if (global?.nodeId) {
|
|
116
|
+
try {
|
|
117
|
+
await click(global.nodeId);
|
|
118
|
+
return true;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|