barebrowse 0.17.0 → 0.19.1

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.
@@ -23,6 +23,8 @@ import { scopedCookiesForUrl } from './auth.js';
23
23
  import { assertNavigable, assertUploadAllowed } from './url-guard.js';
24
24
  import { dismissConsentFirefox } from './consent-firefox.js';
25
25
  import { waitForNetworkIdleBiDi } from './network-idle.js';
26
+ import { decideDialog, dialogLogEntry } from './dialog.js';
27
+ import { isChallengePage, countNodes } from './challenge.js';
26
28
 
27
29
  /** BiDi/WebDriver normalized key values for named keys (U+E000 block). */
28
30
  const BIDI_KEYS = {
@@ -41,6 +43,9 @@ const BIDI_KEYS = {
41
43
  * @param {string} [opts.uploadDir] - When set, upload() rejects files outside this dir.
42
44
  * @param {boolean} [opts.incognito=false] - Clean session: injectCookies() is a no-op.
43
45
  * @param {boolean} [opts.consent=true] - Auto-dismiss cookie consent dialogs after goto().
46
+ * @param {boolean} [opts.hybrid=false] - Relaunch headed on a bot-challenge page and retry.
47
+ * @param {boolean} [opts.headed=false] - Whether the browser was launched headed (skips hybrid fallback).
48
+ * @param {?function(): Promise<{bidi: object, topContext: string}>} [opts.relaunchHeaded] - Hybrid relaunch hook (from connectFirefox).
44
49
  * @returns {Promise<object>} page object
45
50
  */
46
51
  export async function createFirefoxPage(bidi, opts = {}) {
@@ -49,6 +54,17 @@ export async function createFirefoxPage(bidi, opts = {}) {
49
54
  const uploadDir = opts.uploadDir || null;
50
55
  const incognito = !!opts.incognito;
51
56
  const consent = opts.consent !== false;
57
+ // Hybrid mode: on a bot-challenge page, relaunch headed and retry. The
58
+ // relaunch tears down this Firefox+BiDi and returns a fresh one, so the
59
+ // browser lifecycle is owned by connectFirefox, which supplies relaunchHeaded.
60
+ const hybrid = !!opts.hybrid;
61
+ const relaunchHeaded = opts.relaunchHeaded || null;
62
+ let currentlyHeaded = !!opts.headed; // already headed → no fallback needed
63
+ let botBlocked = false;
64
+ // Last injectCookies(url, opts) args, so a hybrid relaunch (a brand-new
65
+ // Firefox profile) can re-inject them — otherwise the headed retry loads
66
+ // unauthenticated, defeating hybrid on an auth-gated challenge page.
67
+ let lastInject = null;
52
68
  // The active browsing context. Starts at the initial tab; switchTab() points
53
69
  // it at another top-level context, so it's mutable and read via a getter.
54
70
  const { contexts } = await bidi.send('browsingContext.getTree', {});
@@ -170,29 +186,143 @@ export async function createFirefoxPage(bidi, opts = {}) {
170
186
  return Promise.race([p, t]).finally(() => clearTimeout(timer));
171
187
  }
172
188
 
189
+ // Download tracking, parity with the CDP `page.downloads` array. Firefox
190
+ // downloads land in `downloadDir` (a throwaway dir the caller sets via launch
191
+ // prefs — see connectFirefox / firefox.js), so `savedPath` is a real path the
192
+ // caller can read. BiDi emits browsingContext.downloadWillBegin (start) +
193
+ // downloadEnd (finish, with the actual filepath + status) — measured against
194
+ // real Firefox. Records mirror the CDP shape:
195
+ // { url, suggestedFilename, savedPath, state, totalBytes, receivedBytes }.
196
+ const downloads = [];
197
+ async function setupDownloads() {
198
+ await bidi.subscribe(['browsingContext.downloadWillBegin', 'browsingContext.downloadEnd']);
199
+ bidi.on('browsingContext.downloadWillBegin', (e) => {
200
+ downloads.push({
201
+ url: e.url,
202
+ suggestedFilename: e.suggestedFilename || '',
203
+ savedPath: null,
204
+ state: 'inProgress',
205
+ totalBytes: 0,
206
+ receivedBytes: 0,
207
+ });
208
+ });
209
+ bidi.on('browsingContext.downloadEnd', (e) => {
210
+ // Correlate to the most recent still-in-progress record for this URL.
211
+ const d = [...downloads].reverse().find((x) => x.url === e.url && x.state === 'inProgress');
212
+ if (!d) return;
213
+ // BiDi status is 'complete' | 'canceled'; normalize 'complete' to CDP's
214
+ // 'completed' so callers can branch on one vocabulary across engines.
215
+ d.state = e.status === 'complete' ? 'completed' : e.status || 'completed';
216
+ d.savedPath = e.filepath || null;
217
+ });
218
+ }
219
+
220
+ // JS dialog handling (alert/confirm/prompt/beforeunload), parity with the
221
+ // CDP path in index.js. The BiDi session is created with
222
+ // unhandledPromptBehavior:'ignore' (see bidi.js) so prompts stay open until
223
+ // we respond; setupDialogs() must run before the first navigation or an
224
+ // 'ignore' prompt would hang the page. Default: accept everything except
225
+ // beforeunload (dismiss = stay), mirroring the CDP default. A caller can
226
+ // override per-dialog via page.onDialog(handler).
227
+ const dialogLog = [];
228
+ let onDialogHandler = null;
229
+ async function setupDialogs() {
230
+ await bidi.subscribe(['browsingContext.userPromptOpened']);
231
+ bidi.on('browsingContext.userPromptOpened', async (e) => {
232
+ dialogLog.push(dialogLogEntry(e.type, e.message));
233
+ // Shared decision core with the CDP path (dialog.js). BiDi's userText is
234
+ // the CDP promptText; its defaultValue is the CDP defaultPrompt.
235
+ const { accept, promptText } = await decideDialog(
236
+ { type: e.type, message: e.message, defaultPrompt: e.defaultValue },
237
+ onDialogHandler,
238
+ );
239
+ try {
240
+ await bidi.send('browsingContext.handleUserPrompt', {
241
+ context: e.context, accept, userText: promptText,
242
+ });
243
+ } catch {
244
+ // Prompt already gone (closed by page JS / navigation). Nothing to do.
245
+ }
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Wire all event subscriptions (dialogs, downloads, load) on the CURRENT
251
+ * bidi connection. Run once at construction, and again after a hybrid
252
+ * relaunch swaps in a fresh connection. Dialogs must be wired before any
253
+ * navigation (the 'ignore' capability would otherwise hang a prompt).
254
+ */
255
+ async function setupSubscriptions() {
256
+ await setupDialogs();
257
+ await setupDownloads();
258
+ await bidi.subscribe(['browsingContext.load']);
259
+ }
260
+
261
+ /**
262
+ * Post-navigation routine shared by goto() and the hybrid retry: settle for
263
+ * dynamic content, auto-dismiss consent (best-effort), then report whether
264
+ * the resulting page looks like a bot challenge.
265
+ */
266
+ async function afterNavigate() {
267
+ await new Promise((r) => setTimeout(r, 500));
268
+ // Build the tree ONCE and use it for both consent dismissal and challenge
269
+ // detection. A challenge page (Cloudflare/hCaptcha) never carries a consent
270
+ // banner, so dismissing consent can't flip the challenge verdict — reusing
271
+ // the pre-dismiss tree avoids a second full AX reconstruction per goto.
272
+ let root = null;
273
+ try { root = await buildTree(); } catch { /* null → treated as challenge */ }
274
+ if (consent && root) {
275
+ try { await dismissConsentFirefox(root, (ref) => pointerClick(ref)); }
276
+ catch { /* consent dismissal is best-effort */ }
277
+ }
278
+ return isChallengePage(root, countNodes(root));
279
+ }
280
+
173
281
  const page = {
174
282
  /** The BiDi escape hatch, analogous to connect()'s page.cdp. */
175
283
  get bidi() { return bidi; },
176
284
  /** The active browsing-context id (getter so it tracks switchTab). */
177
285
  get context() { return topContext; },
286
+ /** Whether the last goto() landed on a bot-challenge page (parity w/ CDP). */
287
+ get botBlocked() { return botBlocked; },
178
288
 
179
289
  async goto(url, timeout = 30000) {
180
290
  // Same navigation guard the CDP path enforces — block file:/chrome:/
181
291
  // view-source: and (optionally) private-network hosts before navigating.
182
292
  assertNavigable(url, urlGuard);
293
+ refContexts = new Map(); // refs from the previous page are now stale
183
294
  await withTimeout(
184
295
  bidi.send('browsingContext.navigate', { context: topContext, url, wait: 'complete' }),
185
296
  timeout, `goto(${url})`);
186
- // Brief settle for dynamic/SPA content, matching the CDP path.
187
- await new Promise((r) => setTimeout(r, 500));
188
- // Auto-dismiss cookie consent, mirroring the CDP path (index.js runs
189
- // dismissConsent right after navigate). Best-effort: a failure here must
190
- // never fail the navigation.
191
- if (consent) {
297
+ // Settle + consent + challenge detection (mirrors the CDP path).
298
+ botBlocked = await afterNavigate();
299
+
300
+ // Hybrid fallback: on a bot-challenge page, relaunch headed once and
301
+ // retry a real display often clears an interstitial headless can't.
302
+ // Only when hybrid mode and we're still headless. relaunchHeaded (from
303
+ // connectFirefox) tears down this Firefox+BiDi and returns a fresh one.
304
+ if (botBlocked && hybrid && !currentlyHeaded && relaunchHeaded) {
192
305
  try {
193
- const root = await buildTree();
194
- if (root) await dismissConsentFirefox(root, (ref) => pointerClick(ref));
195
- } catch { /* consent dismissal is best-effort */ }
306
+ const relaunched = await relaunchHeaded();
307
+ bidi = relaunched.bidi; // rebind the closure — all inner fns follow
308
+ topContext = relaunched.topContext;
309
+ currentlyHeaded = true;
310
+ refContexts = new Map();
311
+ await setupSubscriptions(); // re-wire dialogs/downloads/load on the new bidi
312
+ // Re-inject cookies into the fresh profile BEFORE navigating, so the
313
+ // headed retry is authenticated (the relaunch is a new Firefox profile
314
+ // — the pre-relaunch session's cookies are gone).
315
+ if (lastInject) {
316
+ try { await page.injectCookies(lastInject.url, lastInject.cookieOpts); } catch { /* best-effort */ }
317
+ }
318
+ await withTimeout(
319
+ bidi.send('browsingContext.navigate', { context: topContext, url, wait: 'complete' }),
320
+ timeout, `goto(${url})`);
321
+ botBlocked = await afterNavigate();
322
+ } catch {
323
+ // Headed relaunch failed (no display?) — keep the headless result;
324
+ // botBlocked stays true so the caller can see it didn't clear.
325
+ }
196
326
  }
197
327
  },
198
328
 
@@ -263,6 +393,7 @@ export async function createFirefoxPage(bidi, opts = {}) {
263
393
  */
264
394
  async injectCookies(url, cookieOpts) {
265
395
  if (incognito) return 0;
396
+ lastInject = { url, cookieOpts }; // remember for a hybrid re-inject
266
397
  const cookies = scopedCookiesForUrl(url, { browser: cookieOpts?.browser });
267
398
  let injected = 0;
268
399
  for (const c of cookies) {
@@ -411,19 +542,72 @@ export async function createFirefoxPage(bidi, opts = {}) {
411
542
  return waitForNetworkIdleBiDi(bidi, idleOpts);
412
543
  },
413
544
 
414
- // --- CDP-only surfaces, stubbed for daemon parity ---------------------
415
- // The daemon (src/daemon.js) dispatches these unconditionally. On the
416
- // Firefox/BiDi engine download tracking and dialog capture aren't wired
417
- // (Phase 3/4), so the two logs are genuinely empty; the two actions are
418
- // CDP-only and fail with a clear, intentional message instead of an
419
- // incidental TypeError. Documented as a known gap in CHANGELOG.
420
- get downloads() { return []; },
421
- get dialogLog() { return []; },
422
- async saveState() {
423
- throw new Error('saveState() is not supported on the Firefox/BiDi engine (CDP-only)');
545
+ /** Downloads that began in this session (Phase 4 — see setupDownloads). */
546
+ get downloads() { return downloads; },
547
+
548
+ dialogLog,
549
+
550
+ /**
551
+ * Install a custom JS dialog handler, mirroring connect()'s page.onDialog.
552
+ * Called with `{ type, message, defaultPrompt }`; may return (sync or async)
553
+ * `{ accept: bool, promptText: string }` to override the auto-accept
554
+ * default. Pass null to restore default behavior.
555
+ */
556
+ onDialog(handler) {
557
+ onDialogHandler = handler;
424
558
  },
425
- async waitForNavigation() {
426
- throw new Error('waitForNavigation() is not supported on the Firefox/BiDi engine (CDP-only)');
559
+
560
+ /**
561
+ * Persist cookies + localStorage to a JSON file (parity with the CDP
562
+ * saveState). Cookies come from BiDi storage.getCookies (whose value is a
563
+ * `{type,value}` object) and are flattened to the CDP-symmetric shape so the
564
+ * file format matches across engines. Written 0600 — it holds session
565
+ * tokens, so a multi-user host must not be able to read another user's.
566
+ */
567
+ async saveState(filePath) {
568
+ const { cookies } = await bidi.send('storage.getCookies', {});
569
+ const flat = cookies.map((c) => {
570
+ const out = {
571
+ name: c.name,
572
+ value: c.value && typeof c.value === 'object' ? c.value.value : c.value,
573
+ domain: c.domain,
574
+ path: c.path,
575
+ secure: !!c.secure,
576
+ httpOnly: !!c.httpOnly,
577
+ };
578
+ if (c.sameSite && c.sameSite !== 'default') out.sameSite = c.sameSite;
579
+ if (c.expiry && c.expiry > 0) out.expires = c.expiry;
580
+ return out;
581
+ });
582
+ const lsRaw = await bidi
583
+ .evaluate(topContext, 'JSON.stringify(Object.fromEntries(Object.entries(localStorage)))', false)
584
+ .catch(() => '{}'); // opaque-origin pages (about:blank) throw — treat as empty
585
+ const state = { cookies: flat, localStorage: JSON.parse(lsRaw || '{}') };
586
+ const { writeFileSync, chmodSync } = await import('node:fs');
587
+ writeFileSync(filePath, JSON.stringify(state, null, 2), { mode: 0o600 });
588
+ try { chmodSync(filePath, 0o600); } catch { /* best effort if pre-existing */ }
589
+ },
590
+
591
+ /**
592
+ * Resolve when the active tab fires its next load event (parity with the
593
+ * CDP waitForNavigation → Page.loadEventFired). Scoped to topContext so a
594
+ * subframe load can't resolve it early. Falls back to a short settle delay
595
+ * for SPA navigations that fire no load event.
596
+ */
597
+ async waitForNavigation(timeout = 30000) {
598
+ try {
599
+ await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
600
+ const timer = setTimeout(() => { unsub(); reject(new Error('waitForNavigation timed out')); }, timeout);
601
+ const unsub = bidi.on('browsingContext.load', (e) => {
602
+ if (e.context !== topContext) return;
603
+ clearTimeout(timer); unsub(); resolve();
604
+ });
605
+ }));
606
+ refContexts = new Map(); // the DOM changed — old refs are stale
607
+ } catch {
608
+ // No load event (SPA pushState/replaceState) — settle for DOM updates.
609
+ await new Promise((r) => setTimeout(r, 500));
610
+ }
427
611
  },
428
612
 
429
613
  async close() {
@@ -441,5 +625,9 @@ export async function createFirefoxPage(bidi, opts = {}) {
441
625
  await new Promise((r) => setTimeout(r, 500));
442
626
  }
443
627
 
628
+ // Wire event subscriptions before returning — dialogs must precede any
629
+ // navigation so an 'ignore' prompt (see bidi.js capability) is never hung.
630
+ await setupSubscriptions();
631
+
444
632
  return page;
445
633
  }
package/src/firefox.js CHANGED
@@ -97,6 +97,7 @@ export function findFirefox() {
97
97
  * @param {string} [opts.proxy] - Proxy 'host:port' or 'scheme://host:port'
98
98
  * (http/https → HTTP+SSL proxy; socks/socks5/socks4 → SOCKS), via prefs
99
99
  * @param {{width:number,height:number}} [opts.viewport] - Initial window size
100
+ * @param {string} [opts.downloadDir] - Route downloads here (throwaway dir), no prompt
100
101
  * @returns {Promise<{wsUrl: string, process: import('node:child_process').ChildProcess, port: number, ownedProfileDir: string}>}
101
102
  */
102
103
  export async function launchFirefox(opts = {}) {
@@ -114,6 +115,20 @@ export async function launchFirefox(opts = {}) {
114
115
  'user_pref("geo.prompt.testing", true);',
115
116
  'user_pref("geo.prompt.testing.allow", true);',
116
117
  ];
118
+ if (opts.downloadDir) {
119
+ // Route downloads to a caller-owned throwaway dir (parity with CDP's
120
+ // Browser.setDownloadBehavior) and never prompt — folderList:2 = custom dir.
121
+ // JSON.stringify emits a fully-escaped JS string literal (quotes, backslashes
122
+ // AND newlines/control chars), so a path can't inject extra user_pref lines.
123
+ const dir = JSON.stringify(String(opts.downloadDir));
124
+ prefs.push(
125
+ 'user_pref("browser.download.folderList", 2);',
126
+ `user_pref("browser.download.dir", ${dir});`,
127
+ 'user_pref("browser.download.useDownloadDir", true);',
128
+ 'user_pref("browser.download.manager.showWhenStarting", false);',
129
+ 'user_pref("browser.download.always_ask_before_handling_new_types", false);',
130
+ );
131
+ }
117
132
  if (opts.proxy) {
118
133
  // Honor the scheme: a socks:// proxy must be wired as SOCKS, not HTTP —
119
134
  // otherwise SOCKS traffic is silently sent to an HTTP proxy and fails.
package/src/index.js CHANGED
@@ -20,8 +20,11 @@ import { click as cdpClick, type as cdpType, scroll as cdpScroll, press as cdpPr
20
20
  import { dismissConsent } from './consent.js';
21
21
  import { applyStealth } from './stealth.js';
22
22
  import { applyFirefoxStealth } from './stealth-firefox.js';
23
- import { DEFAULT_BLOCKLIST } from './blocklist.js';
23
+ import { applyFirefoxBlocklist } from './blocklist-firefox.js';
24
+ import { resolveBlocklistPatterns } from './blocklist.js';
24
25
  import { waitForNetworkIdle } from './network-idle.js';
26
+ import { decideDialog, dialogLogEntry } from './dialog.js';
27
+ import { isChallengePage } from './challenge.js';
25
28
  import { readable as extractReadable } from './readable.js';
26
29
  import { assertNavigable, assertUploadAllowed } from './url-guard.js';
27
30
  import { join as pathJoin } from 'node:path';
@@ -351,29 +354,11 @@ export async function connect(opts = {}) {
351
354
  let onDialogHandler = null;
352
355
  function setupDialogHandler(session) {
353
356
  session.on('Page.javascriptDialogOpening', async (params) => {
354
- dialogLog.push({
355
- type: params.type,
356
- message: params.message,
357
- timestamp: new Date().toISOString(),
358
- });
359
- let accept = params.type !== 'beforeunload';
360
- let promptText = params.defaultPrompt || '';
361
- if (onDialogHandler) {
362
- try {
363
- const decision = await onDialogHandler({
364
- type: params.type,
365
- message: params.message,
366
- defaultPrompt: params.defaultPrompt || '',
367
- });
368
- if (decision && typeof decision === 'object') {
369
- if (typeof decision.accept === 'boolean') accept = decision.accept;
370
- if (typeof decision.promptText === 'string') promptText = decision.promptText;
371
- }
372
- } catch {
373
- // Handler threw — fall back to defaults so the page doesn't hang
374
- // waiting for a never-arriving handleJavaScriptDialog reply.
375
- }
376
- }
357
+ dialogLog.push(dialogLogEntry(params.type, params.message));
358
+ const { accept, promptText } = await decideDialog(
359
+ { type: params.type, message: params.message, defaultPrompt: params.defaultPrompt },
360
+ onDialogHandler,
361
+ );
377
362
  await session.send('Page.handleJavaScriptDialog', { accept, promptText });
378
363
  });
379
364
  }
@@ -747,45 +732,89 @@ async function suppressPermissions(cdp) {
747
732
  * ref model, and AX-tree source all differ (see firefox-page.js). close() is
748
733
  * wrapped to also reap the Firefox process + temp profile.
749
734
  *
750
- * Chromium-only options NOT applied on this path: `blockAds`/`blockUrls` and
751
- * `hybrid` mode. `saveState`, `waitForNavigation`, and download/dialog capture
752
- * are CDP-only too (the page object stubs them — see firefox-page.js).
753
- * `waitForNetworkIdle` and daemon console/network capture DO apply as of Phase
754
- * 2 (over BiDi `network.*` and `log.entryAdded` events). `consent`
755
- * (auto-dismiss) and
756
- * stealth patches DO apply here as of v0.16.0 (stealth headless-only, via BiDi
757
- * preload script see stealth-firefox.js / consent-firefox.js), alongside the
758
- * navigation guard
759
- * (`allowLocalUrls`/`blockPrivateNetwork`), `uploadDir` sandbox, `incognito`,
760
- * `proxy`, `viewport`, and `pruneMode`.
761
- * @param {object} opts - connect() options ({ mode, proxy, binary, viewport, pruneMode, urlGuard, uploadDir, incognito })
735
+ * As of Phase 4, the Firefox path reaches practical parity with the CDP path:
736
+ * `hybrid` mode (relaunch headed on a bot-challenge page), `saveState`
737
+ * (`storage.getCookies` + localStorage), `waitForNavigation`
738
+ * (`browsingContext.load`), and download capture (`page.downloads` via
739
+ * `browsingContext.downloadWillBegin`/`downloadEnd`) all work here now.
740
+ * `blockAds`/`blockUrls` + JS dialog handling arrived in Phase 3; console/
741
+ * network capture + `waitForNetworkIdle` in Phase 2; stealth + consent in
742
+ * v0.16.0. Still Firefox-limited: `reload({ignoreCache})` (upstream BiDi gap).
743
+ * The navigation guard (`allowLocalUrls`/`blockPrivateNetwork`), `uploadDir`
744
+ * sandbox, `incognito`, `proxy`, `viewport`, and `pruneMode` all apply.
745
+ * @param {object} opts - connect() options ({ mode, proxy, binary, viewport, pruneMode, urlGuard, uploadDir, incognito, downloadPath, blockAds, blockUrls })
762
746
  * @returns {Promise<object>} Firefox page object
763
747
  */
764
748
  async function connectFirefox(opts) {
765
- const browser = await launchFirefox({
766
- headed: opts.mode === 'headed',
767
- proxy: opts.proxy,
768
- binary: opts.binary,
769
- viewport: opts.viewport,
770
- });
771
- const bidi = await createBiDi(browser.wsUrl);
772
- // Stealth patches (headless only, mirroring the CDP path's `mode !== 'headed'`
773
- // gate). Registered before the first navigation via script.addPreloadScript.
774
- // Firefox needs a much smaller script than Chromium — see stealth-firefox.js.
775
- if (opts.mode !== 'headed') {
776
- await applyFirefoxStealth(bidi);
749
+ const hybrid = opts.mode === 'hybrid';
750
+ const blockOpts = {
751
+ blockAds: opts.blockAds !== undefined ? opts.blockAds : true,
752
+ blockUrls: opts.blockUrls,
753
+ };
754
+
755
+ // Downloads land in a throwaway dir (parity with CDP's setDownloadBehavior).
756
+ // A caller-supplied opts.downloadPath is honored and left alone; otherwise we
757
+ // own + reap a temp dir on close.
758
+ let ownedDownloadDir = null;
759
+ let downloadDir = opts.downloadPath || null;
760
+ if (!downloadDir) {
761
+ const { mkdtempSync } = await import('node:fs');
762
+ const { tmpdir } = await import('node:os');
763
+ ownedDownloadDir = mkdtempSync(pathJoin(tmpdir(), 'barebrowse-ff-dl-'));
764
+ downloadDir = ownedDownloadDir;
765
+ }
766
+
767
+ // Launch Firefox, open a BiDi session, and apply stealth (headless-only,
768
+ // mirroring the CDP `mode !== 'headed'` gate) + ad/tracker blocking. Shared
769
+ // by the initial launch and the hybrid headed relaunch so both are wired
770
+ // identically. Blocking defaults on — Firefox is always a throwaway profile,
771
+ // never attach mode — and must be live before the first navigation.
772
+ async function launchAndSetup(headed) {
773
+ const browser = await launchFirefox({
774
+ headed, proxy: opts.proxy, binary: opts.binary, viewport: opts.viewport, downloadDir,
775
+ });
776
+ const bidi = await createBiDi(browser.wsUrl);
777
+ if (!headed) await applyFirefoxStealth(bidi);
778
+ await applyFirefoxBlocklist(bidi, blockOpts);
779
+ const { contexts } = await bidi.send('browsingContext.getTree', {});
780
+ return { browser, bidi, topContext: contexts[0].context };
777
781
  }
782
+
783
+ let { browser, bidi } = await launchAndSetup(opts.mode === 'headed');
784
+
785
+ // Hybrid relaunch: launch a headed Firefox FIRST (so a failure leaves the
786
+ // current session intact), then tear down the old headless one and hand the
787
+ // fresh bidi/topContext back to the page, which rebinds to it.
788
+ async function relaunchHeaded() {
789
+ const next = await launchAndSetup(true);
790
+ const old = browser;
791
+ browser = next.browser;
792
+ try { bidi.close(); } catch {}
793
+ try { await cleanupFirefox(old); } catch {}
794
+ bidi = next.bidi;
795
+ return { bidi: next.bidi, topContext: next.topContext };
796
+ }
797
+
778
798
  const page = await createFirefoxPage(bidi, {
779
799
  pruneMode: opts.pruneMode,
780
800
  urlGuard: { allowLocalUrls: opts.allowLocalUrls, blockPrivateNetwork: opts.blockPrivateNetwork },
781
801
  uploadDir: opts.uploadDir || null,
782
802
  incognito: !!opts.incognito,
783
803
  consent: opts.consent,
804
+ hybrid,
805
+ headed: opts.mode === 'headed',
806
+ relaunchHeaded: hybrid ? relaunchHeaded : null,
784
807
  });
785
808
  const closePage = page.close.bind(page);
786
809
  page.close = async () => {
787
810
  await closePage();
788
- await cleanupFirefox(browser);
811
+ await cleanupFirefox(browser); // `browser` tracks the relaunched one if hybrid fired
812
+ if (ownedDownloadDir) {
813
+ try {
814
+ const { rmSync } = await import('node:fs');
815
+ rmSync(ownedDownloadDir, { recursive: true, force: true });
816
+ } catch { /* best-effort reap */ }
817
+ }
789
818
  };
790
819
  return page;
791
820
  }
@@ -911,10 +940,7 @@ let blocklistWarned = false;
911
940
  * API surface.
912
941
  */
913
942
  export async function applyBlocklist(session, pageOpts) {
914
- if (pageOpts.blockAds === false && !pageOpts.blockUrls) return;
915
- const patterns = pageOpts.blockAds === false
916
- ? (pageOpts.blockUrls || [])
917
- : [...DEFAULT_BLOCKLIST, ...(pageOpts.blockUrls || [])];
943
+ const patterns = resolveBlocklistPatterns(pageOpts);
918
944
  if (!patterns.length) return;
919
945
  try {
920
946
  await session.send('Network.setBlockedURLs', { urls: patterns });
@@ -1096,66 +1122,8 @@ function extractProps(props) {
1096
1122
  return result;
1097
1123
  }
1098
1124
 
1099
- /**
1100
- * Detect if a page is a bot-challenge page (Cloudflare, hCaptcha, etc.).
1101
- *
1102
- * Pre-H9 this was over-aggressive: `nodeCount < 50` alone fired on any
1103
- * legitimate small page (404s, simple landings, error pages), and generic
1104
- * phrases like "access denied" / "unknown error" / "permission denied"
1105
- * triggered on real HTTP 4xx/5xx pages, kicking hybrid mode into a costly
1106
- * headed fallback for nothing.
1107
- *
1108
- * H9 split: STRONG_PHRASES are essentially-unambiguous challenge UI and
1109
- * fire regardless of page size; WEAK_PHRASES only fire when the page is
1110
- * ALSO tiny (so a legitimate-looking error page with "access denied" in
1111
- * its body doesn't trip the fallback).
1112
- *
1113
- * @param {object} tree - Nested ARIA tree (from buildTree)
1114
- * @param {number} [nodeCount] - Raw CDP node count (from Accessibility.getFullAXTree)
1115
- */
1116
- export function isChallengePage(tree, nodeCount) {
1117
- if (!tree) return true; // truly empty AX tree — something went wrong fetching the page
1118
-
1119
- const text = flattenTreeText(tree);
1120
- const lower = text.toLowerCase();
1121
-
1122
- // Strong phrases — distinctive enough to identify the challenge product
1123
- // by name. Fire on their own regardless of node count.
1124
- const STRONG_PHRASES = [
1125
- 'just a moment', // Cloudflare interstitial
1126
- 'checking if the site connection is secure', // Cloudflare
1127
- 'checking your browser', // Various JS challenges
1128
- 'verify you are human', // hCaptcha / reCAPTCHA
1129
- 'prove your humanity',
1130
- 'attention required', // Cloudflare block page
1131
- 'enable javascript and cookies to continue', // Cloudflare
1132
- 'please complete the security check', // Cloudflare/Akamai
1133
- ];
1134
- if (STRONG_PHRASES.some((p) => lower.includes(p))) return true;
1135
-
1136
- // Weak phrases — show up on real challenge pages but ALSO on legitimate
1137
- // small error pages. Only count when the page is itself tiny (low node
1138
- // count or near-empty text), which is the corroborating signal that
1139
- // separates a real error UI from a challenge skeleton.
1140
- const WEAK_PHRASES = [
1141
- 'please wait',
1142
- 'request blocked',
1143
- 'access denied',
1144
- 'permission denied',
1145
- 'unknown error',
1146
- 'file a ticket',
1147
- ];
1148
- const tinyPage = (nodeCount !== undefined && nodeCount < 30) || text.trim().length < 50;
1149
- if (tinyPage && WEAK_PHRASES.some((p) => lower.includes(p))) return true;
1150
-
1151
- return false;
1152
- }
1153
-
1154
- function flattenTreeText(node) {
1155
- if (!node) return '';
1156
- let text = node.name || '';
1157
- for (const child of node.children || []) {
1158
- text += ' ' + flattenTreeText(child);
1159
- }
1160
- return text;
1161
- }
1125
+ // isChallengePage (the hybrid-fallback gate) now lives in challenge.js so both
1126
+ // the CDP path here and the BiDi path (firefox-page.js) share it without a
1127
+ // circular import. Re-exported (from the local import above) so existing
1128
+ // importers of `src/index.js` and the public API keep working unchanged.
1129
+ export { isChallengePage };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Install ad/tracker blocking on a BiDi session. Idempotent per session is the
3
+ * caller's concern; call once at connect time before the first navigation.
4
+ *
5
+ * @param {object} bidi - BiDi client (send, subscribe, on).
6
+ * @param {object} pageOpts
7
+ * @param {boolean} [pageOpts.blockAds] - false disables the default list.
8
+ * @param {string[]} [pageOpts.blockUrls] - extra CDP-format globs; extend the
9
+ * default unless blockAds is false, in which case they're the whole list.
10
+ * @returns {Promise<void>}
11
+ */
12
+ export function applyFirefoxBlocklist(bidi: object, pageOpts?: {
13
+ blockAds?: boolean | undefined;
14
+ blockUrls?: string[] | undefined;
15
+ }): Promise<void>;
@@ -1,3 +1,37 @@
1
+ /**
2
+ * Resolve the effective blocklist from a page's blockAds/blockUrls options.
3
+ * Single-sourced across engines (CDP applyBlocklist + BiDi applyFirefoxBlocklist)
4
+ * so the merge/extend rule can't drift between them:
5
+ * - blockAds !== false → DEFAULT_BLOCKLIST plus any blockUrls (extend);
6
+ * - blockAds === false → only blockUrls (the default list is dropped);
7
+ * - neither → empty (blocking disabled).
8
+ *
9
+ * @param {object} [pageOpts]
10
+ * @param {boolean} [pageOpts.blockAds] - false drops the default list.
11
+ * @param {string[]} [pageOpts.blockUrls] - extra CDP-format globs.
12
+ * @returns {string[]} the patterns to block (possibly empty).
13
+ */
14
+ export function resolveBlocklistPatterns(pageOpts?: {
15
+ blockAds?: boolean | undefined;
16
+ blockUrls?: string[] | undefined;
17
+ }): string[];
18
+ /**
19
+ * Compile CDP-format glob patterns into a single URL-matching predicate.
20
+ *
21
+ * CDP blocks natively via Network.setBlockedURLs, which does the glob matching
22
+ * browser-side. WebDriver BiDi has no glob-capable equivalent — network.
23
+ * addIntercept's urlPatterns reject '*' outright ("forbidden character *") and
24
+ * can't express subdomain wildcards like *.doubleclick.net. So the Firefox
25
+ * path intercepts *all* requests and matches each URL here, in-process, against
26
+ * the same patterns — keeping the blocklist single-sourced across engines.
27
+ *
28
+ * Matches CDP's glob semantics: '*' = any run of characters, '?' = exactly
29
+ * one character, whole-URL (anchored) match; every other character is literal.
30
+ *
31
+ * @param {string[]} patterns - CDP-format globs (e.g. DEFAULT_BLOCKLIST).
32
+ * @returns {(url: string) => boolean} true when `url` matches any pattern.
33
+ */
34
+ export function makeBlockMatcher(patterns: string[]): (url: string) => boolean;
1
35
  /**
2
36
  * blocklist.js — Ad/tracker URL patterns for CDP Network.setBlockedURLs.
3
37
  *
@@ -0,0 +1,25 @@
1
+ /**
2
+ * challenge.js — Bot-challenge / interstitial detection for the hybrid fallback.
3
+ *
4
+ * Extracted from index.js so both engines can share it without a circular
5
+ * import (index.js drives CDP; firefox-page.js drives BiDi, and both need to
6
+ * decide "is this a Cloudflare/hCaptcha wall we should retry headed?"). Pure —
7
+ * operates on the nested ARIA tree that buildTree() / ariaTree() produce, which
8
+ * is identical in shape across engines.
9
+ */
10
+ /**
11
+ * Heuristic: does this ARIA tree look like a bot-challenge / block interstitial
12
+ * (Cloudflare "Just a moment", hCaptcha, Akamai) rather than real content?
13
+ *
14
+ * H9 split: STRONG_PHRASES are essentially-unambiguous challenge UI and fire
15
+ * regardless of page size; WEAK_PHRASES only fire when the page is ALSO tiny
16
+ * (so a legitimate-looking error page with "access denied" in its body doesn't
17
+ * trip the fallback).
18
+ *
19
+ * @param {object} tree - Nested ARIA tree (from buildTree / ariaTree)
20
+ * @param {number} [nodeCount] - Node count (CDP: getFullAXTree; BiDi: tree walk)
21
+ * @returns {boolean}
22
+ */
23
+ export function isChallengePage(tree: object, nodeCount?: number): boolean;
24
+ /** Count every node in a nested ARIA tree (shared node-count for the tinyPage test). */
25
+ export function countNodes(node: any): number;