browsectl 0.1.1__tar.gz
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.
- browsectl-0.1.1/LICENSE +9 -0
- browsectl-0.1.1/PKG-INFO +11 -0
- browsectl-0.1.1/browsectl/__init__.py +0 -0
- browsectl-0.1.1/browsectl/adapters/__init__.py +0 -0
- browsectl-0.1.1/browsectl/adapters/cdp.py +394 -0
- browsectl-0.1.1/browsectl/core.py +157 -0
- browsectl-0.1.1/browsectl/gateway.py +40 -0
- browsectl-0.1.1/browsectl/main.py +116 -0
- browsectl-0.1.1/browsectl/models.py +63 -0
- browsectl-0.1.1/browsectl/ports.py +26 -0
- browsectl-0.1.1/pyproject.toml +58 -0
browsectl-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Luis Alejandro Bordo Garcia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
browsectl-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: browsectl
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Browser-agnostic automation for AI agents
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Author: Luis Bordo
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
10
|
+
Requires-Dist: attrs (>=26.1.0,<27.0.0)
|
|
11
|
+
Requires-Dist: websockets (>=15.0)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Chrome DevTools Protocol adapter."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import urllib.request
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
import attrs
|
|
10
|
+
import websockets.asyncio.client
|
|
11
|
+
|
|
12
|
+
from browsectl.models import (
|
|
13
|
+
BrowserEndpoint,
|
|
14
|
+
EvalResult,
|
|
15
|
+
PageInfo,
|
|
16
|
+
Screenshot,
|
|
17
|
+
Tab,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
CDP_TIMEOUT: float = 30.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CdpError(Exception):
|
|
24
|
+
"""Raised when a CDP command returns an error."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CdpTimeoutError(CdpError):
|
|
28
|
+
"""Raised when a CDP operation exceeds its timeout."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@attrs.define(slots=True)
|
|
32
|
+
class CdpSession:
|
|
33
|
+
"""Holds a live websocket connection to a CDP target."""
|
|
34
|
+
|
|
35
|
+
ws: websockets.asyncio.client.ClientConnection
|
|
36
|
+
target_id: str
|
|
37
|
+
endpoint: BrowserEndpoint
|
|
38
|
+
_msg_id: int = 0
|
|
39
|
+
|
|
40
|
+
def next_id(self) -> int:
|
|
41
|
+
self._msg_id += 1
|
|
42
|
+
return self._msg_id
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fetch_json(url: str) -> bytes:
|
|
46
|
+
"""Blocking HTTP GET — run via asyncio.to_thread."""
|
|
47
|
+
with urllib.request.urlopen(url) as resp:
|
|
48
|
+
return resp.read() # type: ignore[no-any-return]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _send_command(
|
|
52
|
+
session: CdpSession,
|
|
53
|
+
method: str,
|
|
54
|
+
params: dict[str, object] | None = None,
|
|
55
|
+
) -> dict[str, object]:
|
|
56
|
+
"""Send a CDP command and return the result."""
|
|
57
|
+
msg_id = session.next_id()
|
|
58
|
+
payload: dict[str, object] = {"id": msg_id, "method": method}
|
|
59
|
+
if params is not None:
|
|
60
|
+
payload["params"] = params
|
|
61
|
+
await session.ws.send(json.dumps(payload))
|
|
62
|
+
|
|
63
|
+
async def _recv_response() -> dict[str, object]:
|
|
64
|
+
while True:
|
|
65
|
+
raw = await session.ws.recv()
|
|
66
|
+
response = json.loads(raw)
|
|
67
|
+
if isinstance(response, dict) and response.get("id") == msg_id:
|
|
68
|
+
if "error" in response:
|
|
69
|
+
error = response["error"]
|
|
70
|
+
if isinstance(error, dict):
|
|
71
|
+
msg = error.get("message", str(error))
|
|
72
|
+
else:
|
|
73
|
+
msg = str(error)
|
|
74
|
+
raise CdpError(msg)
|
|
75
|
+
result = response.get("result")
|
|
76
|
+
if isinstance(result, dict):
|
|
77
|
+
return result
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
return await asyncio.wait_for(_recv_response(), timeout=CDP_TIMEOUT)
|
|
82
|
+
except TimeoutError:
|
|
83
|
+
raise CdpTimeoutError(
|
|
84
|
+
f"CDP command {method} timed out after {CDP_TIMEOUT}s"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
type TSendCommand = Callable[
|
|
89
|
+
[CdpSession, str, dict[str, object] | None], Awaitable[dict[str, object]]
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
send_command: TSendCommand = _send_command
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def connect(
|
|
96
|
+
endpoint: BrowserEndpoint, target_id: str | None = None
|
|
97
|
+
) -> CdpSession:
|
|
98
|
+
"""Connect to a CDP target, preferring target_id if it still exists."""
|
|
99
|
+
list_url = f"http://{endpoint.host}:{endpoint.port}/json"
|
|
100
|
+
raw = await asyncio.to_thread(_fetch_json, list_url)
|
|
101
|
+
targets = json.loads(raw.decode())
|
|
102
|
+
|
|
103
|
+
page_targets = [t for t in targets if t.get("type") == "page"]
|
|
104
|
+
if not page_targets:
|
|
105
|
+
raise CdpError("No page targets found")
|
|
106
|
+
|
|
107
|
+
target = None
|
|
108
|
+
if target_id is not None:
|
|
109
|
+
target = next(
|
|
110
|
+
(t for t in page_targets if t.get("id") == target_id),
|
|
111
|
+
None,
|
|
112
|
+
)
|
|
113
|
+
if target is None:
|
|
114
|
+
target = page_targets[0]
|
|
115
|
+
|
|
116
|
+
ws_url: str = target["webSocketDebuggerUrl"]
|
|
117
|
+
tid: str = target["id"]
|
|
118
|
+
|
|
119
|
+
ws = await websockets.asyncio.client.connect(ws_url)
|
|
120
|
+
return CdpSession(ws=ws, target_id=tid, endpoint=endpoint)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def disconnect(session: CdpSession) -> None:
|
|
124
|
+
"""Close the websocket connection."""
|
|
125
|
+
await session.ws.close()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def list_tabs(session: CdpSession) -> tuple[Tab, ...]:
|
|
129
|
+
"""List all page targets via the Target domain."""
|
|
130
|
+
result = await send_command(session, "Target.getTargets", None)
|
|
131
|
+
raw_targets = result.get("targetInfos", [])
|
|
132
|
+
if not isinstance(raw_targets, list):
|
|
133
|
+
return ()
|
|
134
|
+
return tuple(
|
|
135
|
+
Tab(
|
|
136
|
+
id=t["targetId"],
|
|
137
|
+
title=t.get("title", ""),
|
|
138
|
+
url=t.get("url", ""),
|
|
139
|
+
)
|
|
140
|
+
for t in raw_targets
|
|
141
|
+
if isinstance(t, dict) and t.get("type") == "page"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def page_info(session: CdpSession) -> PageInfo:
|
|
146
|
+
"""Get the current page's URL and title."""
|
|
147
|
+
result = await send_command(
|
|
148
|
+
session,
|
|
149
|
+
"Runtime.evaluate",
|
|
150
|
+
{"expression": "JSON.stringify({url: location.href, title: document.title})"},
|
|
151
|
+
)
|
|
152
|
+
raw_value = result.get("result")
|
|
153
|
+
if not isinstance(raw_value, dict):
|
|
154
|
+
raise CdpError("page_info: unexpected response structure")
|
|
155
|
+
value_str = raw_value.get("value")
|
|
156
|
+
if not isinstance(value_str, str):
|
|
157
|
+
raise CdpError("page_info: missing result value")
|
|
158
|
+
parsed = json.loads(value_str)
|
|
159
|
+
return PageInfo(
|
|
160
|
+
url=parsed.get("url", ""),
|
|
161
|
+
title=parsed.get("title", ""),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def navigate(session: CdpSession, url: str) -> PageInfo:
|
|
166
|
+
"""Navigate to a URL and wait for the page to load."""
|
|
167
|
+
await send_command(session, "Page.enable", None)
|
|
168
|
+
await send_command(session, "Page.navigate", {"url": url})
|
|
169
|
+
|
|
170
|
+
async def _wait_for_load() -> None:
|
|
171
|
+
while True:
|
|
172
|
+
raw = await session.ws.recv()
|
|
173
|
+
msg = json.loads(raw)
|
|
174
|
+
if isinstance(msg, dict) and msg.get("method") == "Page.loadEventFired":
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
await asyncio.wait_for(_wait_for_load(), timeout=CDP_TIMEOUT)
|
|
179
|
+
except TimeoutError:
|
|
180
|
+
raise CdpTimeoutError(f"Page load timed out after {CDP_TIMEOUT}s")
|
|
181
|
+
|
|
182
|
+
return await page_info(session)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def screenshot(session: CdpSession) -> Screenshot:
|
|
186
|
+
"""Capture a full-page screenshot as PNG."""
|
|
187
|
+
result = await send_command(
|
|
188
|
+
session,
|
|
189
|
+
"Page.captureScreenshot",
|
|
190
|
+
{"format": "png"},
|
|
191
|
+
)
|
|
192
|
+
data_str = result.get("data")
|
|
193
|
+
if not isinstance(data_str, str) or not data_str:
|
|
194
|
+
raise CdpError("screenshot: no image data in response")
|
|
195
|
+
return Screenshot(data=base64.b64decode(data_str), format="png")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def click(session: CdpSession, selector: str) -> None:
|
|
199
|
+
"""Click an element identified by CSS selector."""
|
|
200
|
+
js = f"""
|
|
201
|
+
(() => {{
|
|
202
|
+
const el = document.querySelector({json.dumps(selector)});
|
|
203
|
+
if (!el) throw new Error("Element not found: " + {json.dumps(selector)});
|
|
204
|
+
el.scrollIntoView({{block: 'center'}});
|
|
205
|
+
const rect = el.getBoundingClientRect();
|
|
206
|
+
return JSON.stringify({{
|
|
207
|
+
x: rect.x + rect.width / 2,
|
|
208
|
+
y: rect.y + rect.height / 2
|
|
209
|
+
}});
|
|
210
|
+
}})()
|
|
211
|
+
"""
|
|
212
|
+
result = await send_command(
|
|
213
|
+
session,
|
|
214
|
+
"Runtime.evaluate",
|
|
215
|
+
{"expression": js, "returnByValue": True},
|
|
216
|
+
)
|
|
217
|
+
if "exceptionDetails" in result:
|
|
218
|
+
desc = result.get("exceptionDetails")
|
|
219
|
+
if isinstance(desc, dict):
|
|
220
|
+
exc = desc.get("exception")
|
|
221
|
+
if isinstance(exc, dict):
|
|
222
|
+
raise CdpError(str(exc.get("description", "click failed")))
|
|
223
|
+
raise CdpError("click failed")
|
|
224
|
+
|
|
225
|
+
raw_result = result.get("result")
|
|
226
|
+
if not isinstance(raw_result, dict):
|
|
227
|
+
raise CdpError("click: unexpected response structure")
|
|
228
|
+
value_str = raw_result.get("value")
|
|
229
|
+
if not isinstance(value_str, str):
|
|
230
|
+
raise CdpError("click: missing coordinate data")
|
|
231
|
+
coords = json.loads(value_str)
|
|
232
|
+
x = float(coords.get("x", 0))
|
|
233
|
+
y = float(coords.get("y", 0))
|
|
234
|
+
|
|
235
|
+
for event_type in ("mousePressed", "mouseReleased"):
|
|
236
|
+
await send_command(
|
|
237
|
+
session,
|
|
238
|
+
"Input.dispatchMouseEvent",
|
|
239
|
+
{
|
|
240
|
+
"type": event_type,
|
|
241
|
+
"x": x,
|
|
242
|
+
"y": y,
|
|
243
|
+
"button": "left",
|
|
244
|
+
"clickCount": 1,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def type_text(session: CdpSession, selector: str, text: str) -> None:
|
|
250
|
+
"""Focus an element and type text into it."""
|
|
251
|
+
focus_js = f"""
|
|
252
|
+
(() => {{
|
|
253
|
+
const el = document.querySelector({json.dumps(selector)});
|
|
254
|
+
if (!el) throw new Error("Element not found: " + {json.dumps(selector)});
|
|
255
|
+
el.focus();
|
|
256
|
+
}})()
|
|
257
|
+
"""
|
|
258
|
+
result = await send_command(
|
|
259
|
+
session,
|
|
260
|
+
"Runtime.evaluate",
|
|
261
|
+
{"expression": focus_js},
|
|
262
|
+
)
|
|
263
|
+
if "exceptionDetails" in result:
|
|
264
|
+
raise CdpError(f"Focus failed for selector: {selector}")
|
|
265
|
+
|
|
266
|
+
for char in text:
|
|
267
|
+
await send_command(
|
|
268
|
+
session,
|
|
269
|
+
"Input.dispatchKeyEvent",
|
|
270
|
+
{"type": "keyDown", "text": char},
|
|
271
|
+
)
|
|
272
|
+
await send_command(
|
|
273
|
+
session,
|
|
274
|
+
"Input.dispatchKeyEvent",
|
|
275
|
+
{"type": "keyUp"},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def extract_html(session: CdpSession, selector: str) -> str:
|
|
280
|
+
"""Extract the innerHTML of an element by CSS selector."""
|
|
281
|
+
js = f"""
|
|
282
|
+
(() => {{
|
|
283
|
+
const el = document.querySelector({json.dumps(selector)});
|
|
284
|
+
if (!el) throw new Error("Element not found: " + {json.dumps(selector)});
|
|
285
|
+
return el.innerHTML;
|
|
286
|
+
}})()
|
|
287
|
+
"""
|
|
288
|
+
result = await send_command(
|
|
289
|
+
session,
|
|
290
|
+
"Runtime.evaluate",
|
|
291
|
+
{"expression": js, "returnByValue": True},
|
|
292
|
+
)
|
|
293
|
+
if "exceptionDetails" in result:
|
|
294
|
+
raise CdpError(f"Element not found: {selector}")
|
|
295
|
+
raw_result = result.get("result")
|
|
296
|
+
if not isinstance(raw_result, dict):
|
|
297
|
+
raise CdpError("extract_html: unexpected response structure")
|
|
298
|
+
value = raw_result.get("value")
|
|
299
|
+
if not isinstance(value, str):
|
|
300
|
+
raise CdpError("extract_html: no string value in result")
|
|
301
|
+
return value
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def eval_js(session: CdpSession, expression: str) -> EvalResult:
|
|
305
|
+
"""Evaluate a JS expression and return the result."""
|
|
306
|
+
result = await send_command(
|
|
307
|
+
session,
|
|
308
|
+
"Runtime.evaluate",
|
|
309
|
+
{"expression": expression, "returnByValue": True},
|
|
310
|
+
)
|
|
311
|
+
if "exceptionDetails" in result:
|
|
312
|
+
exc_details = result["exceptionDetails"]
|
|
313
|
+
if isinstance(exc_details, dict):
|
|
314
|
+
exc = exc_details.get("exception", {})
|
|
315
|
+
if isinstance(exc, dict):
|
|
316
|
+
raise CdpError(str(exc.get("description", "eval failed")))
|
|
317
|
+
raise CdpError("eval failed")
|
|
318
|
+
raw_result = result.get("result")
|
|
319
|
+
if not isinstance(raw_result, dict):
|
|
320
|
+
raise CdpError("eval_js: unexpected response structure")
|
|
321
|
+
return EvalResult(value=str(raw_result.get("value", "")))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def new_tab(session: CdpSession, url: str) -> Tab:
|
|
325
|
+
"""Create a new tab and return its info."""
|
|
326
|
+
result = await send_command(
|
|
327
|
+
session,
|
|
328
|
+
"Target.createTarget",
|
|
329
|
+
{"url": url},
|
|
330
|
+
)
|
|
331
|
+
target_id = result.get("targetId", "")
|
|
332
|
+
if not isinstance(target_id, str) or not target_id:
|
|
333
|
+
raise CdpError("Failed to create new tab")
|
|
334
|
+
return Tab(id=target_id, title="", url=url)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def switch_tab(session: CdpSession, target_id: str) -> None:
|
|
338
|
+
"""Switch to a different tab by reconnecting to its websocket."""
|
|
339
|
+
ep = session.endpoint
|
|
340
|
+
list_url = f"http://{ep.host}:{ep.port}/json"
|
|
341
|
+
|
|
342
|
+
target = None
|
|
343
|
+
for _ in range(10):
|
|
344
|
+
raw = await asyncio.to_thread(_fetch_json, list_url)
|
|
345
|
+
targets = json.loads(raw.decode())
|
|
346
|
+
target = next(
|
|
347
|
+
(t for t in targets if t.get("id") == target_id),
|
|
348
|
+
None,
|
|
349
|
+
)
|
|
350
|
+
if target is not None:
|
|
351
|
+
break
|
|
352
|
+
await asyncio.sleep(0.2)
|
|
353
|
+
|
|
354
|
+
if target is None:
|
|
355
|
+
raise CdpError(f"Tab not found: {target_id}")
|
|
356
|
+
|
|
357
|
+
ws_url: str = target["webSocketDebuggerUrl"]
|
|
358
|
+
new_ws = await websockets.asyncio.client.connect(ws_url)
|
|
359
|
+
await session.ws.close()
|
|
360
|
+
session.ws = new_ws
|
|
361
|
+
session.target_id = target_id
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def scroll(session: CdpSession, pixels: int) -> None:
|
|
365
|
+
"""Scroll the page by N pixels (positive=down, negative=up)."""
|
|
366
|
+
await send_command(
|
|
367
|
+
session,
|
|
368
|
+
"Runtime.evaluate",
|
|
369
|
+
{"expression": f"window.scrollBy(0, {pixels})"},
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def wait_for(session: CdpSession, selector: str, timeout: float = 30.0) -> None:
|
|
374
|
+
"""Poll for a CSS selector until it exists or timeout."""
|
|
375
|
+
js = f"document.querySelector({json.dumps(selector)}) !== null"
|
|
376
|
+
|
|
377
|
+
async def _poll() -> None:
|
|
378
|
+
while True:
|
|
379
|
+
result = await send_command(
|
|
380
|
+
session,
|
|
381
|
+
"Runtime.evaluate",
|
|
382
|
+
{"expression": js, "returnByValue": True},
|
|
383
|
+
)
|
|
384
|
+
raw = result.get("result")
|
|
385
|
+
if isinstance(raw, dict) and raw.get("value") is True:
|
|
386
|
+
return
|
|
387
|
+
await asyncio.sleep(0.1)
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
await asyncio.wait_for(_poll(), timeout=timeout)
|
|
391
|
+
except TimeoutError:
|
|
392
|
+
raise CdpTimeoutError(
|
|
393
|
+
f"Selector {selector!r} not found after {timeout}s"
|
|
394
|
+
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Command orchestration -- browser-agnostic business logic."""
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from browsectl.gateway import BrowserGateway
|
|
9
|
+
from browsectl.models import BrowserEndpoint, Command
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SESSIONS_DIR = Path.home() / ".browsectl" / "sessions"
|
|
14
|
+
SCREENSHOT_PATH = Path("screenshot.png")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _session_file(name: str) -> Path:
|
|
18
|
+
return SESSIONS_DIR / f"{name}.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_session(name: str = "default") -> tuple[BrowserEndpoint, str | None]:
|
|
22
|
+
"""Load the saved browser endpoint and target from the session file."""
|
|
23
|
+
path = _session_file(name)
|
|
24
|
+
if not path.exists():
|
|
25
|
+
raise SystemExit(
|
|
26
|
+
"No active session. Run: browsectl connect <host> <port>"
|
|
27
|
+
)
|
|
28
|
+
data = json.loads(path.read_text())
|
|
29
|
+
target_id = data.get("target_id")
|
|
30
|
+
return (
|
|
31
|
+
BrowserEndpoint(host=data["host"], port=data["port"]),
|
|
32
|
+
target_id if isinstance(target_id, str) else None,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_session(
|
|
37
|
+
endpoint: BrowserEndpoint,
|
|
38
|
+
target_id: str | None = None,
|
|
39
|
+
name: str = "default",
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Save the browser endpoint and optional target to the session file."""
|
|
42
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
data: dict[str, object] = {"host": endpoint.host, "port": endpoint.port}
|
|
44
|
+
if target_id is not None:
|
|
45
|
+
data["target_id"] = target_id
|
|
46
|
+
path = _session_file(name)
|
|
47
|
+
with path.open("w") as f:
|
|
48
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
49
|
+
f.write(json.dumps(data))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def dispatch[S](
|
|
53
|
+
gateway: BrowserGateway[S],
|
|
54
|
+
command: Command,
|
|
55
|
+
args: tuple[str, ...],
|
|
56
|
+
session_name: str = "default",
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Dispatch a CLI command through the gateway. Returns output text."""
|
|
59
|
+
if command == Command.CONNECT:
|
|
60
|
+
host = args[0] if args else "localhost"
|
|
61
|
+
port = int(args[1]) if len(args) > 1 else 9222
|
|
62
|
+
endpoint = BrowserEndpoint(host=host, port=port)
|
|
63
|
+
session = await gateway.connect(endpoint, None)
|
|
64
|
+
await gateway.disconnect(session)
|
|
65
|
+
save_session(endpoint, name=session_name)
|
|
66
|
+
return f"Connected to {host}:{port}"
|
|
67
|
+
|
|
68
|
+
endpoint, target_id = load_session(session_name)
|
|
69
|
+
session = await gateway.connect(endpoint, target_id)
|
|
70
|
+
try:
|
|
71
|
+
result = await _run_command(gateway, session, command, args)
|
|
72
|
+
if command == Command.SWITCHTAB and args:
|
|
73
|
+
save_session(endpoint, target_id=args[0], name=session_name)
|
|
74
|
+
return result
|
|
75
|
+
finally:
|
|
76
|
+
await gateway.disconnect(session)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _run_command[S](
|
|
80
|
+
gateway: BrowserGateway[S],
|
|
81
|
+
session: S,
|
|
82
|
+
command: Command,
|
|
83
|
+
args: tuple[str, ...],
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Execute a single command on an active session."""
|
|
86
|
+
match command:
|
|
87
|
+
case Command.GOTO:
|
|
88
|
+
if not args:
|
|
89
|
+
raise SystemExit("Usage: browsectl goto <url>")
|
|
90
|
+
info = await gateway.navigate(session, args[0])
|
|
91
|
+
return f"{info.title}\n{info.url}"
|
|
92
|
+
|
|
93
|
+
case Command.SCREENSHOT:
|
|
94
|
+
shot = await gateway.screenshot(session)
|
|
95
|
+
out_path = Path(args[0]) if args else SCREENSHOT_PATH
|
|
96
|
+
out_path.write_bytes(shot.data)
|
|
97
|
+
return f"Saved to {out_path} ({len(shot.data)} bytes)"
|
|
98
|
+
|
|
99
|
+
case Command.CLICK:
|
|
100
|
+
if not args:
|
|
101
|
+
raise SystemExit("Usage: browsectl click <selector>")
|
|
102
|
+
await gateway.click(session, args[0])
|
|
103
|
+
return f"Clicked: {args[0]}"
|
|
104
|
+
|
|
105
|
+
case Command.TYPE:
|
|
106
|
+
if len(args) < 2:
|
|
107
|
+
raise SystemExit("Usage: browsectl type <selector> <text>")
|
|
108
|
+
await gateway.type_text(session, args[0], args[1])
|
|
109
|
+
return f"Typed into: {args[0]}"
|
|
110
|
+
|
|
111
|
+
case Command.HTML:
|
|
112
|
+
if not args:
|
|
113
|
+
raise SystemExit("Usage: browsectl html <selector>")
|
|
114
|
+
return await gateway.extract_html(session, args[0])
|
|
115
|
+
|
|
116
|
+
case Command.EVAL:
|
|
117
|
+
if not args:
|
|
118
|
+
raise SystemExit("Usage: browsectl eval <expression>")
|
|
119
|
+
result = await gateway.eval_js(session, args[0])
|
|
120
|
+
return result.value
|
|
121
|
+
|
|
122
|
+
case Command.INFO:
|
|
123
|
+
info = await gateway.page_info(session)
|
|
124
|
+
return f"{info.title}\n{info.url}"
|
|
125
|
+
|
|
126
|
+
case Command.TABS:
|
|
127
|
+
tabs = await gateway.list_tabs(session)
|
|
128
|
+
lines = tuple(f"{t.id} {t.title} {t.url}" for t in tabs)
|
|
129
|
+
return "\n".join(lines) if lines else "(no tabs)"
|
|
130
|
+
|
|
131
|
+
case Command.NEWTAB:
|
|
132
|
+
url = args[0] if args else "about:blank"
|
|
133
|
+
tab = await gateway.new_tab(session, url)
|
|
134
|
+
return f"Opened tab: {tab.id} {tab.url}"
|
|
135
|
+
|
|
136
|
+
case Command.SWITCHTAB:
|
|
137
|
+
if not args:
|
|
138
|
+
raise SystemExit("Usage: browsectl switchtab <tab-id>")
|
|
139
|
+
await gateway.switch_tab(session, args[0])
|
|
140
|
+
info = await gateway.page_info(session)
|
|
141
|
+
return f"Switched to: {info.title}\n{info.url}"
|
|
142
|
+
|
|
143
|
+
case Command.SCROLL:
|
|
144
|
+
if not args:
|
|
145
|
+
raise SystemExit("Usage: browsectl scroll <pixels>")
|
|
146
|
+
await gateway.scroll(session, int(args[0]))
|
|
147
|
+
return f"Scrolled {args[0]}px"
|
|
148
|
+
|
|
149
|
+
case Command.WAIT:
|
|
150
|
+
if not args:
|
|
151
|
+
raise SystemExit("Usage: browsectl wait <selector> [timeout]")
|
|
152
|
+
timeout = float(args[1]) if len(args) > 1 else 30.0
|
|
153
|
+
await gateway.wait_for(session, args[0], timeout)
|
|
154
|
+
return f"Found: {args[0]}"
|
|
155
|
+
|
|
156
|
+
case Command.CONNECT:
|
|
157
|
+
return ""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Browser gateway bundle -- the single injection point for core."""
|
|
2
|
+
|
|
3
|
+
import attrs
|
|
4
|
+
|
|
5
|
+
from browsectl.ports import (
|
|
6
|
+
TClick,
|
|
7
|
+
TConnect,
|
|
8
|
+
TDisconnect,
|
|
9
|
+
TEvalJs,
|
|
10
|
+
TExtractHtml,
|
|
11
|
+
TListTabs,
|
|
12
|
+
TNavigate,
|
|
13
|
+
TNewTab,
|
|
14
|
+
TPageInfo,
|
|
15
|
+
TScreenshot,
|
|
16
|
+
TScroll,
|
|
17
|
+
TSwitchTab,
|
|
18
|
+
TTypeText,
|
|
19
|
+
TWaitFor,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@attrs.define(frozen=True, slots=True)
|
|
24
|
+
class BrowserGateway[S]:
|
|
25
|
+
"""Bundles all browser port functions into a single injectable unit."""
|
|
26
|
+
|
|
27
|
+
connect: TConnect[S]
|
|
28
|
+
disconnect: TDisconnect[S]
|
|
29
|
+
navigate: TNavigate[S]
|
|
30
|
+
screenshot: TScreenshot[S]
|
|
31
|
+
click: TClick[S]
|
|
32
|
+
type_text: TTypeText[S]
|
|
33
|
+
extract_html: TExtractHtml[S]
|
|
34
|
+
eval_js: TEvalJs[S]
|
|
35
|
+
page_info: TPageInfo[S]
|
|
36
|
+
list_tabs: TListTabs[S]
|
|
37
|
+
new_tab: TNewTab[S]
|
|
38
|
+
switch_tab: TSwitchTab[S]
|
|
39
|
+
scroll: TScroll[S]
|
|
40
|
+
wait_for: TWaitFor[S]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""CLI entry point and wiring."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from browsectl.adapters import cdp
|
|
8
|
+
from browsectl.core import dispatch
|
|
9
|
+
from browsectl.gateway import BrowserGateway
|
|
10
|
+
from browsectl.models import Command
|
|
11
|
+
|
|
12
|
+
USAGE = """\
|
|
13
|
+
Usage: browsectl [-b <backend>] [-s <session>] <command> [args...]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
-b, --backend <name> Browser backend (default: cdp)
|
|
17
|
+
Available: cdp
|
|
18
|
+
-s, --session <name> Named session (default: default)
|
|
19
|
+
Allows multiple independent browser sessions
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
connect [host] [port] Connect to browser (default: localhost:9222)
|
|
23
|
+
goto <url> Navigate to URL
|
|
24
|
+
screenshot [path] Save screenshot (default: screenshot.png)
|
|
25
|
+
click <selector> Click element by CSS selector
|
|
26
|
+
type <selector> <text> Type text into element
|
|
27
|
+
html <selector> Extract innerHTML of element
|
|
28
|
+
eval <expression> Evaluate JavaScript expression
|
|
29
|
+
info Show current page URL and title
|
|
30
|
+
tabs List open tabs
|
|
31
|
+
newtab [url] Open a new tab (default: about:blank)
|
|
32
|
+
switchtab <tab-id> Switch to a tab by ID (from 'tabs' output)
|
|
33
|
+
scroll <pixels> Scroll page (positive=down, negative=up)
|
|
34
|
+
wait <selector> [timeout] Wait for element to appear (default: 30s)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Backend(StrEnum):
|
|
39
|
+
CDP = "cdp"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_cdp_gateway() -> BrowserGateway[cdp.CdpSession]:
|
|
43
|
+
return BrowserGateway(
|
|
44
|
+
connect=cdp.connect,
|
|
45
|
+
disconnect=cdp.disconnect,
|
|
46
|
+
navigate=cdp.navigate,
|
|
47
|
+
screenshot=cdp.screenshot,
|
|
48
|
+
click=cdp.click,
|
|
49
|
+
type_text=cdp.type_text,
|
|
50
|
+
extract_html=cdp.extract_html,
|
|
51
|
+
eval_js=cdp.eval_js,
|
|
52
|
+
page_info=cdp.page_info,
|
|
53
|
+
list_tabs=cdp.list_tabs,
|
|
54
|
+
new_tab=cdp.new_tab,
|
|
55
|
+
switch_tab=cdp.switch_tab,
|
|
56
|
+
scroll=cdp.scroll,
|
|
57
|
+
wait_for=cdp.wait_for,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_args(
|
|
62
|
+
argv: list[str],
|
|
63
|
+
) -> tuple[Backend, str, Command, tuple[str, ...]]:
|
|
64
|
+
backend = Backend.CDP
|
|
65
|
+
session_name = "default"
|
|
66
|
+
args = list(argv)
|
|
67
|
+
|
|
68
|
+
while args and args[0].startswith("-"):
|
|
69
|
+
flag = args.pop(0)
|
|
70
|
+
if flag in ("-b", "--backend"):
|
|
71
|
+
if not args:
|
|
72
|
+
raise SystemExit("--backend requires a value")
|
|
73
|
+
try:
|
|
74
|
+
backend = Backend(args.pop(0))
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
raise SystemExit(
|
|
77
|
+
f"Unknown backend: {e}. "
|
|
78
|
+
f"Available: {', '.join(Backend)}"
|
|
79
|
+
)
|
|
80
|
+
elif flag in ("-s", "--session"):
|
|
81
|
+
if not args:
|
|
82
|
+
raise SystemExit("--session requires a value")
|
|
83
|
+
session_name = args.pop(0)
|
|
84
|
+
elif flag in ("-h", "--help"):
|
|
85
|
+
print(USAGE)
|
|
86
|
+
raise SystemExit(0)
|
|
87
|
+
else:
|
|
88
|
+
print(f"Unknown flag: {flag}", file=sys.stderr)
|
|
89
|
+
print(USAGE, file=sys.stderr)
|
|
90
|
+
raise SystemExit(1)
|
|
91
|
+
|
|
92
|
+
if not args:
|
|
93
|
+
print(USAGE)
|
|
94
|
+
raise SystemExit(0)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
command = Command(args[0])
|
|
98
|
+
except ValueError:
|
|
99
|
+
print(f"Unknown command: {args[0]}", file=sys.stderr)
|
|
100
|
+
print(USAGE, file=sys.stderr)
|
|
101
|
+
raise SystemExit(1)
|
|
102
|
+
|
|
103
|
+
return backend, session_name, command, tuple(args[1:])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main() -> None:
|
|
107
|
+
"""Entry point for the browsectl CLI."""
|
|
108
|
+
backend, session_name, command, args = _parse_args(sys.argv[1:])
|
|
109
|
+
match backend:
|
|
110
|
+
case Backend.CDP:
|
|
111
|
+
gateway = _build_cdp_gateway()
|
|
112
|
+
case _:
|
|
113
|
+
raise SystemExit(f"Unsupported backend: {backend}")
|
|
114
|
+
output = asyncio.run(dispatch(gateway, command, args, session_name))
|
|
115
|
+
if output:
|
|
116
|
+
print(output)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Domain types for browsectl."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
import attrs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@attrs.define(frozen=True, slots=True)
|
|
9
|
+
class BrowserEndpoint:
|
|
10
|
+
"""Where to connect to a browser's automation interface."""
|
|
11
|
+
|
|
12
|
+
host: str
|
|
13
|
+
port: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@attrs.define(frozen=True, slots=True)
|
|
17
|
+
class Tab:
|
|
18
|
+
"""A single browser tab."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
title: str
|
|
22
|
+
url: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@attrs.define(frozen=True, slots=True)
|
|
26
|
+
class PageInfo:
|
|
27
|
+
"""Current state of a page."""
|
|
28
|
+
|
|
29
|
+
url: str
|
|
30
|
+
title: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@attrs.define(frozen=True, slots=True)
|
|
34
|
+
class Screenshot:
|
|
35
|
+
"""Captured page image."""
|
|
36
|
+
|
|
37
|
+
data: bytes
|
|
38
|
+
format: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@attrs.define(frozen=True, slots=True)
|
|
42
|
+
class EvalResult:
|
|
43
|
+
"""Result of evaluating a JS expression."""
|
|
44
|
+
|
|
45
|
+
value: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Command(StrEnum):
|
|
49
|
+
"""CLI commands."""
|
|
50
|
+
|
|
51
|
+
CONNECT = "connect"
|
|
52
|
+
GOTO = "goto"
|
|
53
|
+
SCREENSHOT = "screenshot"
|
|
54
|
+
CLICK = "click"
|
|
55
|
+
TYPE = "type"
|
|
56
|
+
HTML = "html"
|
|
57
|
+
EVAL = "eval"
|
|
58
|
+
INFO = "info"
|
|
59
|
+
TABS = "tabs"
|
|
60
|
+
NEWTAB = "newtab"
|
|
61
|
+
SWITCHTAB = "switchtab"
|
|
62
|
+
SCROLL = "scroll"
|
|
63
|
+
WAIT = "wait"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Function signature contracts for browser operations."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
|
|
5
|
+
from browsectl.models import (
|
|
6
|
+
BrowserEndpoint,
|
|
7
|
+
EvalResult,
|
|
8
|
+
PageInfo,
|
|
9
|
+
Screenshot,
|
|
10
|
+
Tab,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type TConnect[S] = Callable[[BrowserEndpoint, str | None], Awaitable[S]]
|
|
14
|
+
type TDisconnect[S] = Callable[[S], Awaitable[None]]
|
|
15
|
+
type TNavigate[S] = Callable[[S, str], Awaitable[PageInfo]]
|
|
16
|
+
type TScreenshot[S] = Callable[[S], Awaitable[Screenshot]]
|
|
17
|
+
type TClick[S] = Callable[[S, str], Awaitable[None]]
|
|
18
|
+
type TTypeText[S] = Callable[[S, str, str], Awaitable[None]]
|
|
19
|
+
type TExtractHtml[S] = Callable[[S, str], Awaitable[str]]
|
|
20
|
+
type TEvalJs[S] = Callable[[S, str], Awaitable[EvalResult]]
|
|
21
|
+
type TPageInfo[S] = Callable[[S], Awaitable[PageInfo]]
|
|
22
|
+
type TListTabs[S] = Callable[[S], Awaitable[tuple[Tab, ...]]]
|
|
23
|
+
type TNewTab[S] = Callable[[S, str], Awaitable[Tab]]
|
|
24
|
+
type TSwitchTab[S] = Callable[[S, str], Awaitable[None]]
|
|
25
|
+
type TScroll[S] = Callable[[S, int], Awaitable[None]]
|
|
26
|
+
type TWaitFor[S] = Callable[[S, str, float], Awaitable[None]]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "browsectl"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Browser-agnostic automation for AI agents"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Luis Bordo"},
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"websockets>=15.0",
|
|
11
|
+
"attrs (>=26.1.0,<27.0.0)",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
browsectl = "browsectl.main:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
19
|
+
build-backend = "poetry.core.masonry.api"
|
|
20
|
+
|
|
21
|
+
[tool.poetry.group.dev.dependencies]
|
|
22
|
+
mypy = "^1.16"
|
|
23
|
+
pytest = "^8.0"
|
|
24
|
+
ruff = "^0.12"
|
|
25
|
+
pytest-asyncio = "^1.4.0"
|
|
26
|
+
|
|
27
|
+
[tool.mypy]
|
|
28
|
+
python_version = "3.14"
|
|
29
|
+
strict = true
|
|
30
|
+
explicit_package_bases = true
|
|
31
|
+
warn_unreachable = true
|
|
32
|
+
disallow_any_explicit = true
|
|
33
|
+
disallow_any_unimported = true
|
|
34
|
+
disallow_any_decorated = true
|
|
35
|
+
enable_error_code = [
|
|
36
|
+
"possibly-undefined",
|
|
37
|
+
"redundant-expr",
|
|
38
|
+
"truthy-bool",
|
|
39
|
+
"truthy-iterable",
|
|
40
|
+
"exhaustive-match",
|
|
41
|
+
]
|
|
42
|
+
mypy_path = "."
|
|
43
|
+
|
|
44
|
+
[[tool.mypy.overrides]]
|
|
45
|
+
module = [
|
|
46
|
+
"websockets",
|
|
47
|
+
]
|
|
48
|
+
ignore_missing_imports = true
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 88
|
|
52
|
+
target-version = "py314"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "N", "W", "UP"]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|