browserfabric 1.0.0__py3-none-any.whl
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.
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
browserfabric - Python SDK for BrowserFabric API
|
|
3
|
+
|
|
4
|
+
Simple Usage:
|
|
5
|
+
import browserfabric
|
|
6
|
+
|
|
7
|
+
# Super simple interface
|
|
8
|
+
mcp = browserfabric.MCP("browseruse")
|
|
9
|
+
session = await mcp.create_session()
|
|
10
|
+
await session.navigate("https://example.com")
|
|
11
|
+
await session.screenshot("page.png")
|
|
12
|
+
|
|
13
|
+
# Or even simpler with context manager
|
|
14
|
+
async with browserfabric.browser() as browser:
|
|
15
|
+
await browser.navigate("https://example.com")
|
|
16
|
+
await browser.click("#button")
|
|
17
|
+
await browser.screenshot("result.png")
|
|
18
|
+
|
|
19
|
+
# One-liner screenshot
|
|
20
|
+
await browserfabric.screenshot("https://example.com", "example.png")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "1.0.0"
|
|
24
|
+
__author__ = "BrowserFabric Contributors"
|
|
25
|
+
__email__ = "info@example.com"
|
|
26
|
+
|
|
27
|
+
# Simple client interface
|
|
28
|
+
from .client import MCP, MCPError, browser, ensure_server_running, screenshot, test_form
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Simple client interface
|
|
32
|
+
"MCP",
|
|
33
|
+
"browser",
|
|
34
|
+
"screenshot",
|
|
35
|
+
"test_form",
|
|
36
|
+
"ensure_server_running",
|
|
37
|
+
"MCPError",
|
|
38
|
+
]
|
browserfabric/client.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Super simple client interface for BrowserFabric.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
import browserfabric
|
|
6
|
+
|
|
7
|
+
# Simple usage
|
|
8
|
+
mcp = browserfabric.MCP("browseruse")
|
|
9
|
+
session = await mcp.create_session()
|
|
10
|
+
await session.navigate("https://example.com")
|
|
11
|
+
await session.screenshot("page.png")
|
|
12
|
+
|
|
13
|
+
# With persistent context
|
|
14
|
+
session = await mcp.create_session(persist=True)
|
|
15
|
+
await session.navigate("https://twitter.com")
|
|
16
|
+
# ... user logs in ...
|
|
17
|
+
ctx = await session.save_context("twitter-login")
|
|
18
|
+
await session.close()
|
|
19
|
+
|
|
20
|
+
# Restore context later
|
|
21
|
+
session = await mcp.create_session(context_id=ctx["context_id"])
|
|
22
|
+
# Already logged in!
|
|
23
|
+
|
|
24
|
+
# Or even simpler
|
|
25
|
+
async with browserfabric.browser() as browser:
|
|
26
|
+
await browser.navigate("https://example.com")
|
|
27
|
+
await browser.click("#button")
|
|
28
|
+
await browser.screenshot("result.png")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import base64
|
|
33
|
+
import os
|
|
34
|
+
from contextlib import asynccontextmanager
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
import httpx
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MCPError(Exception):
|
|
42
|
+
"""Base exception for MCP operations."""
|
|
43
|
+
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BrowserSession:
|
|
48
|
+
"""Represents a browser session with convenient methods."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, client: "MCPClient", session_id: str, ws_url: Optional[str] = None,
|
|
51
|
+
cdp_port: Optional[int] = None):
|
|
52
|
+
self.client = client
|
|
53
|
+
self.session_id = session_id
|
|
54
|
+
self.ws_url = ws_url
|
|
55
|
+
self.cdp_port = cdp_port
|
|
56
|
+
self._closed = False
|
|
57
|
+
|
|
58
|
+
async def navigate(self, url: str, wait_until: str = "domcontentloaded") -> Dict[str, Any]:
|
|
59
|
+
"""Navigate to a URL. wait_until can be: domcontentloaded, load, networkidle, commit."""
|
|
60
|
+
if self._closed:
|
|
61
|
+
raise MCPError("Session is closed")
|
|
62
|
+
|
|
63
|
+
result = await self.client._call_tool(
|
|
64
|
+
"navigate", {"url": url, "wait_until": wait_until, "session_id": self.session_id}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if not result.get("success"):
|
|
68
|
+
raise MCPError(f"Navigation failed: {result.get('error')}")
|
|
69
|
+
|
|
70
|
+
return result["result"]
|
|
71
|
+
|
|
72
|
+
async def click(self, selector: str, by: str = "css") -> Dict[str, Any]:
|
|
73
|
+
"""Click an element."""
|
|
74
|
+
if self._closed:
|
|
75
|
+
raise MCPError("Session is closed")
|
|
76
|
+
|
|
77
|
+
result = await self.client._call_tool(
|
|
78
|
+
"click_element",
|
|
79
|
+
{"selector": selector, "by": by, "session_id": self.session_id},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not result.get("success"):
|
|
83
|
+
raise MCPError(f"Click failed: {result.get('error')}")
|
|
84
|
+
|
|
85
|
+
return result["result"]
|
|
86
|
+
|
|
87
|
+
async def type(self, selector: str, text: str, by: str = "css") -> Dict[str, Any]:
|
|
88
|
+
"""Type text into an element."""
|
|
89
|
+
if self._closed:
|
|
90
|
+
raise MCPError("Session is closed")
|
|
91
|
+
|
|
92
|
+
result = await self.client._call_tool(
|
|
93
|
+
"type_text",
|
|
94
|
+
{
|
|
95
|
+
"selector": selector,
|
|
96
|
+
"text": text,
|
|
97
|
+
"by": by,
|
|
98
|
+
"session_id": self.session_id,
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if not result.get("success"):
|
|
103
|
+
raise MCPError(f"Type failed: {result.get('error')}")
|
|
104
|
+
|
|
105
|
+
return result["result"]
|
|
106
|
+
|
|
107
|
+
async def find(self, selector: str, by: str = "css") -> List[Dict[str, Any]]:
|
|
108
|
+
"""Find elements on the page."""
|
|
109
|
+
if self._closed:
|
|
110
|
+
raise MCPError("Session is closed")
|
|
111
|
+
|
|
112
|
+
result = await self.client._call_tool(
|
|
113
|
+
"find_elements",
|
|
114
|
+
{"selector": selector, "by": by, "session_id": self.session_id},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if not result.get("success"):
|
|
118
|
+
raise MCPError(f"Find failed: {result.get('error')}")
|
|
119
|
+
|
|
120
|
+
return result["result"].get("elements", [])
|
|
121
|
+
|
|
122
|
+
async def screenshot(
|
|
123
|
+
self, filename: Optional[str] = None, save_dir: str = "screenshots", full_page: bool = False
|
|
124
|
+
) -> str:
|
|
125
|
+
"""Take a screenshot and save it. Set full_page=True for full scrollable page."""
|
|
126
|
+
if self._closed:
|
|
127
|
+
raise MCPError("Session is closed")
|
|
128
|
+
|
|
129
|
+
result = await self.client._call_tool(
|
|
130
|
+
"take_screenshot", {"full_page": full_page, "session_id": self.session_id}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if not result.get("success"):
|
|
134
|
+
raise MCPError(f"Screenshot failed: {result.get('error')}")
|
|
135
|
+
|
|
136
|
+
screenshot_b64 = result["result"]["screenshot"]
|
|
137
|
+
screenshot_data = base64.b64decode(screenshot_b64)
|
|
138
|
+
|
|
139
|
+
if not filename:
|
|
140
|
+
from datetime import datetime
|
|
141
|
+
|
|
142
|
+
filename = f"screenshot_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
|
143
|
+
|
|
144
|
+
save_path = Path(save_dir)
|
|
145
|
+
save_path.mkdir(exist_ok=True)
|
|
146
|
+
|
|
147
|
+
filepath = save_path / filename
|
|
148
|
+
with open(filepath, "wb") as f:
|
|
149
|
+
f.write(screenshot_data)
|
|
150
|
+
|
|
151
|
+
return str(filepath)
|
|
152
|
+
|
|
153
|
+
async def page_info(self) -> Dict[str, Any]:
|
|
154
|
+
"""Get current page information."""
|
|
155
|
+
if self._closed:
|
|
156
|
+
raise MCPError("Session is closed")
|
|
157
|
+
|
|
158
|
+
result = await self.client._call_tool(
|
|
159
|
+
"get_page_info", {"session_id": self.session_id}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not result.get("success"):
|
|
163
|
+
raise MCPError(f"Get page info failed: {result.get('error')}")
|
|
164
|
+
|
|
165
|
+
return result["result"]
|
|
166
|
+
|
|
167
|
+
async def observe(self) -> Dict[str, Any]:
|
|
168
|
+
"""Get simplified text-based DOM tree of important visible elements with interaction paths."""
|
|
169
|
+
if self._closed:
|
|
170
|
+
raise MCPError("Session is closed")
|
|
171
|
+
|
|
172
|
+
result = await self.client._call_tool(
|
|
173
|
+
"observe", {"session_id": self.session_id}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if not result.get("success"):
|
|
177
|
+
raise MCPError(f"Observe failed: {result.get('error')}")
|
|
178
|
+
|
|
179
|
+
return result["result"]
|
|
180
|
+
|
|
181
|
+
async def snapshot(self) -> str:
|
|
182
|
+
"""Get the accessibility tree of the page."""
|
|
183
|
+
if self._closed:
|
|
184
|
+
raise MCPError("Session is closed")
|
|
185
|
+
|
|
186
|
+
result = await self.client._call_tool(
|
|
187
|
+
"snapshot", {"session_id": self.session_id}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if not result.get("success"):
|
|
191
|
+
raise MCPError(f"Snapshot failed: {result.get('error')}")
|
|
192
|
+
|
|
193
|
+
return result["result"].get("snapshot", "")
|
|
194
|
+
|
|
195
|
+
async def session_info(self) -> Dict[str, Any]:
|
|
196
|
+
"""Get session metadata including CDP WebSocket URL."""
|
|
197
|
+
if self._closed:
|
|
198
|
+
raise MCPError("Session is closed")
|
|
199
|
+
|
|
200
|
+
result = await self.client._call_tool(
|
|
201
|
+
"get_session_info", {"session_id": self.session_id}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if not result.get("success"):
|
|
205
|
+
raise MCPError(f"Get session info failed: {result.get('error')}")
|
|
206
|
+
|
|
207
|
+
return result["result"]
|
|
208
|
+
|
|
209
|
+
async def wait_for(
|
|
210
|
+
self, selector: str, state: str = "visible", timeout: int = 30, by: str = "css"
|
|
211
|
+
) -> Dict[str, Any]:
|
|
212
|
+
"""Wait for an element to reach a given state (visible, hidden, attached, detached)."""
|
|
213
|
+
if self._closed:
|
|
214
|
+
raise MCPError("Session is closed")
|
|
215
|
+
result = await self.client._call_tool(
|
|
216
|
+
"wait_for",
|
|
217
|
+
{"selector": selector, "state": state, "timeout": timeout, "by": by, "session_id": self.session_id},
|
|
218
|
+
)
|
|
219
|
+
if not result.get("success"):
|
|
220
|
+
raise MCPError(f"Wait failed: {result.get('error')}")
|
|
221
|
+
return result["result"]
|
|
222
|
+
|
|
223
|
+
async def scroll(self, direction: str = "down", amount: int = 500) -> Dict[str, Any]:
|
|
224
|
+
"""Scroll the page. Directions: down, up, top, bottom."""
|
|
225
|
+
if self._closed:
|
|
226
|
+
raise MCPError("Session is closed")
|
|
227
|
+
result = await self.client._call_tool(
|
|
228
|
+
"scroll",
|
|
229
|
+
{"direction": direction, "amount": amount, "session_id": self.session_id},
|
|
230
|
+
)
|
|
231
|
+
if not result.get("success"):
|
|
232
|
+
raise MCPError(f"Scroll failed: {result.get('error')}")
|
|
233
|
+
return result["result"]
|
|
234
|
+
|
|
235
|
+
async def press_key(self, key: str) -> Dict[str, Any]:
|
|
236
|
+
"""Press a keyboard key (Enter, Tab, Escape, ArrowDown, etc.)."""
|
|
237
|
+
if self._closed:
|
|
238
|
+
raise MCPError("Session is closed")
|
|
239
|
+
result = await self.client._call_tool(
|
|
240
|
+
"press_key", {"key": key, "session_id": self.session_id},
|
|
241
|
+
)
|
|
242
|
+
if not result.get("success"):
|
|
243
|
+
raise MCPError(f"Press key failed: {result.get('error')}")
|
|
244
|
+
return result["result"]
|
|
245
|
+
|
|
246
|
+
async def select_option(self, selector: str, value: str, by: str = "css") -> Dict[str, Any]:
|
|
247
|
+
"""Select an option from a dropdown/select element."""
|
|
248
|
+
if self._closed:
|
|
249
|
+
raise MCPError("Session is closed")
|
|
250
|
+
result = await self.client._call_tool(
|
|
251
|
+
"select_option",
|
|
252
|
+
{"selector": selector, "value": value, "by": by, "session_id": self.session_id},
|
|
253
|
+
)
|
|
254
|
+
if not result.get("success"):
|
|
255
|
+
raise MCPError(f"Select failed: {result.get('error')}")
|
|
256
|
+
return result["result"]
|
|
257
|
+
|
|
258
|
+
async def hover(self, selector: str, by: str = "css") -> Dict[str, Any]:
|
|
259
|
+
"""Hover over an element."""
|
|
260
|
+
if self._closed:
|
|
261
|
+
raise MCPError("Session is closed")
|
|
262
|
+
result = await self.client._call_tool(
|
|
263
|
+
"hover",
|
|
264
|
+
{"selector": selector, "by": by, "session_id": self.session_id},
|
|
265
|
+
)
|
|
266
|
+
if not result.get("success"):
|
|
267
|
+
raise MCPError(f"Hover failed: {result.get('error')}")
|
|
268
|
+
return result["result"]
|
|
269
|
+
|
|
270
|
+
async def evaluate_js(self, expression: str) -> Any:
|
|
271
|
+
"""Execute JavaScript in the page and return the result."""
|
|
272
|
+
if self._closed:
|
|
273
|
+
raise MCPError("Session is closed")
|
|
274
|
+
result = await self.client._call_tool(
|
|
275
|
+
"evaluate_js",
|
|
276
|
+
{"expression": expression, "session_id": self.session_id},
|
|
277
|
+
)
|
|
278
|
+
if not result.get("success"):
|
|
279
|
+
raise MCPError(f"JS eval failed: {result.get('error')}")
|
|
280
|
+
return result["result"].get("result")
|
|
281
|
+
|
|
282
|
+
async def go_back(self) -> Dict[str, Any]:
|
|
283
|
+
"""Navigate back in browser history."""
|
|
284
|
+
if self._closed:
|
|
285
|
+
raise MCPError("Session is closed")
|
|
286
|
+
result = await self.client._call_tool(
|
|
287
|
+
"go_back", {"session_id": self.session_id},
|
|
288
|
+
)
|
|
289
|
+
if not result.get("success"):
|
|
290
|
+
raise MCPError(f"Go back failed: {result.get('error')}")
|
|
291
|
+
return result["result"]
|
|
292
|
+
|
|
293
|
+
async def go_forward(self) -> Dict[str, Any]:
|
|
294
|
+
"""Navigate forward in browser history."""
|
|
295
|
+
if self._closed:
|
|
296
|
+
raise MCPError("Session is closed")
|
|
297
|
+
result = await self.client._call_tool(
|
|
298
|
+
"go_forward", {"session_id": self.session_id},
|
|
299
|
+
)
|
|
300
|
+
if not result.get("success"):
|
|
301
|
+
raise MCPError(f"Go forward failed: {result.get('error')}")
|
|
302
|
+
return result["result"]
|
|
303
|
+
|
|
304
|
+
async def screenshot_element(self, selector: str, by: str = "css") -> str:
|
|
305
|
+
"""Take a screenshot of a specific element. Returns base64 PNG."""
|
|
306
|
+
if self._closed:
|
|
307
|
+
raise MCPError("Session is closed")
|
|
308
|
+
result = await self.client._call_tool(
|
|
309
|
+
"screenshot_element",
|
|
310
|
+
{"selector": selector, "by": by, "session_id": self.session_id},
|
|
311
|
+
)
|
|
312
|
+
if not result.get("success"):
|
|
313
|
+
raise MCPError(f"Element screenshot failed: {result.get('error')}")
|
|
314
|
+
return result["result"]["screenshot"]
|
|
315
|
+
|
|
316
|
+
async def page_summary(self) -> Dict[str, Any]:
|
|
317
|
+
"""Get a quick summary of the current page state."""
|
|
318
|
+
if self._closed:
|
|
319
|
+
raise MCPError("Session is closed")
|
|
320
|
+
result = await self.client._call_tool(
|
|
321
|
+
"page_summary", {"session_id": self.session_id},
|
|
322
|
+
)
|
|
323
|
+
if not result.get("success"):
|
|
324
|
+
raise MCPError(f"Page summary failed: {result.get('error')}")
|
|
325
|
+
return result["result"]
|
|
326
|
+
|
|
327
|
+
async def save_context(self, name: str) -> Dict[str, Any]:
|
|
328
|
+
"""Save browser context (cookies, localStorage) for later restoration.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
name: Human-readable name for the context (e.g., "twitter-login")
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Dict with context_id, name, cookies_count, status
|
|
335
|
+
"""
|
|
336
|
+
if self._closed:
|
|
337
|
+
raise MCPError("Session is closed")
|
|
338
|
+
|
|
339
|
+
result = await self.client._call_tool(
|
|
340
|
+
"save_context", {"context_name": name, "session_id": self.session_id}
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if not result.get("success"):
|
|
344
|
+
raise MCPError(f"Save context failed: {result.get('error')}")
|
|
345
|
+
|
|
346
|
+
return result["result"]
|
|
347
|
+
|
|
348
|
+
async def close(self):
|
|
349
|
+
"""Close the browser session."""
|
|
350
|
+
if self._closed:
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
result = await self.client._call_tool(
|
|
355
|
+
"close_session", {"session_id": self.session_id}
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
if not result.get("success"):
|
|
359
|
+
print(f"Warning: Failed to close session: {result.get('error')}")
|
|
360
|
+
finally:
|
|
361
|
+
self._closed = True
|
|
362
|
+
|
|
363
|
+
async def __aenter__(self):
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
367
|
+
await self.close()
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class MCPClient:
|
|
371
|
+
"""Simple client for BrowserFabric services."""
|
|
372
|
+
|
|
373
|
+
def __init__(
|
|
374
|
+
self,
|
|
375
|
+
service_name: str,
|
|
376
|
+
api_key: Optional[str] = None,
|
|
377
|
+
base_url: str = "http://localhost:9000",
|
|
378
|
+
max_retries: int = 3,
|
|
379
|
+
timeout: float = 60.0,
|
|
380
|
+
):
|
|
381
|
+
self.service_name = service_name
|
|
382
|
+
self.base_url = base_url.rstrip("/")
|
|
383
|
+
self.max_retries = max_retries
|
|
384
|
+
self.timeout = timeout
|
|
385
|
+
|
|
386
|
+
# API key support
|
|
387
|
+
self.headers = {}
|
|
388
|
+
key = api_key or os.getenv("BROWSERFABRIC_API_KEY")
|
|
389
|
+
if key:
|
|
390
|
+
self.headers["Authorization"] = f"Bearer {key}"
|
|
391
|
+
|
|
392
|
+
async def _call_tool(
|
|
393
|
+
self, tool_name: str, arguments: Dict[str, Any]
|
|
394
|
+
) -> Dict[str, Any]:
|
|
395
|
+
"""Call a tool on the service with automatic retry on transient errors."""
|
|
396
|
+
session_id = (
|
|
397
|
+
arguments.pop("session_id", None) if "session_id" in arguments else None
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
request_data = {"tool_name": tool_name, "arguments": arguments}
|
|
401
|
+
if session_id:
|
|
402
|
+
request_data["session_id"] = session_id
|
|
403
|
+
|
|
404
|
+
last_error = None
|
|
405
|
+
for attempt in range(self.max_retries):
|
|
406
|
+
try:
|
|
407
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
408
|
+
response = await client.post(
|
|
409
|
+
f"{self.base_url}/api/v1/services/{self.service_name}/call",
|
|
410
|
+
headers=self.headers,
|
|
411
|
+
json=request_data,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Don't retry client errors (4xx) except 429 (rate limit)
|
|
415
|
+
if response.status_code == 429:
|
|
416
|
+
retry_after = int(response.headers.get("Retry-After", 5))
|
|
417
|
+
await asyncio.sleep(retry_after)
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
if response.status_code != 200:
|
|
421
|
+
raise MCPError(f"HTTP {response.status_code}: {response.text}")
|
|
422
|
+
|
|
423
|
+
return response.json()
|
|
424
|
+
|
|
425
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
|
|
426
|
+
last_error = e
|
|
427
|
+
if attempt < self.max_retries - 1:
|
|
428
|
+
wait = min(2 ** attempt, 10) # 1s, 2s, 4s... max 10s
|
|
429
|
+
await asyncio.sleep(wait)
|
|
430
|
+
continue
|
|
431
|
+
raise MCPError(f"Connection failed after {self.max_retries} attempts: {e}")
|
|
432
|
+
|
|
433
|
+
raise MCPError(f"Failed after {self.max_retries} attempts: {last_error}")
|
|
434
|
+
|
|
435
|
+
async def create_session(
|
|
436
|
+
self,
|
|
437
|
+
headless: bool = True,
|
|
438
|
+
timeout: int = 30,
|
|
439
|
+
context_id: Optional[str] = None,
|
|
440
|
+
persist: bool = False,
|
|
441
|
+
) -> BrowserSession:
|
|
442
|
+
"""Create a new browser session.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
headless: Run browser in headless mode
|
|
446
|
+
timeout: Default timeout in seconds
|
|
447
|
+
context_id: Optional context ID to restore cookies/localStorage from
|
|
448
|
+
persist: If True, auto-save cookies/localStorage when session closes
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
BrowserSession with ws_url for CDP connections
|
|
452
|
+
"""
|
|
453
|
+
args = {"headless": headless, "timeout": timeout, "persist": persist}
|
|
454
|
+
if context_id:
|
|
455
|
+
args["context_id"] = context_id
|
|
456
|
+
|
|
457
|
+
result = await self._call_tool("create_session", args)
|
|
458
|
+
|
|
459
|
+
if not result.get("success"):
|
|
460
|
+
raise MCPError(f"Failed to create session: {result.get('error')}")
|
|
461
|
+
|
|
462
|
+
data = result["result"]
|
|
463
|
+
return BrowserSession(
|
|
464
|
+
self,
|
|
465
|
+
data["session_id"],
|
|
466
|
+
ws_url=data.get("ws_url"),
|
|
467
|
+
cdp_port=data.get("cdp_port"),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
async def list_sessions(self) -> List[Dict[str, Any]]:
|
|
471
|
+
"""List all active browser sessions for the current user."""
|
|
472
|
+
result = await self._call_tool("list_sessions", {})
|
|
473
|
+
if not result.get("success"):
|
|
474
|
+
raise MCPError(f"Failed to list sessions: {result.get('error')}")
|
|
475
|
+
return result["result"].get("sessions", [])
|
|
476
|
+
|
|
477
|
+
async def list_contexts(self) -> List[Dict[str, Any]]:
|
|
478
|
+
"""List all saved persistent contexts."""
|
|
479
|
+
result = await self._call_tool("list_contexts", {})
|
|
480
|
+
if not result.get("success"):
|
|
481
|
+
raise MCPError(f"Failed to list contexts: {result.get('error')}")
|
|
482
|
+
return result["result"].get("contexts", [])
|
|
483
|
+
|
|
484
|
+
async def delete_context(self, context_id: str) -> Dict[str, Any]:
|
|
485
|
+
"""Delete a saved persistent context."""
|
|
486
|
+
result = await self._call_tool("delete_context", {"context_id": context_id})
|
|
487
|
+
if not result.get("success"):
|
|
488
|
+
raise MCPError(f"Failed to delete context: {result.get('error')}")
|
|
489
|
+
return result["result"]
|
|
490
|
+
|
|
491
|
+
async def health_check(self) -> Dict[str, Any]:
|
|
492
|
+
"""Check if the service is healthy."""
|
|
493
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
494
|
+
response = await client.get(f"{self.base_url}/health")
|
|
495
|
+
|
|
496
|
+
if response.status_code != 200:
|
|
497
|
+
raise MCPError(f"Health check failed: HTTP {response.status_code}")
|
|
498
|
+
|
|
499
|
+
return response.json()
|
|
500
|
+
|
|
501
|
+
async def is_healthy(self) -> bool:
|
|
502
|
+
"""Quick health check. Returns True if server is reachable, False otherwise."""
|
|
503
|
+
try:
|
|
504
|
+
await self.health_check()
|
|
505
|
+
return True
|
|
506
|
+
except Exception:
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
async def wait_for_ready(self, timeout: int = 30, interval: float = 1.0) -> bool:
|
|
510
|
+
"""Wait for the server to become ready. Returns True if ready within timeout."""
|
|
511
|
+
import time
|
|
512
|
+
start = time.monotonic()
|
|
513
|
+
while time.monotonic() - start < timeout:
|
|
514
|
+
if await self.is_healthy():
|
|
515
|
+
return True
|
|
516
|
+
await asyncio.sleep(interval)
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
async def list_tools(self) -> List[Dict[str, Any]]:
|
|
520
|
+
"""List available tools for this service."""
|
|
521
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
522
|
+
response = await client.get(
|
|
523
|
+
f"{self.base_url}/api/v1/services/{self.service_name}/tools",
|
|
524
|
+
headers=self.headers,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
if response.status_code != 200:
|
|
528
|
+
raise MCPError(f"Failed to list tools: HTTP {response.status_code}")
|
|
529
|
+
|
|
530
|
+
result = response.json()
|
|
531
|
+
return result.get("tools", [])
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class MCP:
|
|
535
|
+
"""Main MCP interface - super simple to use!"""
|
|
536
|
+
|
|
537
|
+
def __init__(
|
|
538
|
+
self,
|
|
539
|
+
service_name: str = "browseruse",
|
|
540
|
+
api_key: Optional[str] = None,
|
|
541
|
+
base_url: str = "http://localhost:9000",
|
|
542
|
+
):
|
|
543
|
+
self.client = MCPClient(service_name, api_key, base_url)
|
|
544
|
+
|
|
545
|
+
async def create_session(
|
|
546
|
+
self,
|
|
547
|
+
headless: bool = True,
|
|
548
|
+
timeout: int = 30,
|
|
549
|
+
context_id: Optional[str] = None,
|
|
550
|
+
persist: bool = False,
|
|
551
|
+
) -> BrowserSession:
|
|
552
|
+
"""Create a new browser session."""
|
|
553
|
+
return await self.client.create_session(headless, timeout, context_id, persist)
|
|
554
|
+
|
|
555
|
+
async def list_contexts(self) -> List[Dict[str, Any]]:
|
|
556
|
+
"""List all saved persistent contexts."""
|
|
557
|
+
return await self.client.list_contexts()
|
|
558
|
+
|
|
559
|
+
async def delete_context(self, context_id: str) -> Dict[str, Any]:
|
|
560
|
+
"""Delete a saved persistent context."""
|
|
561
|
+
return await self.client.delete_context(context_id)
|
|
562
|
+
|
|
563
|
+
async def health_check(self) -> Dict[str, Any]:
|
|
564
|
+
"""Check service health."""
|
|
565
|
+
return await self.client.health_check()
|
|
566
|
+
|
|
567
|
+
async def list_tools(self) -> List[Dict[str, Any]]:
|
|
568
|
+
"""List available tools."""
|
|
569
|
+
return await self.client.list_tools()
|
|
570
|
+
|
|
571
|
+
async def quick_screenshot(self, url: str, filename: Optional[str] = None) -> str:
|
|
572
|
+
"""Quick screenshot of any URL."""
|
|
573
|
+
async with await self.create_session() as session:
|
|
574
|
+
await session.navigate(url)
|
|
575
|
+
await asyncio.sleep(2)
|
|
576
|
+
return await session.screenshot(filename)
|
|
577
|
+
|
|
578
|
+
async def quick_navigate(self, url: str) -> BrowserSession:
|
|
579
|
+
"""Quick navigation - returns session for further use."""
|
|
580
|
+
session = await self.create_session()
|
|
581
|
+
await session.navigate(url)
|
|
582
|
+
return session
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@asynccontextmanager
|
|
586
|
+
async def browser(
|
|
587
|
+
api_key: Optional[str] = None,
|
|
588
|
+
base_url: str = "http://localhost:9000",
|
|
589
|
+
headless: bool = True,
|
|
590
|
+
context_id: Optional[str] = None,
|
|
591
|
+
persist: bool = False,
|
|
592
|
+
):
|
|
593
|
+
"""
|
|
594
|
+
Super convenient context manager for browser automation.
|
|
595
|
+
|
|
596
|
+
Usage:
|
|
597
|
+
async with browserfabric.browser() as browser:
|
|
598
|
+
await browser.navigate("https://example.com")
|
|
599
|
+
await browser.click("#button")
|
|
600
|
+
await browser.screenshot("result.png")
|
|
601
|
+
|
|
602
|
+
# With persistent context
|
|
603
|
+
async with browserfabric.browser(persist=True) as browser:
|
|
604
|
+
await browser.navigate("https://twitter.com")
|
|
605
|
+
# ... cookies auto-saved on close
|
|
606
|
+
"""
|
|
607
|
+
mcp = MCP("browseruse", api_key, base_url)
|
|
608
|
+
session = await mcp.create_session(
|
|
609
|
+
headless=headless, context_id=context_id, persist=persist
|
|
610
|
+
)
|
|
611
|
+
try:
|
|
612
|
+
yield session
|
|
613
|
+
finally:
|
|
614
|
+
await session.close()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
async def screenshot(
|
|
618
|
+
url: str, filename: Optional[str] = None, api_key: Optional[str] = None
|
|
619
|
+
) -> str:
|
|
620
|
+
"""Take a quick screenshot of any URL."""
|
|
621
|
+
mcp = MCP("browseruse", api_key)
|
|
622
|
+
return await mcp.quick_screenshot(url, filename)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
async def test_form(
|
|
626
|
+
url: str, form_data: Dict[str, str], api_key: Optional[str] = None
|
|
627
|
+
) -> str:
|
|
628
|
+
"""Quick form testing."""
|
|
629
|
+
async with browser(api_key) as session:
|
|
630
|
+
await session.navigate(url)
|
|
631
|
+
|
|
632
|
+
for selector, value in form_data.items():
|
|
633
|
+
await session.type(selector, value)
|
|
634
|
+
|
|
635
|
+
return await session.screenshot("form_test.png")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
async def ensure_server_running():
|
|
639
|
+
"""Ensure BrowserFabric server is running."""
|
|
640
|
+
try:
|
|
641
|
+
mcp = MCP("browseruse")
|
|
642
|
+
await mcp.health_check()
|
|
643
|
+
return True
|
|
644
|
+
except Exception:
|
|
645
|
+
print("BrowserFabric server not running. Start it with: browserfabric serve")
|
|
646
|
+
return False
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: browserfabric
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: BrowserFabric Python SDK - Client library for BrowserFabric browser automation API
|
|
5
|
+
Author-email: Raphael Shu <raphael@uaca.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/acenta-ai/remote_browser
|
|
8
|
+
Project-URL: Documentation, https://browserfabric.com/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/acenta-ai/remote_browser
|
|
10
|
+
Project-URL: Issues, https://github.com/acenta-ai/remote_browser/issues
|
|
11
|
+
Keywords: browser,automation,api,web,crawling,sdk,client,playwright,cdp,headless
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: httpx>=0.25.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
28
|
+
Requires-Dist: black>=23.9.0; extra == "dev"
|
|
29
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.6.0; extra == "dev"
|
|
31
|
+
|
|
32
|
+
# BrowserFabric
|
|
33
|
+
|
|
34
|
+
Python SDK for **BrowserFabric** — cloud browser automation API with persistent sessions and CDP access.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install browserfabric
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import browserfabric
|
|
46
|
+
import asyncio
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
async with browserfabric.browser() as session:
|
|
50
|
+
await session.navigate("https://example.com")
|
|
51
|
+
await session.click("a")
|
|
52
|
+
await session.screenshot("result.png")
|
|
53
|
+
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Authentication
|
|
58
|
+
|
|
59
|
+
Set your API key as an environment variable or pass it directly:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
# Via environment variable
|
|
63
|
+
export BROWSERFABRIC_API_KEY=bf_your_key_here
|
|
64
|
+
|
|
65
|
+
# Or pass directly
|
|
66
|
+
async with browserfabric.browser(api_key="bf_...") as session:
|
|
67
|
+
...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Persistent Sessions
|
|
71
|
+
|
|
72
|
+
Save and restore browser state (cookies, localStorage) across sessions:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# Save state on close
|
|
76
|
+
async with browserfabric.browser(persist=True) as session:
|
|
77
|
+
await session.navigate("https://app.example.com/login")
|
|
78
|
+
await session.type("#email", "user@example.com")
|
|
79
|
+
await session.type("#password", "secret")
|
|
80
|
+
await session.click("#submit")
|
|
81
|
+
ctx = await session.save_context("my-app-login")
|
|
82
|
+
|
|
83
|
+
# Restore later
|
|
84
|
+
async with browserfabric.browser(context_id=ctx["context_id"]) as session:
|
|
85
|
+
# Already logged in
|
|
86
|
+
await session.navigate("https://app.example.com/dashboard")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## CDP WebSocket
|
|
90
|
+
|
|
91
|
+
Connect with Playwright directly for full browser control:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from playwright.async_api import async_playwright
|
|
95
|
+
|
|
96
|
+
# Get the CDP WebSocket URL from session info
|
|
97
|
+
info = await session.session_info()
|
|
98
|
+
ws_url = info["ws_url"]
|
|
99
|
+
|
|
100
|
+
# Connect with Playwright
|
|
101
|
+
pw = await async_playwright().start()
|
|
102
|
+
browser = await pw.chromium.connect_over_cdp(ws_url)
|
|
103
|
+
page = browser.contexts[0].pages[0]
|
|
104
|
+
await page.goto("https://example.com")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Available Methods
|
|
108
|
+
|
|
109
|
+
| Method | Description |
|
|
110
|
+
|--------|-------------|
|
|
111
|
+
| `navigate(url)` | Go to a URL |
|
|
112
|
+
| `click(selector)` | Click an element |
|
|
113
|
+
| `type(selector, text)` | Type into an input |
|
|
114
|
+
| `find(selector)` | Find elements |
|
|
115
|
+
| `screenshot(filename)` | Take a screenshot |
|
|
116
|
+
| `observe()` | Get interactive DOM elements |
|
|
117
|
+
| `snapshot()` | Get accessibility tree |
|
|
118
|
+
| `page_info()` | Get page URL and title |
|
|
119
|
+
| `session_info()` | Get session metadata + CDP URL |
|
|
120
|
+
| `save_context(name)` | Save browser state |
|
|
121
|
+
| `close()` | Close the session |
|
|
122
|
+
|
|
123
|
+
## Self-Hosted
|
|
124
|
+
|
|
125
|
+
Point to your own BrowserFabric instance:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
async with browserfabric.browser(base_url="http://your-server:9000") as session:
|
|
129
|
+
...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
browserfabric/__init__.py,sha256=_atiyYccmWOmm5kQDvE3qD_oMv9MQWLNkKrKrxmPP04,1000
|
|
2
|
+
browserfabric/client.py,sha256=MOEmcZTyXCDWtbw3zUjehRDUWgZJk5VvvvxTDzOs6g0,22803
|
|
3
|
+
browserfabric-1.0.0.dist-info/METADATA,sha256=2Yz87b6NVbsECvhuOVfx3y3MOIcjPYAZFwh-vbGD_NM,3936
|
|
4
|
+
browserfabric-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
browserfabric-1.0.0.dist-info/top_level.txt,sha256=_GY9asze9UyTLWQHgHr_kmbyl0p6HdK_DG9O1XantGY,14
|
|
6
|
+
browserfabric-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
browserfabric
|