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.
- package/CHANGELOG.md +87 -0
- package/README.md +27 -25
- package/barebrowse.context.md +1 -1
- package/cli.js +1 -1
- package/mcp-server.js +6 -4
- package/package.json +1 -1
- package/src/bidi.js +8 -2
- package/src/blocklist-firefox.js +60 -0
- package/src/blocklist.js +49 -0
- package/src/challenge.js +77 -0
- package/src/dialog.js +52 -0
- package/src/firefox-page.js +209 -21
- package/src/firefox.js +15 -0
- package/src/index.js +84 -116
- package/types/blocklist-firefox.d.ts +15 -0
- package/types/blocklist.d.ts +34 -0
- package/types/challenge.d.ts +25 -0
- package/types/dialog.d.ts +44 -0
- package/types/firefox-page.d.ts +9 -0
- package/types/firefox.d.ts +2 -0
- package/types/index.d.ts +2 -18
package/src/firefox-page.js
CHANGED
|
@@ -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
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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 {
|
|
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
|
-
|
|
356
|
-
message: params.message,
|
|
357
|
-
|
|
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
|
-
*
|
|
751
|
-
* `hybrid` mode
|
|
752
|
-
*
|
|
753
|
-
* `
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
* navigation guard
|
|
759
|
-
*
|
|
760
|
-
*
|
|
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
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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>;
|
package/types/blocklist.d.ts
CHANGED
|
@@ -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;
|