barebrowse 0.15.0 → 0.18.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/src/consent.js CHANGED
@@ -4,152 +4,13 @@
4
4
  * Scans the page ARIA tree for consent dialogs and clicks the "accept" button.
5
5
  * Works across languages by matching common accept/agree patterns.
6
6
  * Runs once after page load — no polling, no mutation observers.
7
+ *
8
+ * The multilingual pattern sets live in consent-patterns.js (shared with the
9
+ * Firefox/BiDi walker in consent-firefox.js); this file owns the CDP-specific
10
+ * tree walk + click.
7
11
  */
8
12
 
9
- // Button text patterns that mean "accept all" / "I agree" across common languages.
10
- // Order matters: more specific patterns first to avoid false positives.
11
- const ACCEPT_PATTERNS = [
12
- // English
13
- /\baccept\s*all\b/i,
14
- /\ballow\s*all\b/i,
15
- /\bagree\s*to\s*all\b/i,
16
- /\byes,?\s*i\s*agree\b/i,
17
- /\bi\s*agree\b/i,
18
- /\baccept\s*cookies?\b/i,
19
- /\ballow\s*cookies?\b/i,
20
- /\bgot\s*it\b/i,
21
- // Dutch
22
- /\balles\s*accepteren\b/i,
23
- /\balles\s*toestaan\b/i,
24
- /\baccepteren\b/i,
25
- /\bakkoord\b/i,
26
- // German
27
- /\balle\s*akzeptieren\b/i,
28
- /\ballem\s*zustimmen\b/i,
29
- /\balle\s*cookies?\s*akzeptieren\b/i,
30
- // French
31
- /\btout\s*accepter\b/i,
32
- /\baccepter\s*tout\b/i,
33
- /\bj['']accepte\b/i,
34
- // Spanish
35
- /\baceptar\s*todo\b/i,
36
- /\baceptar\s*todas?\b/i,
37
- // Italian
38
- /\baccetta\s*tutto\b/i,
39
- /\baccetto\b/i,
40
- // Portuguese
41
- /\baceitar\s*tudo\b/i,
42
- // Russian
43
- /принять\s*все/i,
44
- /принять/i,
45
- /согласен/i,
46
- // Ukrainian
47
- /прийняти\s*все/i,
48
- /прийняти/i,
49
- // Polish
50
- /zaakceptuj\s*wszystk/i,
51
- /akceptuj\s*wszystk/i,
52
- /zgadzam\s*się/i,
53
- // Czech
54
- /přijmout\s*vše/i,
55
- /souhlasím/i,
56
- // Turkish
57
- /tümünü\s*kabul\s*et/i,
58
- /kabul\s*et/i,
59
- /kabul\s*ediyorum/i,
60
- // Romanian
61
- /acceptă\s*tot/i,
62
- /accept\s*toate/i,
63
- // Hungarian
64
- /összes\s*elfogadás/i,
65
- /elfogad/i,
66
- // Greek
67
- /αποδοχή\s*όλων/i,
68
- /αποδέχομαι/i,
69
- // Swedish
70
- /acceptera\s*alla/i,
71
- /godkänn\s*alla/i,
72
- // Danish
73
- /accepter\s*alle/i,
74
- /acceptér\s*alle/i,
75
- // Norwegian
76
- /godta\s*alle/i,
77
- /aksepter\s*alle/i,
78
- // Finnish
79
- /hyväksy\s*kaikki/i,
80
- /hyväksyn/i,
81
- // Arabic
82
- /قبول\s*الكل/,
83
- /قبول\s*الجميع/,
84
- /موافق/,
85
- /قبول/,
86
- // Persian
87
- /پذیرش\s*همه/,
88
- /موافقم/,
89
- /پذیرش/,
90
- // Chinese (Simplified + Traditional)
91
- /全部接受/,
92
- /接受所有/,
93
- /接受全部/,
94
- /同意并继续/,
95
- /全部接受/,
96
- /接受/,
97
- /同意/,
98
- // Japanese
99
- /すべて受け入れ/,
100
- /すべて許可/,
101
- /同意する/,
102
- /同意します/,
103
- // Korean
104
- /모두\s*수락/,
105
- /모두\s*동의/,
106
- /동의합니다/,
107
- /수락/,
108
- // Vietnamese
109
- /chấp\s*nhận\s*tất\s*cả/i,
110
- /đồng\s*ý\s*tất\s*cả/i,
111
- /đồng\s*ý/i,
112
- // Thai
113
- /ยอมรับทั้งหมด/,
114
- /ยอมรับ/,
115
- // Hindi
116
- /सभी\s*स्वीकार/,
117
- /स्वीकार\s*करें/,
118
- /सहमत/,
119
- // Indonesian / Malay
120
- /terima\s*semua/i,
121
- /setuju/i,
122
- // Generic single-word fallbacks (only matched inside dialogs)
123
- /^accept$/i,
124
- /^agree$/i,
125
- /^ok$/i,
126
- ];
127
-
128
- // Roles that indicate a consent dialog container.
129
- const DIALOG_ROLES = new Set(['dialog', 'alertdialog']);
130
-
131
- // Text patterns in dialog names/headings that confirm it's about consent.
132
- const CONSENT_DIALOG_HINTS = [
133
- /cookie/i,
134
- /consent/i,
135
- /privacy/i,
136
- /before\s*you\s*continue/i,
137
- /voordat\s*je\s*verdergaat/i, // Dutch
138
- /bevor\s*du\s*fortf/i, // German
139
- /avant\s*de\s*continuer/i, // French
140
- /antes\s*de\s*continuar/i, // Spanish / Portuguese
141
- /prima\s*di\s*continuare/i, // Italian
142
- /zanim\s*przejdziesz/i, // Polish
143
- /прежде\s*чем\s*продолжить/i, // Russian
144
- /devam\s*etmeden\s*önce/i, // Turkish
145
- /続行する前に/, // Japanese
146
- /继续前/, // Chinese Simplified
147
- /繼續前/, // Chinese Traditional
148
- /계속하기\s*전에/, // Korean
149
- /trước\s*khi\s*tiếp\s*tục/i, // Vietnamese
150
- /ملفات\s*تعريف\s*الارتباط/, // Arabic: cookies
151
- /คุกกี้/, // Thai: cookies
152
- ];
13
+ import { ACCEPT_PATTERNS, DIALOG_ROLES, CONSENT_DIALOG_HINTS } from './consent-patterns.js';
153
14
 
