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/CHANGELOG.md +140 -1
- package/README.md +20 -1
- package/barebrowse.context.md +3 -3
- 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/consent-firefox.js +125 -0
- package/src/consent-patterns.js +154 -0
- package/src/consent.js +5 -144
- package/src/daemon.js +74 -4
- package/src/firefox-page.js +136 -45
- package/src/index.js +33 -11
- package/src/network-idle.js +66 -28
- package/src/stealth-firefox.js +42 -0
- package/src/stealth.js +107 -69
- package/types/blocklist-firefox.d.ts +15 -0
- package/types/blocklist.d.ts +34 -0
- package/types/consent-firefox.d.ts +9 -0
- package/types/consent-patterns.d.ts +12 -0
- package/types/daemon.d.ts +22 -0
- package/types/firefox-page.d.ts +2 -0
- package/types/network-idle.d.ts +16 -8
- package/types/stealth-firefox.d.ts +9 -0
- package/types/stealth.d.ts +31 -0
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
|
-
|
|
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
|
|
143
|
-
// engine
|
|
144
|
-
//
|
|
145
|
-
//
|
|
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
|
package/src/firefox-page.js
CHANGED
|
@@ -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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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: `
|
|
750
|
-
* `
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
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
|
-
|
|
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 });
|