barebrowse 0.5.9 → 0.7.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 +90 -0
- package/README.md +5 -5
- package/barebrowse.context.md +10 -13
- package/mcp-server.js +86 -76
- package/package.json +1 -1
- package/src/bareagent.js +9 -4
- package/src/chromium.js +5 -4
- package/src/index.js +51 -33
- package/src/interact.js +21 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
MCP resilience: timeouts, auto-retry, LLM-friendly scroll, and click fallback for hidden elements.
|
|
6
|
+
|
|
7
|
+
### Timeouts (`mcp-server.js`)
|
|
8
|
+
- All MCP tool calls now have a hard timeout: 30s for session tools, 60s for `browse` and `assess`
|
|
9
|
+
- Returns a structured error (`Tool "X" timed out after Ns`) instead of hanging silently
|
|
10
|
+
- Previously: a hung browser or slow page caused `[Tool result missing due to internal error]` — opaque and unrecoverable
|
|
11
|
+
|
|
12
|
+
### Auto-retry (`mcp-server.js`)
|
|
13
|
+
- `withRetry()` wrapper on all session tools (goto, snapshot, click, type, press, scroll, back, forward, drag, upload, pdf)
|
|
14
|
+
- On transient CDP failure (WebSocket closed, target/session closed), resets the session and retries once automatically
|
|
15
|
+
- Non-CDP errors (validation, unknown tool) are not retried
|
|
16
|
+
|
|
17
|
+
### LLM-friendly scroll (`mcp-server.js`, `src/bareagent.js`)
|
|
18
|
+
- Scroll tool now accepts `direction: "up"/"down"` in addition to numeric `deltaY`
|
|
19
|
+
- LLMs naturally say `scroll(direction: "down")` — this now works instead of crashing with `deltaX/deltaY expected for mouseWheel event`
|
|
20
|
+
- `"down"` → `deltaY: 900`, `"up"` → `deltaY: -900`. Numeric `deltaY` still works and takes precedence.
|
|
21
|
+
- Clear validation error if neither `direction` nor `deltaY` is provided
|
|
22
|
+
|
|
23
|
+
### Click JS fallback (`src/interact.js`)
|
|
24
|
+
- Click now falls back to JS `element.click()` when `DOM.scrollIntoViewIfNeeded` fails with "Node does not have a layout object"
|
|
25
|
+
- This error occurs on elements that exist in the ARIA tree but have no visual layout (display:none, zero-size, collapsed sections, detached nodes)
|
|
26
|
+
- Resolves the node via `DOM.requestNode` → `DOM.resolveNode` → `Runtime.callFunctionOn`
|
|
27
|
+
- Other click errors still throw normally
|
|
28
|
+
|
|
29
|
+
### Docs
|
|
30
|
+
- Updated barebrowse.context.md, README.md, prd.md with resilience features
|
|
31
|
+
- MCP server version string updated to 0.7.0
|
|
32
|
+
|
|
33
|
+
### Tests
|
|
34
|
+
- 71/71 passing — no test changes needed
|
|
35
|
+
|
|
36
|
+
## 0.6.1
|
|
37
|
+
|
|
38
|
+
Headed fallback is now a per-navigation escape hatch, not a permanent mode switch. Graceful degradation when headed is unavailable.
|
|
39
|
+
|
|
40
|
+
### Switch-back to headless (`src/index.js`)
|
|
41
|
+
- `connect().goto()` in hybrid mode: if currently headed from a previous fallback, kills the headed browser and launches fresh headless before navigating
|
|
42
|
+
- New `currentlyHeaded` runtime state variable tracks actual browser mode (vs `mode` which is user config)
|
|
43
|
+
- `createPage()` stealth decision uses runtime mode (`!currentlyHeaded`) instead of config mode (`mode !== 'headed'`)
|
|
44
|
+
- `createTab()` also uses `currentlyHeaded` for correct stealth application
|
|
45
|
+
|
|
46
|
+
### Graceful degradation (`src/index.js`)
|
|
47
|
+
- `connect().goto()` hybrid fallback wrapped in try/catch — if `launch({ headed: true })` fails (no `$DISPLAY`, no Wayland, CI/Docker), keeps the headless result with `botBlocked: true` and `[BOT CHALLENGE DETECTED]` warning
|
|
48
|
+
- `browse()` hybrid fallback also wrapped in try/catch — same graceful degradation for one-shot browsing
|
|
49
|
+
- No crash on headless-only environments
|
|
50
|
+
|
|
51
|
+
### Flow after changes
|
|
52
|
+
```
|
|
53
|
+
goto(url) in hybrid mode:
|
|
54
|
+
1. If currently headed → kill headed, launch headless, reset currentlyHeaded
|
|
55
|
+
2. Navigate to url
|
|
56
|
+
3. Check bot-blocked
|
|
57
|
+
4. If bot-blocked → TRY launch headed (set currentlyHeaded=true)
|
|
58
|
+
→ CATCH: headed unavailable, keep headless result
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Docs
|
|
62
|
+
- Updated hybrid mode descriptions in barebrowse.context.md, system-state.md, prd.md
|
|
63
|
+
|
|
64
|
+
### Tests
|
|
65
|
+
- All existing tests pass (tests use headless mode, unaffected by hybrid logic)
|
|
66
|
+
|
|
67
|
+
## 0.6.0
|
|
68
|
+
|
|
69
|
+
Self-launching headed fallback. Headed and hybrid modes no longer require a manually-launched browser on port 9222 — barebrowse auto-launches a visible Chromium window via `launch({ headed: true })`.
|
|
70
|
+
|
|
71
|
+
### Headed mode auto-launch (`src/chromium.js`)
|
|
72
|
+
- `launch()` accepts `headed` option — skips `--headless=new` and `--hide-scrollbars` flags
|
|
73
|
+
- Same temp profile, same random port, same CDP parsing, same process return
|
|
74
|
+
|
|
75
|
+
### Hybrid fallback fix (`src/index.js`)
|
|
76
|
+
- All 4 `getDebugUrl(port)` call sites replaced with `launch({ headed: true, proxy })` + `createCDP(browser.wsUrl)`
|
|
77
|
+
- `browse()` headed branch, `browse()` hybrid fallback, `connect()` headed branch, `connect().goto()` hybrid fallback
|
|
78
|
+
- `getDebugUrl` import removed from index.js (still exported from chromium.js for external use)
|
|
79
|
+
- Hybrid mode now actually works — previously it tried to connect to port 9222 which nobody ran
|
|
80
|
+
|
|
81
|
+
### Assess handler simplified (`mcp-server.js`)
|
|
82
|
+
- Removed dual-path `runAssess(headed)` function (~60 lines of broken headed fallback)
|
|
83
|
+
- Assess now uses the session's hybrid mode: if tab is bot-blocked, triggers headed fallback via main page `goto()`, then retries in a new tab
|
|
84
|
+
- One flow, no separate `connect({ mode: 'headed' })` call
|
|
85
|
+
|
|
86
|
+
### Docs
|
|
87
|
+
- Removed all "launch browser with --remote-debugging-port=9222" instructions
|
|
88
|
+
- Updated headed/hybrid mode descriptions across barebrowse.context.md, README.md, system-state.md, prd.md
|
|
89
|
+
|
|
90
|
+
### Tests
|
|
91
|
+
- 71/71 passing — no test changes needed (all tests use headless mode)
|
|
92
|
+
|
|
3
93
|
## 0.5.8
|
|
4
94
|
|
|
5
95
|
Bot challenge detection for all browsing, not just assess.
|
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ Or manually add to your config (`claude_desktop_config.json`, `.cursor/mcp.json`
|
|
|
87
87
|
}
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
12 tools: `browse`, `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `back`, `forward`, `drag`, `upload`, `pdf`. Plus `assess` (privacy scan) if [wearehere](https://github.com/hamr0/wearehere) is installed. Session runs in hybrid mode with automatic cookie injection.
|
|
90
|
+
12 tools: `browse`, `goto`, `snapshot`, `click`, `type`, `press`, `scroll`, `back`, `forward`, `drag`, `upload`, `pdf`. Plus `assess` (privacy scan) if [wearehere](https://github.com/hamr0/wearehere) is installed. Session runs in hybrid mode with automatic cookie injection. All tools have timeouts (30s/60s) and auto-retry on transient failures.
|
|
91
91
|
|
|
92
92
|
### 3. Library -- for agentic automation
|
|
93
93
|
|
|
@@ -100,8 +100,8 @@ For code examples, API reference, and wiring instructions, see **[barebrowse.con
|
|
|
100
100
|
| Mode | What happens | Best for |
|
|
101
101
|
|------|-------------|----------|
|
|
102
102
|
| **Headless** (default) | Launches a fresh Chromium, no UI | Fast automation, scraping, reading pages |
|
|
103
|
-
| **Headed** |
|
|
104
|
-
| **Hybrid** | Tries headless first,
|
|
103
|
+
| **Headed** | Auto-launches a visible Chromium window | Bot-detected sites, visual debugging, CAPTCHAs |
|
|
104
|
+
| **Hybrid** | Tries headless first, auto-launches headed if blocked | General-purpose agent browsing |
|
|
105
105
|
|
|
106
106
|
## What it handles automatically
|
|
107
107
|
|
|
@@ -129,10 +129,10 @@ Everything the agent can do through barebrowse:
|
|
|
129
129
|
| **Navigate** | Load a URL, wait for page load, auto-dismiss consent |
|
|
130
130
|
| **Back / Forward** | Browser history navigation |
|
|
131
131
|
| **Snapshot** | Pruned ARIA tree with `[ref=N]` markers. Two modes: `act` (buttons, links, inputs) and `read` (full text). 40-90% token reduction. |
|
|
132
|
-
| **Click** | Scroll into view + mouse click at element center |
|
|
132
|
+
| **Click** | Scroll into view + mouse click at element center, JS fallback for hidden elements |
|
|
133
133
|
| **Type** | Focus + insert text, with option to clear existing content first |
|
|
134
134
|
| **Press** | Special keys: Enter, Tab, Escape, Backspace, Delete, arrows, Space |
|
|
135
|
-
| **Scroll** | Mouse wheel up or down |
|
|
135
|
+
| **Scroll** | Mouse wheel up or down (accepts direction or pixels) |
|
|
136
136
|
| **Hover** | Move mouse to element center (triggers tooltips, hover states) |
|
|
137
137
|
| **Select** | Set dropdown value (native select or custom dropdown) |
|
|
138
138
|
| **Drag** | Drag one element to another (Kanban boards, sliders) |
|
package/barebrowse.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# barebrowse -- Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring barebrowse into a project.
|
|
4
|
-
> v0.
|
|
4
|
+
> v0.7.0 | Node.js >= 22 | 0 required deps | MIT
|
|
5
5
|
|
|
6
6
|
## What this is
|
|
7
7
|
|
|
@@ -23,10 +23,8 @@ Three integration paths:
|
|
|
23
23
|
| Mode | What it does | When to use |
|
|
24
24
|
|---|---|---|
|
|
25
25
|
| `headless` (default) | Launches a fresh Chromium, no UI | Scraping, reading, fast automation |
|
|
26
|
-
| `headed` |
|
|
27
|
-
| `hybrid` | Tries headless first,
|
|
28
|
-
|
|
29
|
-
Headed mode requires the browser to be launched with `--remote-debugging-port=9222`.
|
|
26
|
+
| `headed` | Auto-launches a visible Chromium window | Bot-detected sites, debugging, visual tasks |
|
|
27
|
+
| `hybrid` | Tries headless first, headed fallback per-navigation (switches back to headless next time) | General-purpose agent browsing |
|
|
30
28
|
|
|
31
29
|
## Minimal usage: one-shot browse
|
|
32
30
|
|
|
@@ -45,13 +43,12 @@ const snapshot = await browse('https://example.com', {
|
|
|
45
43
|
pruneMode: 'act', // 'act' (interactive elements) | 'read' (all content)
|
|
46
44
|
consent: true, // auto-dismiss cookie consent dialogs
|
|
47
45
|
timeout: 30000, // navigation timeout in ms
|
|
48
|
-
port: 9222, // CDP port for headed/hybrid mode
|
|
49
46
|
});
|
|
50
47
|
```
|
|
51
48
|
|
|
52
49
|
## connect() API
|
|
53
50
|
|
|
54
|
-
`connect(opts)` returns a page handle for interactive sessions. Same opts as `browse()` for mode
|
|
51
|
+
`connect(opts)` returns a page handle for interactive sessions. Same opts as `browse()` for mode. Supports `hybrid` mode — starts headless, auto-launches headed on bot detection (same as `browse()`).
|
|
55
52
|
|
|
56
53
|
| Method | Args | Returns | Notes |
|
|
57
54
|
|---|---|---|---|
|
|
@@ -62,7 +59,7 @@ const snapshot = await browse('https://example.com', {
|
|
|
62
59
|
| `click(ref)` | ref: string | void | Scroll into view + mouse press+release at center |
|
|
63
60
|
| `type(ref, text, opts?)` | ref: string, text: string, opts: { clear?, keyEvents? } | void | Focus + insert text. `clear: true` replaces existing. |
|
|
64
61
|
| `press(key)` | key: string | void | Special key: Enter, Tab, Escape, Backspace, Delete, arrows, Home, End, PageUp, PageDown, Space |
|
|
65
|
-
| `scroll(deltaY)` | deltaY: number | void | Mouse wheel. Positive = down, negative = up. |
|
|
62
|
+
| `scroll(deltaY)` | deltaY: number | void | Mouse wheel. Positive = down, negative = up. MCP/bareagent also accept `direction: "up"/"down"`. |
|
|
66
63
|
| `hover(ref)` | ref: string | void | Move mouse to element center |
|
|
67
64
|
| `select(ref, value)` | ref: string, value: string | void | Set `<select>` value or click custom dropdown option |
|
|
68
65
|
| `drag(fromRef, toRef)` | fromRef: string, toRef: string | void | Drag from one element to another |
|
|
@@ -152,7 +149,7 @@ barebrowse can inject cookies from the user's real browser sessions, bypassing l
|
|
|
152
149
|
| Media autoplay blocked | `--autoplay-policy=no-user-gesture-required` | Both |
|
|
153
150
|
| Login walls | Cookie extraction from Firefox/Chromium + CDP injection | Both |
|
|
154
151
|
| Pre-filled form inputs | `type({ clear: true })` selects all + deletes first | Both |
|
|
155
|
-
| Off-screen elements | `DOM.scrollIntoViewIfNeeded` before every click | Both |
|
|
152
|
+
| Off-screen elements | `DOM.scrollIntoViewIfNeeded` before every click, JS `.click()` fallback for no-layout elements | Both |
|
|
156
153
|
| Form submission | `press('Enter')` triggers onsubmit | Both |
|
|
157
154
|
| SPA navigation | `waitForNavigation()` uses loadEventFired + frameNavigated | Both |
|
|
158
155
|
| Bot detection | ARIA node count (<50 = bot-blocked) + text heuristics. `botBlocked` flag set after every `goto()`. Hybrid fallback switches to headed. Snapshot shows `[BOT CHALLENGE DETECTED]` warning. | Hybrid |
|
|
@@ -244,7 +241,7 @@ Action tools return `'ok'` -- the agent calls `snapshot` explicitly to observe.
|
|
|
244
241
|
|
|
245
242
|
Session runs in hybrid mode (headless with automatic headed fallback on bot detection). `goto` injects cookies from the user's browser before navigation for authenticated access.
|
|
246
243
|
|
|
247
|
-
Session tools share a singleton page, lazy-created on first use.
|
|
244
|
+
Session tools share a singleton page, lazy-created on first use. All session tools have auto-retry on transient CDP failures (browser crash, WebSocket close) — session resets and retries once automatically. 30s timeout on all tools (60s for `browse`/`assess`). Scroll accepts `direction: "up"/"down"` in addition to numeric `deltaY`. Click falls back to JS `.click()` when elements have no layout. Assess tries headless first; if bot-blocked, retries headed. Browser OOM/crash auto-recovers (session resets, server stays alive).
|
|
248
245
|
|
|
249
246
|
## Architecture
|
|
250
247
|
|
|
@@ -312,13 +309,13 @@ Useful for agent threshold decisions: "skip sites above score 40", "warn if term
|
|
|
312
309
|
|
|
313
310
|
3. **Pruning modes matter.** `act` mode (default) keeps interactive elements + visible labels. `read` mode keeps all text content. Use `read` for content extraction, `act` for form filling and navigation.
|
|
314
311
|
|
|
315
|
-
4. **Headed mode
|
|
312
|
+
4. **Headed mode auto-launches Chromium.** No need to start a browser manually — barebrowse launches a headed Chromium instance with CDP enabled automatically.
|
|
316
313
|
|
|
317
314
|
5. **Cookie extraction needs unlocked profile.** Chromium cookies are AES-encrypted with a keyring key. If Chromium is running, the profile may be locked. Firefox cookies are plaintext and always accessible.
|
|
318
315
|
|
|
319
|
-
6. **Hybrid mode
|
|
316
|
+
6. **Hybrid mode is per-navigation.** If headless is bot-blocked, hybrid kills headless and launches headed for that URL. On the next `goto()`, it switches back to headless automatically. If headed can't launch (no display — CI, Docker), it degrades gracefully with the headless result and a `[BOT CHALLENGE DETECTED]` warning.
|
|
320
317
|
|
|
321
|
-
7. **One page per connect().** Each `connect()` call creates one page.
|
|
318
|
+
7. **One page per connect(), but tabs are supported.** Each `connect()` call creates one page. Use `createTab()` for additional tabs in the same browser.
|
|
322
319
|
|
|
323
320
|
8. **Consent dismiss is best-effort.** It handles 16+ tested sites across 29 languages but novel consent implementations may need manual handling. Disable with `{ consent: false }`.
|
|
324
321
|
|
package/mcp-server.js
CHANGED
|
@@ -25,6 +25,18 @@ function isCdpDead(err) {
|
|
|
25
25
|
return m.includes('WebSocket') || m.includes('Target closed') || m.includes('Session closed') || m.includes('CDP');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Retry-once wrapper for transient CDP failures. Resets session and retries. */
|
|
29
|
+
async function withRetry(fn) {
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (!isCdpDead(err)) throw err;
|
|
34
|
+
// CDP died — reset session and retry once
|
|
35
|
+
_page = null;
|
|
36
|
+
return await fn();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
const MAX_CHARS_DEFAULT = 30000;
|
|
29
41
|
const OUTPUT_DIR = join(process.cwd(), '.barebrowse');
|
|
30
42
|
|
|
@@ -139,13 +151,13 @@ const TOOLS = [
|
|
|
139
151
|
},
|
|
140
152
|
{
|
|
141
153
|
name: 'scroll',
|
|
142
|
-
description: 'Scroll the page.
|
|
154
|
+
description: 'Scroll the page up or down. Pass direction ("up"/"down") or a numeric deltaY. Returns ok.',
|
|
143
155
|
inputSchema: {
|
|
144
156
|
type: 'object',
|
|
145
157
|
properties: {
|
|
146
|
-
|
|
158
|
+
direction: { type: 'string', enum: ['up', 'down'], description: 'Scroll direction — "up" or "down" (scrolls ~3 screen-heights)' },
|
|
159
|
+
deltaY: { type: 'number', description: 'Pixels to scroll (positive=down, negative=up). Overrides direction if both given.' },
|
|
147
160
|
},
|
|
148
|
-
required: ['deltaY'],
|
|
149
161
|
},
|
|
150
162
|
},
|
|
151
163
|
{
|
|
@@ -222,13 +234,13 @@ async function handleToolCall(name, args) {
|
|
|
222
234
|
}
|
|
223
235
|
return text;
|
|
224
236
|
}
|
|
225
|
-
case 'goto': {
|
|
237
|
+
case 'goto': return withRetry(async () => {
|
|
226
238
|
const page = await getPage();
|
|
227
239
|
try { await page.injectCookies(args.url); } catch {}
|
|
228
240
|
await page.goto(args.url);
|
|
229
241
|
return 'ok';
|
|
230
|
-
}
|
|
231
|
-
case 'snapshot': {
|
|
242
|
+
});
|
|
243
|
+
case 'snapshot': return withRetry(async () => {
|
|
232
244
|
const page = await getPage();
|
|
233
245
|
const text = await page.snapshot();
|
|
234
246
|
const limit = args.maxChars ?? MAX_CHARS_DEFAULT;
|
|
@@ -237,110 +249,101 @@ async function handleToolCall(name, args) {
|
|
|
237
249
|
return `Snapshot (${text.length} chars) saved to ${file}`;
|
|
238
250
|
}
|
|
239
251
|
return text;
|
|
240
|
-
}
|
|
241
|
-
case 'click': {
|
|
252
|
+
});
|
|
253
|
+
case 'click': return withRetry(async () => {
|
|
242
254
|
const page = await getPage();
|
|
243
255
|
await page.click(args.ref);
|
|
244
256
|
return 'ok';
|
|
245
|
-
}
|
|
246
|
-
case 'type': {
|
|
257
|
+
});
|
|
258
|
+
case 'type': return withRetry(async () => {
|
|
247
259
|
const page = await getPage();
|
|
248
260
|
await page.type(args.ref, args.text, { clear: args.clear });
|
|
249
261
|
return 'ok';
|
|
250
|
-
}
|
|
251
|
-
case 'press': {
|
|
262
|
+
});
|
|
263
|
+
case 'press': return withRetry(async () => {
|
|
252
264
|
const page = await getPage();
|
|
253
265
|
await page.press(args.key);
|
|
254
266
|
return 'ok';
|
|
255
|
-
}
|
|
256
|
-
case 'scroll': {
|
|
267
|
+
});
|
|
268
|
+
case 'scroll': return withRetry(async () => {
|
|
257
269
|
const page = await getPage();
|
|
258
|
-
|
|
270
|
+
let dy = args.deltaY;
|
|
271
|
+
if (dy == null && args.direction) {
|
|
272
|
+
dy = args.direction === 'up' ? -900 : 900;
|
|
273
|
+
}
|
|
274
|
+
if (dy == null || typeof dy !== 'number') {
|
|
275
|
+
throw new Error('scroll requires "direction" ("up"/"down") or numeric "deltaY"');
|
|
276
|
+
}
|
|
277
|
+
await page.scroll(dy);
|
|
259
278
|
return 'ok';
|
|
260
|
-
}
|
|
261
|
-
case 'back': {
|
|
279
|
+
});
|
|
280
|
+
case 'back': return withRetry(async () => {
|
|
262
281
|
const page = await getPage();
|
|
263
282
|
await page.goBack();
|
|
264
283
|
return 'ok';
|
|
265
|
-
}
|
|
266
|
-
case 'forward': {
|
|
284
|
+
});
|
|
285
|
+
case 'forward': return withRetry(async () => {
|
|
267
286
|
const page = await getPage();
|
|
268
287
|
await page.goForward();
|
|
269
288
|
return 'ok';
|
|
270
|
-
}
|
|
271
|
-
case 'drag': {
|
|
289
|
+
});
|
|
290
|
+
case 'drag': return withRetry(async () => {
|
|
272
291
|
const page = await getPage();
|
|
273
292
|
await page.drag(args.fromRef, args.toRef);
|
|
274
293
|
return 'ok';
|
|
275
|
-
}
|
|
276
|
-
case 'upload': {
|
|
294
|
+
});
|
|
295
|
+
case 'upload': return withRetry(async () => {
|
|
277
296
|
const page = await getPage();
|
|
278
297
|
await page.upload(args.ref, args.files);
|
|
279
298
|
return 'ok';
|
|
280
|
-
}
|
|
281
|
-
case 'pdf': {
|
|
299
|
+
});
|
|
300
|
+
case 'pdf': return withRetry(async () => {
|
|
282
301
|
const page = await getPage();
|
|
283
302
|
return await page.pdf({ landscape: args.landscape });
|
|
284
|
-
}
|
|
303
|
+
});
|
|
285
304
|
case 'assess': {
|
|
286
305
|
if (!assessFn) throw new Error('wearehere is not installed. Run: npm install wearehere');
|
|
287
306
|
const releaseSlot = await acquireAssessSlot();
|
|
288
307
|
try {
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
tab = await connect({ mode: 'headed' });
|
|
293
|
-
} else {
|
|
294
|
-
const page = await getPage();
|
|
295
|
-
tab = await page.createTab();
|
|
296
|
-
}
|
|
297
|
-
let timer;
|
|
298
|
-
try {
|
|
299
|
-
const result = await Promise.race([
|
|
300
|
-
(async () => {
|
|
301
|
-
await tab.injectCookies(args.url).catch(() => {});
|
|
302
|
-
return await assessFn(tab, args.url, { timeout: args.timeout, settle: args.settle });
|
|
303
|
-
})(),
|
|
304
|
-
new Promise((_, reject) => {
|
|
305
|
-
timer = setTimeout(() => {
|
|
306
|
-
tab.close().catch(() => {});
|
|
307
|
-
reject(new Error('assess timeout'));
|
|
308
|
-
}, 30000);
|
|
309
|
-
}),
|
|
310
|
-
]);
|
|
311
|
-
clearTimeout(timer);
|
|
312
|
-
const wasBotBlocked = tab.botBlocked;
|
|
313
|
-
await tab.close().catch(() => {});
|
|
314
|
-
return { result, botBlocked: wasBotBlocked };
|
|
315
|
-
} catch (err) {
|
|
316
|
-
clearTimeout(timer);
|
|
317
|
-
await tab.close().catch(() => {});
|
|
318
|
-
throw err;
|
|
319
|
-
}
|
|
320
|
-
};
|
|
321
|
-
|
|
322
|
-
// Try headless first
|
|
308
|
+
const page = await getPage();
|
|
309
|
+
const tab = await page.createTab();
|
|
310
|
+
let timer;
|
|
323
311
|
try {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
312
|
+
await tab.injectCookies(args.url).catch(() => {});
|
|
313
|
+
const result = await Promise.race([
|
|
314
|
+
assessFn(tab, args.url, { timeout: args.timeout, settle: args.settle }),
|
|
315
|
+
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('assess timeout')), 30000); }),
|
|
316
|
+
]);
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
if (tab.botBlocked) {
|
|
319
|
+
// Bot-blocked — trigger hybrid fallback via main page, retry in new tab
|
|
320
|
+
await tab.close().catch(() => {});
|
|
321
|
+
await page.goto(args.url);
|
|
322
|
+
const tab2 = await page.createTab();
|
|
323
|
+
let timer2;
|
|
327
324
|
try {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
325
|
+
await tab2.injectCookies(args.url).catch(() => {});
|
|
326
|
+
const r2 = await Promise.race([
|
|
327
|
+
assessFn(tab2, args.url, { timeout: args.timeout, settle: args.settle }),
|
|
328
|
+
new Promise((_, rej) => { timer2 = setTimeout(() => rej(new Error('assess timeout')), 30000); }),
|
|
329
|
+
]);
|
|
330
|
+
clearTimeout(timer2);
|
|
331
|
+
if (tab2.botBlocked) r2._warning = 'Bot-blocked in both modes. Score may be unreliable.';
|
|
332
|
+
await tab2.close().catch(() => {});
|
|
333
|
+
return JSON.stringify(r2, null, 2);
|
|
334
|
+
} catch (err2) {
|
|
335
|
+
clearTimeout(timer2);
|
|
336
|
+
await tab2.close().catch(() => {});
|
|
337
|
+
throw err2;
|
|
332
338
|
}
|
|
333
339
|
}
|
|
340
|
+
await tab.close().catch(() => {});
|
|
334
341
|
return JSON.stringify(result, null, 2);
|
|
335
342
|
} catch (err) {
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
await tab.close().catch(() => {});
|
|
336
345
|
if (isCdpDead(err)) _page = null;
|
|
337
|
-
|
|
338
|
-
try {
|
|
339
|
-
const headed = await runAssess(true);
|
|
340
|
-
return JSON.stringify(headed.result, null, 2);
|
|
341
|
-
} catch (retryErr) {
|
|
342
|
-
throw retryErr;
|
|
343
|
-
}
|
|
346
|
+
throw err;
|
|
344
347
|
}
|
|
345
348
|
} finally {
|
|
346
349
|
releaseSlot();
|
|
@@ -366,7 +369,7 @@ async function handleMessage(msg) {
|
|
|
366
369
|
return jsonrpcResponse(id, {
|
|
367
370
|
protocolVersion: '2024-11-05',
|
|
368
371
|
capabilities: { tools: {} },
|
|
369
|
-
serverInfo: { name: 'barebrowse', version: '0.
|
|
372
|
+
serverInfo: { name: 'barebrowse', version: '0.7.0' },
|
|
370
373
|
});
|
|
371
374
|
}
|
|
372
375
|
|
|
@@ -380,12 +383,19 @@ async function handleMessage(msg) {
|
|
|
380
383
|
|
|
381
384
|
if (method === 'tools/call') {
|
|
382
385
|
const { name, arguments: args } = params;
|
|
386
|
+
const TOOL_TIMEOUT = name === 'browse' || name === 'assess' ? 60000 : 30000;
|
|
383
387
|
try {
|
|
384
|
-
|
|
388
|
+
let timer;
|
|
389
|
+
const result = await Promise.race([
|
|
390
|
+
handleToolCall(name, args || {}),
|
|
391
|
+
new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`Tool "${name}" timed out after ${TOOL_TIMEOUT / 1000}s`)), TOOL_TIMEOUT); }),
|
|
392
|
+
]);
|
|
393
|
+
clearTimeout(timer);
|
|
385
394
|
return jsonrpcResponse(id, {
|
|
386
395
|
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
387
396
|
});
|
|
388
397
|
} catch (err) {
|
|
398
|
+
if (isCdpDead(err)) _page = null;
|
|
389
399
|
return jsonrpcResponse(id, {
|
|
390
400
|
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
391
401
|
isError: true,
|
package/package.json
CHANGED
package/src/bareagent.js
CHANGED
|
@@ -116,15 +116,20 @@ export function createBrowseTools(opts = {}) {
|
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
118
|
name: 'scroll',
|
|
119
|
-
description: 'Scroll the page. Returns the updated snapshot.',
|
|
119
|
+
description: 'Scroll the page up or down. Pass direction ("up"/"down") or a numeric deltaY. Returns the updated snapshot.',
|
|
120
120
|
parameters: {
|
|
121
121
|
type: 'object',
|
|
122
122
|
properties: {
|
|
123
|
-
|
|
123
|
+
direction: { type: 'string', enum: ['up', 'down'], description: 'Scroll direction — "up" or "down" (scrolls ~3 screen-heights)' },
|
|
124
|
+
deltaY: { type: 'number', description: 'Pixels to scroll (positive=down, negative=up). Overrides direction if both given.' },
|
|
124
125
|
},
|
|
125
|
-
required: ['deltaY'],
|
|
126
126
|
},
|
|
127
|
-
execute: async ({ deltaY }) =>
|
|
127
|
+
execute: async ({ direction, deltaY }) => {
|
|
128
|
+
let dy = deltaY;
|
|
129
|
+
if (dy == null && direction) dy = direction === 'up' ? -900 : 900;
|
|
130
|
+
if (dy == null || typeof dy !== 'number') throw new Error('scroll requires "direction" or numeric "deltaY"');
|
|
131
|
+
return actionAndSnapshot((page) => page.scroll(dy));
|
|
132
|
+
},
|
|
128
133
|
},
|
|
129
134
|
{
|
|
130
135
|
name: 'select',
|
package/src/chromium.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* chromium.js — Find, launch, and connect to Chromium-based browsers.
|
|
3
3
|
*
|
|
4
4
|
* Supports: Chrome, Chromium, Brave, Edge, Vivaldi, Arc, Opera.
|
|
5
|
-
* Modes: headless (launch new), headed (
|
|
5
|
+
* Modes: headless (launch new, no UI), headed (launch new, visible window).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { execSync, spawn } from 'node:child_process';
|
|
@@ -55,11 +55,12 @@ export function findBrowser() {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Launch a
|
|
58
|
+
* Launch a Chromium instance with CDP enabled.
|
|
59
59
|
* @param {object} [opts]
|
|
60
60
|
* @param {string} [opts.binary] - Path to browser binary (auto-detected if omitted)
|
|
61
61
|
* @param {number} [opts.port=0] - CDP port (0 = random available port)
|
|
62
62
|
* @param {string} [opts.userDataDir] - Browser profile directory
|
|
63
|
+
* @param {boolean} [opts.headed=false] - Launch in headed mode (with visible window)
|
|
63
64
|
* @returns {Promise<{wsUrl: string, process: ChildProcess, port: number}>}
|
|
64
65
|
*/
|
|
65
66
|
export async function launch(opts = {}) {
|
|
@@ -67,7 +68,6 @@ export async function launch(opts = {}) {
|
|
|
67
68
|
const port = opts.port || 0;
|
|
68
69
|
|
|
69
70
|
const args = [
|
|
70
|
-
'--headless=new',
|
|
71
71
|
`--remote-debugging-port=${port}`,
|
|
72
72
|
'--no-first-run',
|
|
73
73
|
'--no-default-browser-check',
|
|
@@ -75,7 +75,8 @@ export async function launch(opts = {}) {
|
|
|
75
75
|
'--disable-sync',
|
|
76
76
|
'--disable-translate',
|
|
77
77
|
'--mute-audio',
|
|
78
|
-
|
|
78
|
+
// Headless-only flags
|
|
79
|
+
...(!opts.headed ? ['--headless=new', '--hide-scrollbars'] : []),
|
|
79
80
|
// Suppress permission prompts (location, notifications, camera, mic, etc.)
|
|
80
81
|
'--disable-notifications',
|
|
81
82
|
'--autoplay-policy=no-user-gesture-required',
|
package/src/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* const snapshot = await browse('https://example.com');
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { launch
|
|
11
|
+
import { launch } from './chromium.js';
|
|
12
12
|
import { createCDP } from './cdp.js';
|
|
13
13
|
import { formatTree } from './aria.js';
|
|
14
14
|
import { authenticate } from './auth.js';
|
|
@@ -27,7 +27,6 @@ import { applyStealth } from './stealth.js';
|
|
|
27
27
|
* @param {boolean} [opts.cookies=true] - Inject user's cookies (Phase 2)
|
|
28
28
|
* @param {boolean} [opts.prune=true] - Apply ARIA pruning (Phase 2)
|
|
29
29
|
* @param {number} [opts.timeout=30000] - Navigation timeout in ms
|
|
30
|
-
* @param {number} [opts.port] - CDP port for headed mode
|
|
31
30
|
* @returns {Promise<string>} ARIA snapshot text
|
|
32
31
|
*/
|
|
33
32
|
export async function browse(url, opts = {}) {
|
|
@@ -40,9 +39,8 @@ export async function browse(url, opts = {}) {
|
|
|
40
39
|
try {
|
|
41
40
|
// Step 1: Get a CDP connection
|
|
42
41
|
if (mode === 'headed') {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
cdp = await createCDP(wsUrl);
|
|
42
|
+
browser = await launch({ headed: true, proxy: opts.proxy });
|
|
43
|
+
cdp = await createCDP(browser.wsUrl);
|
|
46
44
|
} else {
|
|
47
45
|
// headless or hybrid (start headless)
|
|
48
46
|
browser = await launch({ proxy: opts.proxy });
|
|
@@ -81,17 +79,20 @@ export async function browse(url, opts = {}) {
|
|
|
81
79
|
cdp.close();
|
|
82
80
|
if (browser) { browser.process.kill(); browser = null; }
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
try {
|
|
83
|
+
browser = await launch({ headed: true, proxy: opts.proxy });
|
|
84
|
+
cdp = await createCDP(browser.wsUrl);
|
|
85
|
+
page = await createPage(cdp, false, { viewport: opts.viewport });
|
|
86
|
+
await suppressPermissions(cdp);
|
|
87
|
+
if (opts.cookies !== false) {
|
|
88
|
+
try { await authenticate(page.session, url, { browser: opts.browser }); } catch {}
|
|
89
|
+
}
|
|
90
|
+
await navigate(page, url, timeout);
|
|
91
|
+
if (opts.consent !== false) await dismissConsent(page.session);
|
|
92
|
+
({ tree } = await ariaTree(page));
|
|
93
|
+
} catch {
|
|
94
|
+
// Headed launch failed (no display?) — return headless result as-is
|
|
91
95
|
}
|
|
92
|
-
await navigate(page, url, timeout);
|
|
93
|
-
if (opts.consent !== false) await dismissConsent(page.session);
|
|
94
|
-
({ tree } = await ariaTree(page));
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
// Step 6: Prune for agent consumption
|
|
@@ -121,7 +122,6 @@ export async function browse(url, opts = {}) {
|
|
|
121
122
|
*
|
|
122
123
|
* @param {object} [opts]
|
|
123
124
|
* @param {'headless'|'headed'|'hybrid'} [opts.mode='headless'] - Browser mode
|
|
124
|
-
* @param {number} [opts.port=9222] - CDP port for headed mode
|
|
125
125
|
* @returns {Promise<object>} Page handle with goto, snapshot, close
|
|
126
126
|
*/
|
|
127
127
|
export async function connect(opts = {}) {
|
|
@@ -130,15 +130,15 @@ export async function connect(opts = {}) {
|
|
|
130
130
|
let cdp;
|
|
131
131
|
|
|
132
132
|
if (mode === 'headed') {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
cdp = await createCDP(wsUrl);
|
|
133
|
+
browser = await launch({ headed: true, proxy: opts.proxy });
|
|
134
|
+
cdp = await createCDP(browser.wsUrl);
|
|
136
135
|
} else {
|
|
137
136
|
browser = await launch({ proxy: opts.proxy });
|
|
138
137
|
cdp = await createCDP(browser.wsUrl);
|
|
139
138
|
}
|
|
140
139
|
|
|
141
|
-
let
|
|
140
|
+
let currentlyHeaded = (mode === 'headed');
|
|
141
|
+
let page = await createPage(cdp, !currentlyHeaded, { viewport: opts.viewport });
|
|
142
142
|
let refMap = new Map();
|
|
143
143
|
let botBlocked = false;
|
|
144
144
|
|
|
@@ -175,6 +175,20 @@ export async function connect(opts = {}) {
|
|
|
175
175
|
|
|
176
176
|
return {
|
|
177
177
|
async goto(url, timeout = 30000) {
|
|
178
|
+
// Switch back to headless if we fell back to headed previously
|
|
179
|
+
if (currentlyHeaded && mode === 'hybrid') {
|
|
180
|
+
await cdp.send('Target.closeTarget', { targetId: page.targetId });
|
|
181
|
+
cdp.close();
|
|
182
|
+
if (browser) { browser.process.kill(); browser = null; }
|
|
183
|
+
|
|
184
|
+
browser = await launch({ proxy: opts.proxy });
|
|
185
|
+
cdp = await createCDP(browser.wsUrl);
|
|
186
|
+
page = await createPage(cdp, true, { viewport: opts.viewport });
|
|
187
|
+
setupDialogHandler(page.session);
|
|
188
|
+
await suppressPermissions(cdp);
|
|
189
|
+
currentlyHeaded = false;
|
|
190
|
+
}
|
|
191
|
+
|
|
178
192
|
await navigate(page, url, timeout);
|
|
179
193
|
if (opts.consent !== false) {
|
|
180
194
|
await dismissConsent(page.session);
|
|
@@ -190,18 +204,22 @@ export async function connect(opts = {}) {
|
|
|
190
204
|
cdp.close();
|
|
191
205
|
if (browser) { browser.process.kill(); browser = null; }
|
|
192
206
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
try {
|
|
208
|
+
browser = await launch({ headed: true, proxy: opts.proxy });
|
|
209
|
+
cdp = await createCDP(browser.wsUrl);
|
|
210
|
+
page = await createPage(cdp, false, { viewport: opts.viewport });
|
|
211
|
+
setupDialogHandler(page.session);
|
|
212
|
+
await suppressPermissions(cdp);
|
|
213
|
+
await navigate(page, url, timeout);
|
|
214
|
+
if (opts.consent !== false) await dismissConsent(page.session);
|
|
215
|
+
|
|
216
|
+
// Re-check after headed fallback
|
|
217
|
+
const after = await ariaTree(page);
|
|
218
|
+
botBlocked = isChallengePage(after.tree, after.nodeCount);
|
|
219
|
+
currentlyHeaded = true;
|
|
220
|
+
} catch {
|
|
221
|
+
// Headed launch failed (no display?) — keep headless result, botBlocked stays true
|
|
222
|
+
}
|
|
205
223
|
}
|
|
206
224
|
},
|
|
207
225
|
|
|
@@ -375,7 +393,7 @@ export async function connect(opts = {}) {
|
|
|
375
393
|
cdp: page.session,
|
|
376
394
|
|
|
377
395
|
async createTab() {
|
|
378
|
-
const tab = await createPage(cdp,
|
|
396
|
+
const tab = await createPage(cdp, !currentlyHeaded, { viewport: opts.viewport });
|
|
379
397
|
await suppressPermissions(cdp);
|
|
380
398
|
let tabBotBlocked = false;
|
|
381
399
|
return {
|
package/src/interact.js
CHANGED
|
@@ -46,13 +46,27 @@ async function getCenter(session, backendNodeId) {
|
|
|
46
46
|
* @param {number} backendNodeId - Backend DOM node ID
|
|
47
47
|
*/
|
|
48
48
|
export async function click(session, backendNodeId) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
try {
|
|
50
|
+
const { x, y } = await getCenter(session, backendNodeId);
|
|
51
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
52
|
+
type: 'mousePressed', x, y, button: 'left', clickCount: 1,
|
|
53
|
+
});
|
|
54
|
+
await session.send('Input.dispatchMouseEvent', {
|
|
55
|
+
type: 'mouseReleased', x, y, button: 'left', clickCount: 1,
|
|
56
|
+
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Element has no layout (display:none, zero-size, detached) — fall back to JS click
|
|
59
|
+
if (err.message && err.message.includes('layout object')) {
|
|
60
|
+
const { nodeId } = await session.send('DOM.requestNode', { backendNodeId });
|
|
61
|
+
const { object } = await session.send('DOM.resolveNode', { nodeId });
|
|
62
|
+
await session.send('Runtime.callFunctionOn', {
|
|
63
|
+
objectId: object.objectId,
|
|
64
|
+
functionDeclaration: 'function() { this.click(); }',
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
/**
|