154
15
  /**
155
16
  * Click a node via JavaScript .click() instead of mouse events.
package/src/daemon.js CHANGED
@@ -67,6 +67,74 @@ export function buildDaemonArgs(opts, absDir, initialUrl, cliPath) {
67
67
  return args;
68
68
  }
69
69
 
70
+ /**
71
+ * Wire Firefox/BiDi console + network capture onto the daemon's log arrays.
72
+ * The BiDi analogue of the CDP `Runtime.consoleAPICalled` / `Network.*`
73
+ * capture — measured event shapes: `log.entryAdded` carries {method, level,
74
+ * args:[{type,value}], text}; the network events key in-flight requests by
75
+ * `request.request` and expose `response.{status,statusText,mimeType}` /
76
+ * top-level `errorText`. Pure + exported so the shape mapping (warn→warning
77
+ * normalization, arg extraction, orphan-safe response/error pairing) is
78
+ * unit-testable against a fake bidi without launching Firefox.
79
+ *
80
+ * @param {object} bidi - BiDi client (async .subscribe(), .on(method, cb))
81
+ * @param {object} sinks
82
+ * @param {Array} sinks.consoleLogs - push-target for console entries
83
+ * @param {Array} sinks.networkLogs - push-target for completed/failed requests
84
+ * @param {Map} sinks.pendingRequests - in-flight request bookkeeping
85
+ * @returns {Promise<void>}
86
+ */
87
+ export async function attachBiDiCapture(bidi, { consoleLogs, networkLogs, pendingRequests }) {
88
+ await bidi.subscribe([
89
+ 'log.entryAdded',
90
+ 'network.beforeRequestSent',
91
+ 'network.responseCompleted',
92
+ 'network.fetchError',
93
+ ]);
94
+
95
+ bidi.on('log.entryAdded', (e) => {
96
+ consoleLogs.push({
97
+ // Map BiDi's console method (or level for uncaught JS errors) to CDP's
98
+ // `type` vocabulary so `console-logs --level warning` matches on both
99
+ // engines — BiDi says 'warn', CDP says 'warning'.
100
+ type: e.method === 'warn' ? 'warning' : (e.method || e.level),
101
+ timestamp: new Date().toISOString(),
102
+ args: Array.isArray(e.args) && e.args.length
103
+ ? e.args.map((a) => a.value ?? a.type)
104
+ : [e.text],
105
+ });
106
+ });
107
+
108
+ bidi.on('network.beforeRequestSent', (e) => {
109
+ pendingRequests.set(e.request.request, {
110
+ url: e.request.url,
111
+ method: e.request.method,
112
+ timestamp: new Date().toISOString(),
113
+ });
114
+ });
115
+
116
+ bidi.on('network.responseCompleted', (e) => {
117
+ const req = pendingRequests.get(e.request.request);
118
+ if (req) {
119
+ networkLogs.push({
120
+ ...req,
121
+ status: e.response.status,
122
+ statusText: e.response.statusText,
123
+ mimeType: e.response.mimeType,
124
+ });
125
+ pendingRequests.delete(e.request.request);
126
+ }
127
+ });
128
+
129
+ bidi.on('network.fetchError', (e) => {
130
+ const req = pendingRequests.get(e.request.request);
131
+ if (req) {
132
+ networkLogs.push({ ...req, status: 0, error: e.errorText });
133
+ pendingRequests.delete(e.request.request);
134
+ }
135
+ });
136
+ }
137
+
70
138
  /**
71
139
  * Spawn a detached child process that runs the daemon.
72
140
  * Parent polls for session.json, then exits.
@@ -139,10 +207,10 @@ export async function runDaemon(opts, outputDir, initialUrl) {
139
207
  incognito: opts.incognito || opts.cookies === false,
140
208
  });
141
209
 
142
- // Console + network log capture is CDP-specific (page.cdp). The Firefox/BiDi
143
- // engine exposes page.bidi instead, so these captures are skipped there
144
- // console-logs / network-log commands return empty for a Firefox session.
145
- // (BiDi log.entryAdded / network.* events could back these later.)
210
+ // Console + network log capture. CDP uses Runtime/Network events off
211
+ // page.cdp; the Firefox/BiDi engine (page.bidi) captures the same shape over
212
+ // log.entryAdded / network.* events (Phase 2 attachBiDiCapture). Either
213
+ // way console-logs / network-log / wait-idle behave the same for both.
146
214
  const consoleLogs = [];
147
215
  const networkLogs = [];
148
216
  const pendingRequests = new Map();
@@ -190,6 +258,8 @@ export async function runDaemon(opts, outputDir, initialUrl) {
190
258
  pendingRequests.delete(params.requestId);
191
259
  }
192
260
  });
261
+ } else if (page.bidi) {
262
+ await attachBiDiCapture(page.bidi, { consoleLogs, networkLogs, pendingRequests });
193
263
  }
194
264
 
195
265
  // Navigate to initial URL if provided
@@ -21,6 +21,8 @@ import { axSnapshotExpression, REF_ATTR } from './ax-snapshot.js';
21
21
  import { EXTRACT_EXPRESSION, finalizeReadable } from './readable.js';
22
22
  import { scopedCookiesForUrl } from './auth.js';
23
23
  import { assertNavigable, assertUploadAllowed } from './url-guard.js';
24
+ import { dismissConsentFirefox } from './consent-firefox.js';
25
+ import { waitForNetworkIdleBiDi } from './network-idle.js';
24
26
 
25
27
  /** BiDi/WebDriver normalized key values for named keys (U+E000 block). */
