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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.19.1] - 2026-07-11
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **MCP: Firefox sessions now default to `hybrid`** (was `headless`), matching the
|
|
8
|
+
Chromium default — Phase 4 gave Firefox the same headless→headed challenge
|
|
9
|
+
fallback, so there's no reason to hold it back. A failed headed relaunch (no
|
|
10
|
+
display) falls back to the headless result. Override with `BAREBROWSE_MODE`;
|
|
11
|
+
the Chromium path now honors `BAREBROWSE_MODE` too (was hardcoded `hybrid`).
|
|
12
|
+
|
|
13
|
+
### Docs
|
|
14
|
+
|
|
15
|
+
- **README** refocused on primitives and value — Firefox stated as a capability
|
|
16
|
+
set rather than a version-by-version log; stale counts/versions and a redundant
|
|
17
|
+
section removed.
|
|
18
|
+
- **Single PRD** — the Firefox parity plan is merged into `docs/01-product/prd.md`
|
|
19
|
+
as a "Firefox Parity" section (capability matrix + goal/non-goals + forward
|
|
20
|
+
plan); the standalone `firefox-parity-plan.md` is removed.
|
|
21
|
+
- Fixed stale "hybrid is chromium-only" strings in the CLI help and MCP comments.
|
|
22
|
+
|
|
23
|
+
## [0.19.0] - 2026-07-11
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Firefox/BiDi resilience + remaining methods (parity plan Phase 4).** The
|
|
28
|
+
Firefox engine reaches practical parity with the Chromium/CDP engine — four
|
|
29
|
+
CDP-only surfaces now work the same over WebDriver BiDi:
|
|
30
|
+
- **Hybrid mode** (`mode: 'hybrid'`). On a bot-challenge page (Cloudflare "Just
|
|
31
|
+
a moment", hCaptcha, etc.) the Firefox engine now relaunches **headed** and
|
|
32
|
+
retries, exactly like the CDP path — a real display often clears an
|
|
33
|
+
interstitial headless can't. The relaunch tears down the headless
|
|
34
|
+
Firefox+BiDi and hands a fresh connection back to the page, which rebinds to
|
|
35
|
+
it; a failed relaunch (no display) keeps the headless result. Cookies
|
|
36
|
+
injected before the challenge are **re-injected into the fresh headed
|
|
37
|
+
profile** so the retry stays authenticated. The challenge heuristic
|
|
38
|
+
(`isChallengePage`) is now shared by both engines (`challenge.js`).
|
|
39
|
+
- **`page.saveState(file)`** — persists cookies (`storage.getCookies`) +
|
|
40
|
+
localStorage (`script.evaluate`) to a `0600` JSON file, flattened to the
|
|
41
|
+
same shape the CDP path writes (was a throwing stub).
|
|
42
|
+
- **`page.waitForNavigation()`** — resolves on the next top-context
|
|
43
|
+
`browsingContext.load` event, with an SPA settle fallback (was a stub).
|
|
44
|
+
- **`page.downloads`** — real download tracking via
|
|
45
|
+
`browsingContext.downloadWillBegin`/`downloadEnd`. Downloads land in a
|
|
46
|
+
throwaway dir (Firefox `browser.download.*` prefs, parity with CDP's
|
|
47
|
+
`setDownloadBehavior`) and each record carries `{url, suggestedFilename,
|
|
48
|
+
savedPath, state}` with BiDi's `complete` normalized to CDP's `completed`
|
|
49
|
+
(was always empty).
|
|
50
|
+
|
|
51
|
+
BiDi mechanics were POC-measured against real Firefox before wiring (cookie/
|
|
52
|
+
localStorage shapes on real vs opaque origins; the `load` event sequence; that
|
|
53
|
+
Firefox emits `downloadWillBegin`/`downloadEnd` and honors the download-dir
|
|
54
|
+
prefs). The shared JS-dialog decision core was also extracted to `dialog.js`
|
|
55
|
+
(both engines — Phase-3 review finding #5). Guarded by
|
|
56
|
+
`test/unit/firefox-hybrid.test.js` (5, fake-BiDi orchestration) and Firefox
|
|
57
|
+
integration tests (saveState, waitForNavigation ×2, downloads).
|
|
58
|
+
Still Firefox-limited: `reload({ignoreCache})` (upstream BiDi gap, no local
|
|
59
|
+
fix).
|
|
60
|
+
|
|
61
|
+
## [0.18.0] - 2026-07-11
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- **Firefox/BiDi noise-reduction + dialogs (parity plan Phase 3).** Two more
|
|
66
|
+
Chromium/CDP capabilities now work the same on the Firefox engine:
|
|
67
|
+
- **Ad/tracker blocking** (`blockAds`, on by default; `blockUrls` to extend).
|
|
68
|
+
BiDi's `network.addIntercept` can't express our glob patterns — its
|
|
69
|
+
`urlPatterns` reject `*` outright and have no subdomain wildcard — so the
|
|
70
|
+
Firefox path registers a *catch-all* `beforeRequestSent` intercept and
|
|
71
|
+
matches each request URL in-process against the shared `blocklist.js`
|
|
72
|
+
(new `makeBlockMatcher` compiles the CDP globs to a predicate, single-sourced
|
|
73
|
+
across engines). Matches are failed like CDP's `ERR_BLOCKED_BY_CLIENT`; the
|
|
74
|
+
rest continue. Default is **on** (Firefox is always a launched throwaway
|
|
75
|
+
profile, never attach mode). `src/blocklist-firefox.js`.
|
|
76
|
+
- **JS dialog handling** (`alert`/`confirm`/`prompt`/`beforeunload`). The BiDi
|
|
77
|
+
session is created with `unhandledPromptBehavior:'ignore'` so Firefox no
|
|
78
|
+
longer auto-dismisses prompts before we can act; `page.dialogLog` records
|
|
79
|
+
every dialog and `page.onDialog(handler)` overrides the default (accept all
|
|
80
|
+
except `beforeunload`), mirroring the CDP surface exactly.
|
|
81
|
+
|
|
82
|
+
BiDi mechanics were POC-measured against real Firefox before wiring (the "no
|
|
83
|
+
such alert" auto-dismiss race, and that `urlPatterns` reject `*`). Guarded by
|
|
84
|
+
`test/unit/blocklist-firefox.test.js` (10) and Firefox integration tests
|
|
85
|
+
(ad-block via a hermetic CORS server so the `blockAds:false` control genuinely
|
|
86
|
+
passes through; dialog auto-accept + `dialogLog` + custom `onDialog`).
|
|
87
|
+
Remaining Firefox gaps (Phase 4+): hybrid mode, `saveState`,
|
|
88
|
+
`waitForNavigation`, downloads, `reload({ignoreCache})`.
|
|
89
|
+
|
|
3
90
|
## [0.17.0] - 2026-07-10
|
|
4
91
|
|
|
5
92
|
### Added
|
package/README.md
CHANGED
|
@@ -96,15 +96,15 @@ Or manually add to your config (`claude_desktop_config.json`, `.cursor/mcp.json`
|
|
|
96
96
|
}
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
MCP tools: `browse`, `goto`, `snapshot`, `readable`, `click`, `type`, `press`, `scroll`, `hover`, `select`, `back`, `forward`, `reload`, `drag`, `upload`, `pdf`, `screenshot`, `wait_for`, `tabs`. Plus `assess` (privacy scan) if [wearehere](https://github.com/hamr0/wearehere) is installed. Plus opt-in `eval` (`BAREBROWSE_MCP_EVAL=1`) — runs JS in the authenticated session, off by default because it can read cookies/localStorage. Session runs in hybrid mode with automatic cookie injection. Per-tool timeouts (goto/reload/wait_for 60s, back/forward 30s, interactive ops 15s, pdf/screenshot/upload 45s) with auto-retry on transient failures (idempotent only — mutating tools fail loudly to avoid double-submits).
|
|
100
100
|
|
|
101
|
-
`browse` and `snapshot` accept `pruneMode: 'act'|'read'
|
|
101
|
+
`browse` and `snapshot` accept `pruneMode: 'act'|'read'`. `act` (default) keeps interactive elements — best for clicking/filling. `read` keeps paragraphs, headings, and long text — best for articles, docs, and content extraction. If act-mode collapses a content-heavy page near-totally, the snapshot includes a `hint: …` line suggesting `pruneMode='read'` so the agent doesn't bail to a separate HTTP fetch.
|
|
102
102
|
|
|
103
103
|
Troubleshooting MCP setup: `npx barebrowse doctor` scans every known config location and flags scope conflicts. `npx barebrowse install --force` overwrites an existing entry pointing at a different endpoint.
|
|
104
104
|
|
|
105
105
|
### 3. Library -- for agentic automation
|
|
106
106
|
|
|
107
|
-
Import barebrowse in your agent code. One-shot reads, interactive sessions, full observe-think-act loops. Works with any LLM orchestration library. Ships with a ready-made adapter for [bareagent](https://www.npmjs.com/package/bare-agent) (
|
|
107
|
+
Import barebrowse in your agent code. One-shot reads, interactive sessions, full observe-think-act loops. Works with any LLM orchestration library. Ships with a ready-made adapter for [bareagent](https://www.npmjs.com/package/bare-agent) (auto-snapshot after every action).
|
|
108
108
|
|
|
109
109
|
For code examples, API reference, and wiring instructions, see **[barebrowse.context.md](barebrowse.context.md)** -- the full integration guide.
|
|
110
110
|
|
|
@@ -112,10 +112,12 @@ For code examples, API reference, and wiring instructions, see **[barebrowse.con
|
|
|
112
112
|
|
|
113
113
|
| Mode | What happens | Best for |
|
|
114
114
|
|------|-------------|----------|
|
|
115
|
-
| **Headless** (default) | Launches a fresh
|
|
116
|
-
| **Headed** | Auto-launches a visible
|
|
115
|
+
| **Headless** (default) | Launches a fresh browser, no UI | Fast automation, scraping, reading pages |
|
|
116
|
+
| **Headed** | Auto-launches a visible browser window | Bot-detected sites, visual debugging, CAPTCHAs |
|
|
117
117
|
| **Hybrid** | Tries headless first, auto-launches headed if blocked | General-purpose agent browsing |
|
|
118
118
|
|
|
119
|
+
All three work on both engines (Chromium/CDP and Firefox/BiDi).
|
|
120
|
+
|
|
119
121
|
### Attach to your already-running browser
|
|
120
122
|
|
|
121
123
|
Start Chromium yourself with a debug port, then drive your real logged-in session:
|
|
@@ -135,21 +137,22 @@ await page.close(); // closes only the tab barebrowse opened — your browser ke
|
|
|
135
137
|
### Firefox (WebDriver BiDi)
|
|
136
138
|
|
|
137
139
|
CDP is deprecated in Firefox, so barebrowse drives it over the W3C WebDriver
|
|
138
|
-
BiDi protocol
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
BiDi protocol — a second transport over the same `ws` dependency, no extra
|
|
141
|
+
download. Same `page.*` API (the ARIA snapshot is reconstructed in-page since
|
|
142
|
+
BiDi has no `getFullAXTree`):
|
|
141
143
|
|
|
142
144
|
```js
|
|
143
|
-
const page = await connect({ engine: 'firefox' });
|
|
145
|
+
const page = await connect({ engine: 'firefox' });
|
|
144
146
|
```
|
|
145
147
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
engine. Consent auto-dismiss and headless stealth work on Firefox too (as of
|
|
149
|
-
v0.16.0). Chromium (CDP) remains the default; `hybrid` mode, ad/tracker
|
|
150
|
-
blocking, and the daemon's console/network capture are still Chromium-only.
|
|
148
|
+
CLI: `barebrowse open <url> --engine firefox`. MCP: `BAREBROWSE_ENGINE=firefox`.
|
|
149
|
+
Firefox cookies (plaintext) reuse into the same engine.
|
|
151
150
|
|
|
152
|
-
|
|
151
|
+
Firefox is at practical parity with Chromium: stealth, consent auto-dismiss,
|
|
152
|
+
ad/tracker blocking, JS dialogs, console/network capture, hybrid fallback,
|
|
153
|
+
`saveState`, `waitForNavigation`, and download tracking all work the same way.
|
|
154
|
+
Chromium (CDP) stays the default; the only remaining gap is
|
|
155
|
+
`reload({ignoreCache})` (upstream BiDi limitation).
|
|
153
156
|
|
|
154
157
|
### Incognito (clean session)
|
|
155
158
|
|
|
@@ -168,9 +171,9 @@ From the CLI: `barebrowse open <url> --incognito`. From MCP: set
|
|
|
168
171
|
|
|
169
172
|
## What it handles automatically
|
|
170
173
|
|
|
171
|
-
Cookie consent walls (29 languages, with real mouse click fallback for stubborn CMPs), login walls (cookie extraction from your browsers), bot detection (
|
|
174
|
+
Cookie consent walls (29 languages, with real mouse click fallback for stubborn CMPs), login walls (cookie extraction from your browsers), bot detection (challenge-phrase + node-count heuristic + stealth patches + automatic headed fallback — snapshot shows `[BOT CHALLENGE DETECTED]` warning when blocked), permission prompts, SPA navigation, JS dialogs, off-screen elements, pre-filled inputs, ARIA noise, and profile locking. The agent doesn't think about any of it.
|
|
172
175
|
|
|
173
|
-
## Safe by default
|
|
176
|
+
## Safe by default
|
|
174
177
|
|
|
175
178
|
barebrowse hands an autonomous — and therefore prompt-injectable — agent an *authenticated* browser, so the defaults are calibrated for that threat:
|
|
176
179
|
|
|
@@ -221,8 +224,8 @@ Everything the agent can do through barebrowse:
|
|
|
221
224
|
| **Wait for network idle** | Resolve when no pending requests for 500ms |
|
|
222
225
|
| **Dialog handling** | Auto-dismiss JS alert/confirm/prompt dialogs |
|
|
223
226
|
| **Save state** | Export cookies + localStorage to JSON |
|
|
224
|
-
| **Inject cookies** | Extract from Firefox/Chromium and inject
|
|
225
|
-
| **Raw CDP** | Escape hatch for any
|
|
227
|
+
| **Inject cookies** | Extract from Firefox/Chromium and inject into the session (CDP or BiDi) |
|
|
228
|
+
| **Raw CDP / BiDi** | Escape hatch: `page.cdp` (Chromium) or `page.bidi` (Firefox) for any low-level command |
|
|
226
229
|
|
|
227
230
|
## Tested against
|
|
228
231
|
|
|
@@ -230,10 +233,6 @@ Everything the agent can do through barebrowse:
|
|
|
230
233
|
|
|
231
234
|
Google, YouTube, BBC, Wikipedia, GitHub, DuckDuckGo, Hacker News, Amazon DE, The Guardian, Spiegel, Le Monde, El Pais, Corriere, NOS, Bild, Nu.nl, Booking, NYT, Stack Overflow, CNN, Reddit
|
|
232
235
|
|
|
233
|
-
## Context file
|
|
234
|
-
|
|
235
|
-
**[barebrowse.context.md](barebrowse.context.md)** is the full integration guide. Feed it to an AI assistant or read it yourself -- it covers the complete API, snapshot format, interaction loop, auth options, bareagent wiring, MCP setup, and gotchas. Everything you need to wire barebrowse into a project.
|
|
236
|
-
|
|
237
236
|
## How it works
|
|
238
237
|
|
|
239
238
|
```
|
|
@@ -245,12 +244,15 @@ URL -> find/launch browser (chromium.js)
|
|
|
245
244
|
-> navigate to URL, wait for load
|
|
246
245
|
-> detect + dismiss cookie consent dialogs (consent.js)
|
|
247
246
|
-> get full ARIA accessibility tree (aria.js)
|
|
248
|
-
->
|
|
247
|
+
-> pruning pipeline from mcprune (prune.js)
|
|
249
248
|
-> dispatch real input events: click/type/scroll (interact.js)
|
|
250
249
|
-> agent-ready snapshot with [ref=N] markers
|
|
251
250
|
```
|
|
252
251
|
|
|
253
|
-
|
|
252
|
+
Firefox follows the same flow over WebDriver BiDi (`bidi.js`), with the AX tree
|
|
253
|
+
reconstructed in-page (`ax-snapshot.js`) and pruning/formatting shared verbatim.
|
|
254
|
+
|
|
255
|
+
A small set of focused modules, two small dependencies (`ws`, `@mozilla/readability`).
|
|
254
256
|
|
|
255
257
|
## Requirements
|
|
256
258
|
|
package/barebrowse.context.md
CHANGED
|
@@ -95,7 +95,7 @@ const snapshot = await browse('https://example.com', {
|
|
|
95
95
|
| `close()` | -- | void | Close page, disconnect CDP, kill browser (if headless) |
|
|
96
96
|
|
|
97
97
|
**connect() options** (in addition to mode/port/consent):
|
|
98
|
-
- `engine: 'chromium'|'firefox'` — Browser engine. Default `chromium` (CDP). `firefox` drives Firefox over WebDriver BiDi (CDP is deprecated there) — a separate transport with the same `page.*` API; the ARIA snapshot is reconstructed in-page since BiDi has no `getFullAXTree`. Consent auto-dismiss and stealth (headless anti-detection) work on Firefox as of v0.16.0; the CLI daemon's console/network capture and `waitForNetworkIdle` work as of v0.17.0
|
|
98
|
+
- `engine: 'chromium'|'firefox'` — Browser engine. Default `chromium` (CDP). `firefox` drives Firefox over WebDriver BiDi (CDP is deprecated there) — a separate transport with the same `page.*` API; the ARIA snapshot is reconstructed in-page since BiDi has no `getFullAXTree`. Consent auto-dismiss and stealth (headless anti-detection) work on Firefox as of v0.16.0; the CLI daemon's console/network capture and `waitForNetworkIdle` work as of v0.17.0; ad/tracker blocking (`blockAds`/`blockUrls`) and JS dialog handling (`dialogLog`/`onDialog`) work as of Phase 3; `hybrid` mode, `saveState`, `waitForNavigation`, and download tracking (`page.downloads`) work as of Phase 4. The only remaining Firefox gap is `reload()` ignoring `ignoreCache` (upstream BiDi limitation).
|
|
99
99
|
- `port: 9222` — Attach to a Chromium already running with `--remote-debugging-port=N` instead of spawning one. The browser keeps running on `close()`. Stealth + permission denial + download capture are skipped to avoid mutating the user's running browser.
|
|
100
100
|
- `proxy: 'http://...'` — HTTP/SOCKS proxy for browser
|
|
101
101
|
- `viewport: '1280x720'` — Set viewport dimensions
|
package/cli.js
CHANGED
|
@@ -487,7 +487,7 @@ Session:
|
|
|
487
487
|
--engine=chromium|firefox Browser engine (default: chromium/CDP;
|
|
488
488
|
firefox drives over WebDriver BiDi)
|
|
489
489
|
--mode=headless|headed|hybrid Browser mode (default: headless;
|
|
490
|
-
hybrid
|
|
490
|
+
hybrid works on both engines)
|
|
491
491
|
--port=N CDP port for headed mode
|
|
492
492
|
--no-cookies Skip cookie injection
|
|
493
493
|
--incognito Clean, unauthenticated session (skip all auth injection)
|
package/mcp-server.js
CHANGED
|
@@ -123,15 +123,17 @@ async function getPage() {
|
|
|
123
123
|
if (_pageConnecting) return _pageConnecting;
|
|
124
124
|
// Engine selection is a launch-time setting (the browser persists across
|
|
125
125
|
// tool calls), so it's an env var, not a per-request flag. Firefox is driven
|
|
126
|
-
// over WebDriver BiDi and
|
|
127
|
-
// (
|
|
126
|
+
// over WebDriver BiDi and, as of parity Phase 4, has the same hybrid fallback
|
|
127
|
+
// as Chromium (relaunch headed on a bot-challenge page) — so it defaults to
|
|
128
|
+
// hybrid too, matching the Chromium default (override with BAREBROWSE_MODE).
|
|
129
|
+
// A failed headed relaunch (no display) falls back to the headless result.
|
|
128
130
|
// Incognito is a launch-time setting like engine/mode: the session persists
|
|
129
131
|
// across tool calls, so it's an env var (BAREBROWSE_INCOGNITO=1), not a
|
|
130
132
|
// per-request flag. Skips all auth injection for a clean, logged-out session.
|
|
131
133
|
const incognito = process.env.BAREBROWSE_INCOGNITO === '1';
|
|
132
134
|
const connectOpts = process.env.BAREBROWSE_ENGINE === 'firefox'
|
|
133
|
-
? { engine: 'firefox', mode: process.env.BAREBROWSE_MODE || '
|
|
134
|
-
: { mode: 'hybrid', incognito };
|
|
135
|
+
? { engine: 'firefox', mode: process.env.BAREBROWSE_MODE || 'hybrid', incognito }
|
|
136
|
+
: { mode: process.env.BAREBROWSE_MODE || 'hybrid', incognito };
|
|
135
137
|
_pageConnecting = connect(connectOpts);
|
|
136
138
|
try {
|
|
137
139
|
_page = await _pageConnecting;
|
package/package.json
CHANGED
package/src/bidi.js
CHANGED
|
@@ -135,8 +135,14 @@ export async function createBiDi(wsUrl) {
|
|
|
135
135
|
close() { ws.close(); },
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
-
// Open the session.
|
|
139
|
-
|
|
138
|
+
// Open the session. unhandledPromptBehavior:'ignore' stops Firefox from
|
|
139
|
+
// auto-dismissing JS dialogs (alert/confirm/prompt) before we can handle them
|
|
140
|
+
// — without it, browsingContext.handleUserPrompt loses the race and fails
|
|
141
|
+
// with "no such alert" (measured). firefox-page.js wires a userPromptOpened
|
|
142
|
+
// handler before any navigation, so no dialog is ever left hanging.
|
|
143
|
+
const session = await client.send('session.new', {
|
|
144
|
+
capabilities: { alwaysMatch: { unhandledPromptBehavior: 'ignore' } },
|
|
145
|
+
});
|
|
140
146
|
client.sessionId = session.sessionId;
|
|
141
147
|
client.capabilities = session.capabilities;
|
|
142
148
|
return client;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blocklist-firefox.js — Ad/tracker blocking on the Firefox/BiDi engine.
|
|
3
|
+
*
|
|
4
|
+
* The BiDi analogue of index.js's applyBlocklist (CDP Network.setBlockedURLs).
|
|
5
|
+
* BiDi's network.addIntercept can't express our glob patterns — its urlPatterns
|
|
6
|
+
* reject '*' ("forbidden character *") and have no subdomain wildcard — so we
|
|
7
|
+
* register a *catch-all* intercept (empty urlPatterns) at the beforeRequestSent
|
|
8
|
+
* phase and decide per request in-process, matching each URL against the shared
|
|
9
|
+
* blocklist (makeBlockMatcher). Matches are failed (net error, like CDP's
|
|
10
|
+
* ERR_BLOCKED_BY_CLIENT); everything else is continued immediately.
|
|
11
|
+
*
|
|
12
|
+
* Cost vs. CDP: CDP matches browser-side with zero round-trips. Here every
|
|
13
|
+
* request pauses for one continue/fail round-trip to Node. On a typical page
|
|
14
|
+
* (tens–low-hundreds of requests over a local socket) that's negligible, and
|
|
15
|
+
* it's the only route that preserves glob parity without a second list.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { resolveBlocklistPatterns, makeBlockMatcher } from './blocklist.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Install ad/tracker blocking on a BiDi session. Idempotent per session is the
|
|
22
|
+
* caller's concern; call once at connect time before the first navigation.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} bidi - BiDi client (send, subscribe, on).
|
|
25
|
+
* @param {object} pageOpts
|
|
26
|
+
* @param {boolean} [pageOpts.blockAds] - false disables the default list.
|
|
27
|
+
* @param {string[]} [pageOpts.blockUrls] - extra CDP-format globs; extend the
|
|
28
|
+
* default unless blockAds is false, in which case they're the whole list.
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
export async function applyFirefoxBlocklist(bidi, pageOpts = {}) {
|
|
32
|
+
const patterns = resolveBlocklistPatterns(pageOpts);
|
|
33
|
+
if (!patterns.length) return;
|
|
34
|
+
|
|
35
|
+
const isBlocked = makeBlockMatcher(patterns);
|
|
36
|
+
|
|
37
|
+
await bidi.send('network.addIntercept', {
|
|
38
|
+
phases: ['beforeRequestSent'],
|
|
39
|
+
urlPatterns: [],
|
|
40
|
+
});
|
|
41
|
+
await bidi.subscribe(['network.beforeRequestSent']);
|
|
42
|
+
|
|
43
|
+
bidi.on('network.beforeRequestSent', async (e) => {
|
|
44
|
+
// Only paused (intercepted) events need a decision; capture-only listeners
|
|
45
|
+
// see the same event with isBlocked=false and must be ignored here.
|
|
46
|
+
if (!e.isBlocked) return;
|
|
47
|
+
const id = e.request?.request;
|
|
48
|
+
if (!id) return;
|
|
49
|
+
try {
|
|
50
|
+
if (isBlocked(e.request.url)) {
|
|
51
|
+
await bidi.send('network.failRequest', { request: id });
|
|
52
|
+
} else {
|
|
53
|
+
await bidi.send('network.continueRequest', { request: id });
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// "no such request" — the request completed or was cancelled between the
|
|
57
|
+
// event and our reply (redundant nav requests, aborted fetches). Harmless.
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
package/src/blocklist.js
CHANGED
|
@@ -200,3 +200,52 @@ export const DEFAULT_BLOCKLIST = [
|
|
|
200
200
|
'*://*.bluekai.com/*', // Oracle Data Cloud
|
|
201
201
|
'*://*.krxd.net/*', // Salesforce / Krux
|
|
202
202
|
];
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Resolve the effective blocklist from a page's blockAds/blockUrls options.
|
|
206
|
+
* Single-sourced across engines (CDP applyBlocklist + BiDi applyFirefoxBlocklist)
|
|
207
|
+
* so the merge/extend rule can't drift between them:
|
|
208
|
+
* - blockAds !== false → DEFAULT_BLOCKLIST plus any blockUrls (extend);
|
|
209
|
+
* - blockAds === false → only blockUrls (the default list is dropped);
|
|
210
|
+
* - neither → empty (blocking disabled).
|
|
211
|
+
*
|
|
212
|
+
* @param {object} [pageOpts]
|
|
213
|
+
* @param {boolean} [pageOpts.blockAds] - false drops the default list.
|
|
214
|
+
* @param {string[]} [pageOpts.blockUrls] - extra CDP-format globs.
|
|
215
|
+
* @returns {string[]} the patterns to block (possibly empty).
|
|
216
|
+
*/
|
|
217
|
+
export function resolveBlocklistPatterns(pageOpts = {}) {
|
|
218
|
+
return pageOpts.blockAds === false
|
|
219
|
+
? (pageOpts.blockUrls || [])
|
|
220
|
+
: [...DEFAULT_BLOCKLIST, ...(pageOpts.blockUrls || [])];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compile CDP-format glob patterns into a single URL-matching predicate.
|
|
225
|
+
*
|
|
226
|
+
* CDP blocks natively via Network.setBlockedURLs, which does the glob matching
|
|
227
|
+
* browser-side. WebDriver BiDi has no glob-capable equivalent — network.
|
|
228
|
+
* addIntercept's urlPatterns reject '*' outright ("forbidden character *") and
|
|
229
|
+
* can't express subdomain wildcards like *.doubleclick.net. So the Firefox
|
|
230
|
+
* path intercepts *all* requests and matches each URL here, in-process, against
|
|
231
|
+
* the same patterns — keeping the blocklist single-sourced across engines.
|
|
232
|
+
*
|
|
233
|
+
* Matches CDP's glob semantics: '*' = any run of characters, '?' = exactly
|
|
234
|
+
* one character, whole-URL (anchored) match; every other character is literal.
|
|
235
|
+
*
|
|
236
|
+
* @param {string[]} patterns - CDP-format globs (e.g. DEFAULT_BLOCKLIST).
|
|
237
|
+
* @returns {(url: string) => boolean} true when `url` matches any pattern.
|
|
238
|
+
*/
|
|
239
|
+
export function makeBlockMatcher(patterns) {
|
|
240
|
+
const regexes = patterns.map((p) => {
|
|
241
|
+
// Escape every regex metachar EXCEPT the two glob wildcards, then expand
|
|
242
|
+
// them: '*' -> '.*', '?' -> '.'. (Escaping runs first so it never touches
|
|
243
|
+
// the '.' / '*' we insert next.)
|
|
244
|
+
const escaped = p
|
|
245
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
246
|
+
.replace(/\*/g, '.*')
|
|
247
|
+
.replace(/\?/g, '.');
|
|
248
|
+
return new RegExp('^' + escaped + '$');
|
|
249
|
+
});
|
|
250
|
+
return (url) => regexes.some((re) => re.test(url));
|
|
251
|
+
}
|
package/src/challenge.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
/**
|
|
12
|
+
* Heuristic: does this ARIA tree look like a bot-challenge / block interstitial
|
|
13
|
+
* (Cloudflare "Just a moment", hCaptcha, Akamai) rather than real content?
|
|
14
|
+
*
|
|
15
|
+
* H9 split: STRONG_PHRASES are essentially-unambiguous challenge UI and fire
|
|
16
|
+
* regardless of page size; WEAK_PHRASES only fire when the page is ALSO tiny
|
|
17
|
+
* (so a legitimate-looking error page with "access denied" in its body doesn't
|
|
18
|
+
* trip the fallback).
|
|
19
|
+
*
|
|
20
|
+
* @param {object} tree - Nested ARIA tree (from buildTree / ariaTree)
|
|
21
|
+
* @param {number} [nodeCount] - Node count (CDP: getFullAXTree; BiDi: tree walk)
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
export function isChallengePage(tree, nodeCount) {
|
|
25
|
+
if (!tree) return true; // truly empty AX tree — something went wrong fetching the page
|
|
26
|
+
|
|
27
|
+
const text = flattenTreeText(tree);
|
|
28
|
+
const lower = text.toLowerCase();
|
|
29
|
+
|
|
30
|
+
// Strong phrases — distinctive enough to identify the challenge product
|
|
31
|
+
// by name. Fire on their own regardless of node count.
|
|
32
|
+
const STRONG_PHRASES = [
|
|
33
|
+
'just a moment', // Cloudflare interstitial
|
|
34
|
+
'checking if the site connection is secure', // Cloudflare
|
|
35
|
+
'checking your browser', // Various JS challenges
|
|
36
|
+
'verify you are human', // hCaptcha / reCAPTCHA
|
|
37
|
+
'prove your humanity',
|
|
38
|
+
'attention required', // Cloudflare block page
|
|
39
|
+
'enable javascript and cookies to continue', // Cloudflare
|
|
40
|
+
'please complete the security check', // Cloudflare/Akamai
|
|
41
|
+
];
|
|
42
|
+
if (STRONG_PHRASES.some((p) => lower.includes(p))) return true;
|
|
43
|
+
|
|
44
|
+
// Weak phrases — show up on real challenge pages but ALSO on legitimate
|
|
45
|
+
// small error pages. Only count when the page is itself tiny (low node
|
|
46
|
+
// count or near-empty text), which is the corroborating signal that
|
|
47
|
+
// separates a real error UI from a challenge skeleton.
|
|
48
|
+
const WEAK_PHRASES = [
|
|
49
|
+
'please wait',
|
|
50
|
+
'request blocked',
|
|
51
|
+
'access denied',
|
|
52
|
+
'permission denied',
|
|
53
|
+
'unknown error',
|
|
54
|
+
'file a ticket',
|
|
55
|
+
];
|
|
56
|
+
const tinyPage = (nodeCount !== undefined && nodeCount < 30) || text.trim().length < 50;
|
|
57
|
+
if (tinyPage && WEAK_PHRASES.some((p) => lower.includes(p))) return true;
|
|
58
|
+
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Count every node in a nested ARIA tree (shared node-count for the tinyPage test). */
|
|
63
|
+
export function countNodes(node) {
|
|
64
|
+
if (!node) return 0;
|
|
65
|
+
let n = 1;
|
|
66
|
+
for (const c of node.children || []) n += countNodes(c);
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function flattenTreeText(node) {
|
|
71
|
+
if (!node) return '';
|
|
72
|
+
let text = node.name || '';
|
|
73
|
+
for (const child of node.children || []) {
|
|
74
|
+
text += ' ' + flattenTreeText(child);
|
|
75
|
+
}
|
|
76
|
+
return text;
|
|
77
|
+
}
|
package/src/dialog.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dialog.js — Shared JS-dialog decision core for both engines.
|
|
3
|
+
*
|
|
4
|
+
* A JS dialog (alert/confirm/prompt/beforeunload) surfaces differently per
|
|
5
|
+
* protocol — CDP's `Page.javascriptDialogOpening` vs BiDi's
|
|
6
|
+
* `browsingContext.userPromptOpened` — but the *decision* (accept? what prompt
|
|
7
|
+
* text?) and the log entry are identical. This module single-sources both so
|
|
8
|
+
* the CDP path (index.js) and the BiDi path (firefox-page.js) can't drift
|
|
9
|
+
* (code-review Phase-3 finding #5).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a dialogLog entry from a normalized dialog descriptor.
|
|
14
|
+
* @param {string} type - 'alert' | 'confirm' | 'prompt' | 'beforeunload'
|
|
15
|
+
* @param {string} [message]
|
|
16
|
+
* @returns {{type: string, message: string, timestamp: string}}
|
|
17
|
+
*/
|
|
18
|
+
export function dialogLogEntry(type, message) {
|
|
19
|
+
return { type, message: message || '', timestamp: new Date().toISOString() };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decide how to answer a JS dialog. Default policy (both engines): accept
|
|
24
|
+
* everything except `beforeunload` (dismiss = stay on page); a `prompt`
|
|
25
|
+
* returns its default text. A caller-installed handler may override via
|
|
26
|
+
* `{ accept, promptText }`; if the handler throws we keep the defaults so the
|
|
27
|
+
* page never hangs waiting for a reply that never arrives.
|
|
28
|
+
*
|
|
29
|
+
* @param {{type: string, message?: string, defaultPrompt?: string}} dialog
|
|
30
|
+
* @param {?(function({type,message,defaultPrompt}): (object|Promise<object>))} handler
|
|
31
|
+
* @returns {Promise<{accept: boolean, promptText: string}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function decideDialog({ type, message, defaultPrompt }, handler) {
|
|
34
|
+
let accept = type !== 'beforeunload';
|
|
35
|
+
let promptText = defaultPrompt || '';
|
|
36
|
+
if (handler) {
|
|
37
|
+
try {
|
|
38
|
+
const decision = await handler({
|
|
39
|
+
type,
|
|
40
|
+
message: message || '',
|
|
41
|
+
defaultPrompt: defaultPrompt || '',
|
|
42
|
+
});
|
|
43
|
+
if (decision && typeof decision === 'object') {
|
|
44
|
+
if (typeof decision.accept === 'boolean') accept = decision.accept;
|
|
45
|
+
if (typeof decision.promptText === 'string') promptText = decision.promptText;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Handler threw — keep defaults so the page doesn't hang.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { accept, promptText };
|
|
52
|
+
}
|