26
28
  const BIDI_KEYS = {
@@ -38,6 +40,7 @@ const BIDI_KEYS = {
38
40
  * @param {{allowLocalUrls?: boolean, blockPrivateNetwork?: boolean}} [opts.urlGuard] - Navigation safety policy, applied on every goto().
39
41
  * @param {string} [opts.uploadDir] - When set, upload() rejects files outside this dir.
40
42
  * @param {boolean} [opts.incognito=false] - Clean session: injectCookies() is a no-op.
43
+ * @param {boolean} [opts.consent=true] - Auto-dismiss cookie consent dialogs after goto().
41
44
  * @returns {Promise<object>} page object
42
45
  */
43
46
  export async function createFirefoxPage(bidi, opts = {}) {
@@ -45,6 +48,7 @@ export async function createFirefoxPage(bidi, opts = {}) {
45
48
  const urlGuard = opts.urlGuard || {};
46
49
  const uploadDir = opts.uploadDir || null;
47
50
  const incognito = !!opts.incognito;
51
+ const consent = opts.consent !== false;
48
52
  // The active browsing context. Starts at the initial tab; switchTab() points
49
53
  // it at another top-level context, so it's mutable and read via a getter.
50
54
  const { contexts } = await bidi.send('browsingContext.getTree', {});
@@ -71,6 +75,53 @@ export async function createFirefoxPage(bidi, opts = {}) {
71
75
  return flat;
72
76
  }
73
77
 
78
+ /**
79
+ * Build the spliced AX tree for the active tab (main frame + child frames)
80
+ * and (re)populate refContexts so a subsequent click routes to the right
81
+ * frame. Returns the top context's root node, or null if it couldn't be
82
+ * snapshotted. Shared by snapshot() and the consent auto-dismiss so the
83
+ * iframe-splice logic lives in one place.
84
+ */
85
+ async function buildTree() {
86
+ const contextIds = await allContexts();
87
+ refContexts = new Map();
88
+
89
+ // Snapshot each context, assigning refs from a shared running counter so
90
+ // they're globally unique (matching CDP's flat integer refs).
91
+ let base = 0;
92
+ const treesByContext = new Map();
93
+ for (const ctx of contextIds) {
94
+ let raw;
95
+ try {
96
+ raw = await bidi.evaluate(ctx, axSnapshotExpression(base), false);
97
+ } catch { continue; } // frame navigated mid-snapshot — skip it
98
+ const { tree, count } = JSON.parse(raw);
99
+ for (let r = base + 1; r <= base + count; r++) refContexts.set(String(r), ctx);
100
+ base += count;
101
+ treesByContext.set(ctx, tree);
102
+ }
103
+
104
+ // Splice each child frame's tree under an <iframe> (role 'Iframe')
105
+ // placeholder in its parent, matched by document order — BiDi getTree
106
+ // lists children in frame order, and the AX walk emits Iframe nodes in
107
+ // the same order.
108
+ const iframeNodes = (tree) => {
109
+ const out = [];
110
+ (function walk(n) { if (n.role === 'Iframe') out.push(n); for (const c of n.children) walk(c); })(tree);
111
+ return out;
112
+ };
113
+ for (let i = 1; i < contextIds.length; i++) {
114
+ const childTree = treesByContext.get(contextIds[i]);
115
+ if (!childTree) continue;
116
+ // Attach to the next unfilled Iframe placeholder in the top tree.
117
+ const holders = iframeNodes(treesByContext.get(topContext) || { role: '', children: [] });
118
+ const holder = holders[i - 1];
119
+ if (holder) holder.children = [childTree];
120
+ }
121
+
122
+ return treesByContext.get(topContext) || null;
123
+ }
124
+
74
125
  /** Resolve a ref to a BiDi element sharedId in its owning context. */
75
126
  async function resolveRef(ref) {
76
127
  const context = refContexts.get(String(ref));
@@ -119,6 +170,51 @@ export async function createFirefoxPage(bidi, opts = {}) {
119
170
  return Promise.race([p, t]).finally(() => clearTimeout(timer));
120
171
  }
121
172
 
173
+ // JS dialog handling (alert/confirm/prompt/beforeunload), parity with the
174
+ // CDP path in index.js. The BiDi session is created with
175
+ // unhandledPromptBehavior:'ignore' (see bidi.js) so prompts stay open until
176
+ // we respond; setupDialogs() must run before the first navigation or an
177
+ // 'ignore' prompt would hang the page. Default: accept everything except
178
+ // beforeunload (dismiss = stay), mirroring the CDP default. A caller can
179
+ // override per-dialog via page.onDialog(handler).
180
+ const dialogLog = [];
181
+ let onDialogHandler = null;
182
+ async function setupDialogs() {
183
+ await bidi.subscribe(['browsingContext.userPromptOpened']);
184
+ bidi.on('browsingContext.userPromptOpened', async (e) => {
185
+ dialogLog.push({
186
+ type: e.type,
187
+ message: e.message || '',
188
+ timestamp: new Date().toISOString(),
189
+ });
190
+ let accept = e.type !== 'beforeunload';
191
+ let userText = e.defaultValue || '';
192
+ if (onDialogHandler) {
193
+ try {
194
+ const decision = await onDialogHandler({
195
+ type: e.type,
196
+ message: e.message || '',
197
+ defaultPrompt: e.defaultValue || '',
198
+ });
199
+ if (decision && typeof decision === 'object') {
200
+ if (typeof decision.accept === 'boolean') accept = decision.accept;
201
+ if (typeof decision.promptText === 'string') userText = decision.promptText;
202
+ }
203
+ } catch {
204
+ // Handler threw — fall back to defaults so the prompt is still
205
+ // answered and the page doesn't hang on an 'ignore' dialog.
206
+ }
207
+ }
208
+ try {
209
+ await bidi.send('browsingContext.handleUserPrompt', {
210
+ context: e.context, accept, userText,
211
+ });
212
+ } catch {
213
+ // Prompt already gone (closed by page JS / navigation). Nothing to do.
214
+ }
215
+ });
216
+ }
217
+
122
218
  const page = {
123
219
  /** The BiDi escape hatch, analogous to connect()'s page.cdp. */
124
220
  get bidi() { return bidi; },
@@ -134,46 +230,19 @@ export async function createFirefoxPage(bidi, opts = {}) {
134
230
  timeout, `goto(${url})`);
135
231
  // Brief settle for dynamic/SPA content, matching the CDP path.
136
232
  await new Promise((r) => setTimeout(r, 500));
137
- },
138
-
139
- async snapshot(pruneOpts) {
140
- const contextIds = await allContexts();
141
- refContexts = new Map();
142
-
143
- // Snapshot each context, assigning refs from a shared running counter so
144
- // they're globally unique (matching CDP's flat integer refs).
145
- let base = 0;
146
- const treesByContext = new Map();
147
- for (const ctx of contextIds) {
148
- let raw;
233
+ // Auto-dismiss cookie consent, mirroring the CDP path (index.js runs
234
+ // dismissConsent right after navigate). Best-effort: a failure here must
235
+ // never fail the navigation.
236
+ if (consent) {
149
237
  try {
150
- raw = await bidi.evaluate(ctx, axSnapshotExpression(base), false);
151
- } catch { continue; } // frame navigated mid-snapshot — skip it
152
- const { tree, count } = JSON.parse(raw);
153
- for (let r = base + 1; r <= base + count; r++) refContexts.set(String(r), ctx);
154
- base += count;
155
- treesByContext.set(ctx, tree);
156
- }
157
-
158
- // Splice each child frame's tree under an <iframe> (role 'Iframe')
159
- // placeholder in its parent, matched by document order — BiDi getTree
160
- // lists children in frame order, and the AX walk emits Iframe nodes in
161
- // the same order.
162
- const iframeNodes = (tree) => {
163
- const out = [];
164
- (function walk(n) { if (n.role === 'Iframe') out.push(n); for (const c of n.children) walk(c); })(tree);
165
- return out;
166
- };
167
- for (let i = 1; i < contextIds.length; i++) {
168
- const childTree = treesByContext.get(contextIds[i]);
169
- if (!childTree) continue;
170
- // Attach to the next unfilled Iframe placeholder in the top tree.
171
- const holders = iframeNodes(treesByContext.get(topContext) || { role: '', children: [] });
172
- const holder = holders[i - 1];
173
- if (holder) holder.children = [childTree];
238
+ const root = await buildTree();
239
+ if (root) await dismissConsentFirefox(root, (ref) => pointerClick(ref));
240
+ } catch { /* consent dismissal is best-effort */ }
174
241
  }
242
+ },
175
243
 
176
- const root = treesByContext.get(topContext);
244
+ async snapshot(pruneOpts) {
245
+ const root = await buildTree();
177
246
  if (!root) return '';
178
247
 
179
248
  const pageUrl = await bidi.evaluate(topContext, 'location.href', false).catch(() => '');
@@ -379,23 +448,41 @@ export async function createFirefoxPage(bidi, opts = {}) {
379
448
  throw new Error(`waitFor timed out after ${timeout}ms`);
380
449
  },
381
450
 
451
+ /**
452
+ * Wait until the network has been idle for `idle` ms, over BiDi
453
+ * network.* events. Parity with the CDP page.waitForNetworkIdle (Phase 2).
454
+ */
455
+ async waitForNetworkIdle(idleOpts = {}) {
456
+ return waitForNetworkIdleBiDi(bidi, idleOpts);
457
+ },
458
+
382
459
  // --- CDP-only surfaces, stubbed for daemon parity ---------------------
383
460
  // The daemon (src/daemon.js) dispatches these unconditionally. On the
384
- // Firefox/BiDi engine download tracking and dialog capture aren't wired,
385
- // so the two logs are genuinely empty; the three actions are CDP-only and
386
- // fail with a clear, intentional message instead of an incidental
387
- // TypeError. Documented as a known gap in CHANGELOG (Firefox engine).
461
+ // Firefox/BiDi engine download tracking isn't wired (Phase 4), so downloads
462
+ // is genuinely empty; saveState/waitForNavigation are CDP-only and fail with
463
+ // a clear, intentional message instead of an incidental TypeError.
464
+ // (dialogLog + onDialog ARE wired Phase 3, see setupDialogs above.)
465
+ // Documented as a known gap in CHANGELOG.
388
466
  get downloads() { return []; },
389
- get dialogLog() { return []; },
467
+
468
+ dialogLog,
469
+
470
+ /**
471
+ * Install a custom JS dialog handler, mirroring connect()'s page.onDialog.
472
+ * Called with `{ type, message, defaultPrompt }`; may return (sync or async)
473
+ * `{ accept: bool, promptText: string }` to override the auto-accept
474
+ * default. Pass null to restore default behavior.
475
+ */
476
+ onDialog(handler) {
477
+ onDialogHandler = handler;
478
+ },
479
+
390
480
  async saveState() {
391
481
  throw new Error('saveState() is not supported on the Firefox/BiDi engine (CDP-only)');
392
482
  },
393
483
  async waitForNavigation() {
394
484
  throw new Error('waitForNavigation() is not supported on the Firefox/BiDi engine (CDP-only)');
395
485
  },
396
- async waitForNetworkIdle() {
397
- throw new Error('waitForNetworkIdle() is not supported on the Firefox/BiDi engine (CDP-only)');
398
- },
399
486
 
400
487
  async close() {
401
488
  try { await bidi.send('browsingContext.close', { context: topContext }); } catch {}
@@ -412,5 +499,9 @@ export async function createFirefoxPage(bidi, opts = {}) {
412
499
  await new Promise((r) => setTimeout(r, 500));
413
500
  }
414
501
 
502
+ // Wire the dialog handler before returning — must precede any navigation so
503
+ // an 'ignore' prompt (see bidi.js capability) is never left hanging.
504
+ await setupDialogs();
505
+
415
506
  return page;
416
507
  }
package/src/index.js CHANGED
@@ -19,7 +19,9 @@ import { prune as pruneTree } from './prune.js';
19
19
  import { click as cdpClick, type as cdpType, scroll as cdpScroll, press as cdpPress, hover as cdpHover, select as cdpSelect, drag as cdpDrag, upload as cdpUpload } from './interact.js';
20
20
  import { dismissConsent } from './consent.js';
21
21
  import { applyStealth } from './stealth.js';
22
- import { DEFAULT_BLOCKLIST } from './blocklist.js';
22
+ import { applyFirefoxStealth } from './stealth-firefox.js';
23
+ import { applyFirefoxBlocklist } from './blocklist-firefox.js';
24
+ import { resolveBlocklistPatterns } from './blocklist.js';
23
25
  import { waitForNetworkIdle } from './network-idle.js';
24
26
  import { readable as extractReadable } from './readable.js';
25
27
  import { assertNavigable, assertUploadAllowed } from './url-guard.js';
@@ -746,12 +748,20 @@ async function suppressPermissions(cdp) {
746
748
  * ref model, and AX-tree source all differ (see firefox-page.js). close() is
747
749
  * wrapped to also reap the Firefox process + temp profile.
748
750
  *
749
- * Chromium-only options NOT applied on this path: `consent` (auto-dismiss),
750
- * `blockAds`/`blockUrls`, stealth patches, and `hybrid` mode. `saveState`,
751
- * `waitForNavigation`, `waitForNetworkIdle`, and download/dialog capture are
752
- * CDP-only too (the page object stubs them see firefox-page.js). The
753
- * navigation guard (`allowLocalUrls`/`blockPrivateNetwork`), `uploadDir`
754
- * sandbox, `incognito`, `proxy`, `viewport`, and `pruneMode` DO apply.
751
+ * Chromium-only options NOT applied on this path: `hybrid` mode. `saveState`,
752
+ * `waitForNavigation`, and download capture are CDP-only too (the page object
753
+ * stubs them — see firefox-page.js). `blockAds`/`blockUrls` and JS dialog
754
+ * handling (`dialogLog`/`onDialog`) DO apply as of Phase 3 (ad-block via a
755
+ * catch-all `network.addIntercept` + in-process glob match; dialogs via
756
+ * `browsingContext.userPromptOpened` `handleUserPrompt`).
757
+ * `waitForNetworkIdle` and daemon console/network capture DO apply as of Phase
758
+ * 2 (over BiDi `network.*` and `log.entryAdded` events). `consent`
759
+ * (auto-dismiss) and
760
+ * stealth patches DO apply here as of v0.16.0 (stealth headless-only, via BiDi
761
+ * preload script — see stealth-firefox.js / consent-firefox.js), alongside the
762
+ * navigation guard
763
+ * (`allowLocalUrls`/`blockPrivateNetwork`), `uploadDir` sandbox, `incognito`,
764
+ * `proxy`, `viewport`, and `pruneMode`.
755
765
  * @param {object} opts - connect() options ({ mode, proxy, binary, viewport, pruneMode, urlGuard, uploadDir, incognito })
756
766
  * @returns {Promise<object>} Firefox page object
757
767
  */
@@ -763,11 +773,26 @@ async function connectFirefox(opts) {
763
773
  viewport: opts.viewport,
764
774
  });
765
775
  const bidi = await createBiDi(browser.wsUrl);
776
+ // Stealth patches (headless only, mirroring the CDP path's `mode !== 'headed'`
777
+ // gate). Registered before the first navigation via script.addPreloadScript.
778
+ // Firefox needs a much smaller script than Chromium — see stealth-firefox.js.
779
+ if (opts.mode !== 'headed') {
780
+ await applyFirefoxStealth(bidi);
781
+ }
782
+ // Ad/tracker blocking (parity with the CDP applyBlocklist). Firefox is always
783
+ // a launched, throwaway profile (never attach mode), so the default is on —
784
+ // unlike the CDP path, which defaults off in attach mode. Must run before the
785
+ // first navigation so the catch-all intercept is live when the page loads.
786
+ await applyFirefoxBlocklist(bidi, {
787
+ blockAds: opts.blockAds !== undefined ? opts.blockAds : true,
788
+ blockUrls: opts.blockUrls,
789
+ });
766
790
  const page = await createFirefoxPage(bidi, {
767
791
  pruneMode: opts.pruneMode,
768
792
  urlGuard: { allowLocalUrls: opts.allowLocalUrls, blockPrivateNetwork: opts.blockPrivateNetwork },
769
793
  uploadDir: opts.uploadDir || null,
770
794
  incognito: !!opts.incognito,
795
+ consent: opts.consent,
771
796
  });
772
797
  const closePage = page.close.bind(page);
773
798
  page.close = async () => {
@@ -898,10 +923,7 @@ let blocklistWarned = false;
898
923
  * API surface.
899
924
  */
900
925
  export async function applyBlocklist(session, pageOpts) {
901
- if (pageOpts.blockAds === false && !pageOpts.blockUrls) return;
902
- const patterns = pageOpts.blockAds === false
903
- ? (pageOpts.blockUrls || [])
904
- : [...DEFAULT_BLOCKLIST, ...(pageOpts.blockUrls || [])];
926
+ const patterns = resolveBlocklistPatterns(pageOpts);
905
927
  if (!patterns.length) return;
906
928
  try {
907
929
  await session.send('Network.setBlockedURLs', { urls: patterns });