publicbrowser 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.
- publicbrowser/__init__.py +24 -0
- publicbrowser/cdp.py +435 -0
- publicbrowser/chrome.py +156 -0
- publicbrowser/client.py +378 -0
- publicbrowser/escape_hatch.py +139 -0
- publicbrowser/page.py +327 -0
- publicbrowser/py.typed +0 -0
- publicbrowser-1.0.0.dist-info/METADATA +231 -0
- publicbrowser-1.0.0.dist-info/RECORD +11 -0
- publicbrowser-1.0.0.dist-info/WHEEL +4 -0
- publicbrowser-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Public Browser — Python client for Chrome browser automation.
|
|
2
|
+
|
|
3
|
+
v2: Uses the Public Browser Script API server (HTTP on port 9223).
|
|
4
|
+
All browser automation logic runs server-side for maximum quality.
|
|
5
|
+
|
|
6
|
+
CdpClient and CdpError are re-exported for backward compatibility
|
|
7
|
+
and for the cdp.py escape hatch (Story 9.9).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from publicbrowser.cdp import CdpClient, CdpError
|
|
11
|
+
from publicbrowser.chrome import Chrome
|
|
12
|
+
from publicbrowser.client import ScriptApiClient
|
|
13
|
+
from publicbrowser.escape_hatch import CdpEscapeHatch
|
|
14
|
+
from publicbrowser.page import Page
|
|
15
|
+
|
|
16
|
+
__version__ = "1.0.0"
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Chrome",
|
|
19
|
+
"Page",
|
|
20
|
+
"ScriptApiClient",
|
|
21
|
+
"CdpClient",
|
|
22
|
+
"CdpError",
|
|
23
|
+
"CdpEscapeHatch",
|
|
24
|
+
]
|
publicbrowser/cdp.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Minimal CDP (Chrome DevTools Protocol) client over WebSocket.
|
|
2
|
+
|
|
3
|
+
This module provides a low-level CDP client that communicates with Chrome
|
|
4
|
+
via the DevTools Protocol. It handles:
|
|
5
|
+
- WebSocket connection to a Chrome instance
|
|
6
|
+
- Request/response matching via message IDs
|
|
7
|
+
- CDP event dispatching
|
|
8
|
+
- Target discovery via HTTP endpoint
|
|
9
|
+
- Session-based routing for tab-specific commands
|
|
10
|
+
|
|
11
|
+
Usage (async):
|
|
12
|
+
import asyncio
|
|
13
|
+
from publicbrowser.cdp import CdpClient
|
|
14
|
+
|
|
15
|
+
async def main():
|
|
16
|
+
client = await CdpClient.connect("localhost", 9222)
|
|
17
|
+
result = await client.send("Runtime.evaluate", {"expression": "1+1"})
|
|
18
|
+
print(result) # {"result": {"type": "number", "value": 2}}
|
|
19
|
+
await client.close()
|
|
20
|
+
|
|
21
|
+
asyncio.run(main())
|
|
22
|
+
|
|
23
|
+
Usage (sync):
|
|
24
|
+
from publicbrowser.cdp import CdpClient
|
|
25
|
+
|
|
26
|
+
client = CdpClient.connect_sync("localhost", 9222)
|
|
27
|
+
result = client.send_sync("Runtime.evaluate", {"expression": "1+1"})
|
|
28
|
+
print(result) # {"result": {"type": "number", "value": 2}}
|
|
29
|
+
client.close_sync()
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
import itertools
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
import threading
|
|
39
|
+
from typing import Any, Callable
|
|
40
|
+
from urllib.request import urlopen
|
|
41
|
+
|
|
42
|
+
from websockets.asyncio.client import connect
|
|
43
|
+
from websockets.exceptions import ConnectionClosed
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Default timeout for CDP commands (seconds)
|
|
48
|
+
DEFAULT_TIMEOUT = 30.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CdpError(Exception):
|
|
52
|
+
"""Raised when a CDP command returns an error response."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self, code: int, message: str, data: Any = None, method: str | None = None
|
|
56
|
+
) -> None:
|
|
57
|
+
self.code = code
|
|
58
|
+
self.message = message
|
|
59
|
+
self.data = data
|
|
60
|
+
self.method = method
|
|
61
|
+
if method:
|
|
62
|
+
super().__init__(f"{method} failed: CDP error {code}: {message}")
|
|
63
|
+
else:
|
|
64
|
+
super().__init__(f"CDP error {code}: {message}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CdpClient:
|
|
68
|
+
"""Minimal async CDP client over WebSocket.
|
|
69
|
+
|
|
70
|
+
Handles request/response matching and event dispatching.
|
|
71
|
+
Use `CdpClient.connect()` to create a connected instance.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, ws_url: str) -> None:
|
|
75
|
+
self._ws_url = ws_url
|
|
76
|
+
self._ws: Any = None
|
|
77
|
+
self._counter = itertools.count(1)
|
|
78
|
+
self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
79
|
+
self._event_handlers: dict[str, list[Callable[[dict[str, Any]], None]]] = {}
|
|
80
|
+
self._listener_task: asyncio.Task[None] | None = None
|
|
81
|
+
self._closed = False
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
async def connect(
|
|
85
|
+
cls,
|
|
86
|
+
host: str = "localhost",
|
|
87
|
+
port: int = 9222,
|
|
88
|
+
*,
|
|
89
|
+
target_id: str | None = None,
|
|
90
|
+
ws_url: str | None = None,
|
|
91
|
+
) -> CdpClient:
|
|
92
|
+
"""Connect to a Chrome instance via CDP.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
host: Chrome host (default: localhost).
|
|
96
|
+
port: Chrome debugging port (default: 9222).
|
|
97
|
+
target_id: Connect to a specific target (tab). If None, connects
|
|
98
|
+
to the browser-level endpoint.
|
|
99
|
+
ws_url: Direct WebSocket URL. If provided, host/port/target_id
|
|
100
|
+
are ignored.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A connected CdpClient instance.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ConnectionError: If Chrome is not reachable.
|
|
107
|
+
"""
|
|
108
|
+
if ws_url is None:
|
|
109
|
+
if target_id is not None:
|
|
110
|
+
ws_url = f"ws://{host}:{port}/devtools/page/{target_id}"
|
|
111
|
+
else:
|
|
112
|
+
ws_url = cls._discover_browser_ws(host, port)
|
|
113
|
+
|
|
114
|
+
client = cls(ws_url)
|
|
115
|
+
await client._connect()
|
|
116
|
+
return client
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _discover_browser_ws(host: str, port: int) -> str:
|
|
120
|
+
"""Discover the browser WebSocket URL via the /json/version endpoint.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
host: Chrome host.
|
|
124
|
+
port: Chrome debugging port.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The browser WebSocket debugger URL.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ConnectionError: If Chrome is not reachable or the response
|
|
131
|
+
is malformed.
|
|
132
|
+
"""
|
|
133
|
+
url = f"http://{host}:{port}/json/version"
|
|
134
|
+
try:
|
|
135
|
+
with urlopen(url, timeout=5) as resp:
|
|
136
|
+
data = json.loads(resp.read())
|
|
137
|
+
ws_url = data.get("webSocketDebuggerUrl")
|
|
138
|
+
if not ws_url:
|
|
139
|
+
raise ConnectionError(
|
|
140
|
+
f"No webSocketDebuggerUrl in /json/version response from {url}"
|
|
141
|
+
)
|
|
142
|
+
return ws_url
|
|
143
|
+
except ConnectionError:
|
|
144
|
+
raise # Re-raise our own ConnectionError (missing field)
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
raise ConnectionError(
|
|
147
|
+
f"Cannot reach Chrome at {url}. Is Chrome running with "
|
|
148
|
+
f"--remote-debugging-port={port}?"
|
|
149
|
+
) from exc
|
|
150
|
+
|
|
151
|
+
async def _connect(self) -> None:
|
|
152
|
+
"""Establish WebSocket connection and start listener."""
|
|
153
|
+
try:
|
|
154
|
+
# websockets.asyncio.client.connect is both an async context manager
|
|
155
|
+
# and directly awaitable. We use __aenter__ to get the connection
|
|
156
|
+
# and store the context manager for clean shutdown.
|
|
157
|
+
self._connect_cm = connect(
|
|
158
|
+
self._ws_url,
|
|
159
|
+
ping_interval=None, # CDP does not use WebSocket pings
|
|
160
|
+
max_size=64 * 1024 * 1024, # 64 MB for large DOM snapshots
|
|
161
|
+
)
|
|
162
|
+
self._ws = await self._connect_cm.__aenter__()
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
raise ConnectionError(
|
|
165
|
+
f"WebSocket connection failed to {self._ws_url}: {exc}"
|
|
166
|
+
) from exc
|
|
167
|
+
|
|
168
|
+
self._closed = False
|
|
169
|
+
self._listener_task = asyncio.create_task(self._listen())
|
|
170
|
+
|
|
171
|
+
async def _listen(self) -> None:
|
|
172
|
+
"""Background listener that dispatches responses and events."""
|
|
173
|
+
try:
|
|
174
|
+
async for raw in self._ws:
|
|
175
|
+
try:
|
|
176
|
+
msg = json.loads(raw)
|
|
177
|
+
except json.JSONDecodeError:
|
|
178
|
+
logger.warning("Received non-JSON message: %s", raw[:200])
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Response to a pending request
|
|
182
|
+
if "id" in msg and msg["id"] in self._pending:
|
|
183
|
+
future = self._pending.pop(msg["id"])
|
|
184
|
+
if not future.done():
|
|
185
|
+
future.set_result(msg)
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# CDP event (no id, has method)
|
|
189
|
+
method = msg.get("method")
|
|
190
|
+
if method and method in self._event_handlers:
|
|
191
|
+
params = msg.get("params", {})
|
|
192
|
+
for handler in self._event_handlers[method]:
|
|
193
|
+
try:
|
|
194
|
+
handler(params)
|
|
195
|
+
except Exception:
|
|
196
|
+
logger.exception(
|
|
197
|
+
"Error in event handler for %s", method
|
|
198
|
+
)
|
|
199
|
+
except ConnectionClosed:
|
|
200
|
+
logger.debug("WebSocket connection closed")
|
|
201
|
+
except asyncio.CancelledError:
|
|
202
|
+
# Map CancelledError to ConnectionError for pending futures
|
|
203
|
+
conn_err = ConnectionError("WebSocket connection lost")
|
|
204
|
+
for future in self._pending.values():
|
|
205
|
+
if not future.done():
|
|
206
|
+
future.set_exception(conn_err)
|
|
207
|
+
self._pending.clear()
|
|
208
|
+
return
|
|
209
|
+
finally:
|
|
210
|
+
# Cancel all pending futures that weren't already resolved
|
|
211
|
+
for future in self._pending.values():
|
|
212
|
+
if not future.done():
|
|
213
|
+
future.cancel()
|
|
214
|
+
self._pending.clear()
|
|
215
|
+
|
|
216
|
+
async def send(
|
|
217
|
+
self,
|
|
218
|
+
method: str,
|
|
219
|
+
params: dict[str, Any] | None = None,
|
|
220
|
+
*,
|
|
221
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
222
|
+
session_id: str | None = None,
|
|
223
|
+
) -> dict[str, Any]:
|
|
224
|
+
"""Send a CDP command and wait for the response.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
method: CDP method name (e.g. "Runtime.evaluate").
|
|
228
|
+
params: CDP method parameters.
|
|
229
|
+
timeout: Maximum wait time in seconds.
|
|
230
|
+
session_id: Optional CDP session ID for tab-specific commands.
|
|
231
|
+
When provided, the request includes a ``sessionId`` field
|
|
232
|
+
for multiplexed session routing via Target.attachToTarget.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The ``result`` field from the CDP response. For example,
|
|
236
|
+
``Runtime.evaluate`` returns
|
|
237
|
+
``{"result": {"type": "number", "value": 2}}``.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
CdpError: If CDP returns an error response.
|
|
241
|
+
asyncio.TimeoutError: If the response does not arrive in time.
|
|
242
|
+
ConnectionError: If the WebSocket is not connected.
|
|
243
|
+
"""
|
|
244
|
+
if self._closed or self._ws is None:
|
|
245
|
+
raise ConnectionError("CdpClient is not connected")
|
|
246
|
+
|
|
247
|
+
cmd_id = next(self._counter)
|
|
248
|
+
loop = asyncio.get_running_loop()
|
|
249
|
+
future: asyncio.Future[dict[str, Any]] = loop.create_future()
|
|
250
|
+
self._pending[cmd_id] = future
|
|
251
|
+
|
|
252
|
+
message: dict[str, Any] = {"id": cmd_id, "method": method}
|
|
253
|
+
if params:
|
|
254
|
+
message["params"] = params
|
|
255
|
+
if session_id is not None:
|
|
256
|
+
message["sessionId"] = session_id
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
await self._ws.send(json.dumps(message))
|
|
260
|
+
except ConnectionClosed as exc:
|
|
261
|
+
self._pending.pop(cmd_id, None)
|
|
262
|
+
raise ConnectionError(f"WebSocket closed while sending: {exc}") from exc
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
response = await asyncio.wait_for(future, timeout=timeout)
|
|
266
|
+
except asyncio.TimeoutError:
|
|
267
|
+
self._pending.pop(cmd_id, None)
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
if "error" in response:
|
|
271
|
+
err = response["error"]
|
|
272
|
+
raise CdpError(
|
|
273
|
+
code=err.get("code", -1),
|
|
274
|
+
message=err.get("message", "Unknown CDP error"),
|
|
275
|
+
data=err.get("data"),
|
|
276
|
+
method=method,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return response.get("result", {})
|
|
280
|
+
|
|
281
|
+
def on(self, event: str, handler: Callable[[dict[str, Any]], None]) -> None:
|
|
282
|
+
"""Register an event handler for a CDP event.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
event: CDP event name (e.g. "Page.loadEventFired").
|
|
286
|
+
handler: Callback that receives the event params dict.
|
|
287
|
+
"""
|
|
288
|
+
self._event_handlers.setdefault(event, []).append(handler)
|
|
289
|
+
|
|
290
|
+
def off(self, event: str, handler: Callable[[dict[str, Any]], None]) -> None:
|
|
291
|
+
"""Remove an event handler.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
event: CDP event name.
|
|
295
|
+
handler: The handler to remove.
|
|
296
|
+
"""
|
|
297
|
+
handlers = self._event_handlers.get(event, [])
|
|
298
|
+
if handler in handlers:
|
|
299
|
+
handlers.remove(handler)
|
|
300
|
+
|
|
301
|
+
async def close(self) -> None:
|
|
302
|
+
"""Close the WebSocket connection and stop the listener."""
|
|
303
|
+
self._closed = True
|
|
304
|
+
|
|
305
|
+
# Cancel all pending futures before stopping the listener
|
|
306
|
+
for future in self._pending.values():
|
|
307
|
+
if not future.done():
|
|
308
|
+
future.cancel()
|
|
309
|
+
self._pending.clear()
|
|
310
|
+
|
|
311
|
+
if self._listener_task and not self._listener_task.done():
|
|
312
|
+
self._listener_task.cancel()
|
|
313
|
+
try:
|
|
314
|
+
await self._listener_task
|
|
315
|
+
except asyncio.CancelledError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
if self._ws:
|
|
319
|
+
await self._ws.close()
|
|
320
|
+
self._ws = None
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
# Synchronous API — wraps async methods for non-async callers
|
|
324
|
+
# ------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def connect_sync(
|
|
328
|
+
cls,
|
|
329
|
+
host: str = "localhost",
|
|
330
|
+
port: int = 9222,
|
|
331
|
+
*,
|
|
332
|
+
target_id: str | None = None,
|
|
333
|
+
ws_url: str | None = None,
|
|
334
|
+
) -> CdpClient:
|
|
335
|
+
"""Synchronous version of :meth:`connect`.
|
|
336
|
+
|
|
337
|
+
Creates a new event loop in a background thread and connects to Chrome.
|
|
338
|
+
The returned client exposes ``send_sync()`` and ``close_sync()`` for
|
|
339
|
+
purely synchronous usage.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
host: Chrome host (default: localhost).
|
|
343
|
+
port: Chrome debugging port (default: 9222).
|
|
344
|
+
target_id: Connect to a specific target (tab).
|
|
345
|
+
ws_url: Direct WebSocket URL (skips discovery).
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
A connected CdpClient instance.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ConnectionError: If Chrome is not reachable.
|
|
352
|
+
"""
|
|
353
|
+
loop = asyncio.new_event_loop()
|
|
354
|
+
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
|
355
|
+
thread.start()
|
|
356
|
+
|
|
357
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
358
|
+
cls.connect(host, port, target_id=target_id, ws_url=ws_url), loop
|
|
359
|
+
)
|
|
360
|
+
client = future.result()
|
|
361
|
+
client._sync_loop = loop
|
|
362
|
+
client._sync_thread = thread
|
|
363
|
+
return client
|
|
364
|
+
|
|
365
|
+
def send_sync(
|
|
366
|
+
self,
|
|
367
|
+
method: str,
|
|
368
|
+
params: dict[str, Any] | None = None,
|
|
369
|
+
*,
|
|
370
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
371
|
+
session_id: str | None = None,
|
|
372
|
+
) -> dict[str, Any]:
|
|
373
|
+
"""Synchronous version of :meth:`send`.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
method: CDP method name (e.g. "Runtime.evaluate").
|
|
377
|
+
params: CDP method parameters.
|
|
378
|
+
timeout: Maximum wait time in seconds.
|
|
379
|
+
session_id: Optional CDP session ID for tab-specific commands.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The ``result`` field from the CDP response.
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
CdpError: If CDP returns an error response.
|
|
386
|
+
TimeoutError: If the response does not arrive in time.
|
|
387
|
+
ConnectionError: If the WebSocket is not connected.
|
|
388
|
+
RuntimeError: If no sync event loop is available (use connect_sync first).
|
|
389
|
+
"""
|
|
390
|
+
loop = getattr(self, "_sync_loop", None)
|
|
391
|
+
if loop is None:
|
|
392
|
+
raise RuntimeError(
|
|
393
|
+
"No sync event loop. Use CdpClient.connect_sync() or call send() with await."
|
|
394
|
+
)
|
|
395
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
396
|
+
self.send(method, params, timeout=timeout, session_id=session_id), loop
|
|
397
|
+
)
|
|
398
|
+
return future.result(timeout=timeout + 1)
|
|
399
|
+
|
|
400
|
+
def close_sync(self) -> None:
|
|
401
|
+
"""Synchronous version of :meth:`close`.
|
|
402
|
+
|
|
403
|
+
Closes the WebSocket connection and stops the background event loop.
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
RuntimeError: If no sync event loop is available.
|
|
407
|
+
"""
|
|
408
|
+
loop = getattr(self, "_sync_loop", None)
|
|
409
|
+
if loop is None:
|
|
410
|
+
raise RuntimeError(
|
|
411
|
+
"No sync event loop. Use CdpClient.connect_sync() or call close() with await."
|
|
412
|
+
)
|
|
413
|
+
future = asyncio.run_coroutine_threadsafe(self.close(), loop)
|
|
414
|
+
future.result(timeout=5)
|
|
415
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
416
|
+
thread = getattr(self, "_sync_thread", None)
|
|
417
|
+
if thread is not None:
|
|
418
|
+
thread.join(timeout=5)
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def closed(self) -> bool:
|
|
422
|
+
"""Whether the client is closed."""
|
|
423
|
+
return self._closed
|
|
424
|
+
|
|
425
|
+
async def __aenter__(self) -> CdpClient:
|
|
426
|
+
return self
|
|
427
|
+
|
|
428
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
429
|
+
await self.close()
|
|
430
|
+
|
|
431
|
+
def __enter__(self) -> CdpClient:
|
|
432
|
+
return self
|
|
433
|
+
|
|
434
|
+
def __exit__(self, *exc: Any) -> None:
|
|
435
|
+
self.close_sync()
|
publicbrowser/chrome.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Chrome — entry point for browser automation (v2 — Shared Core Client).
|
|
2
|
+
|
|
3
|
+
Provides the top-level ``Chrome`` class that connects to the Public Browser
|
|
4
|
+
Script API server and manages tab lifecycle via ``new_page()``.
|
|
5
|
+
|
|
6
|
+
In v2, all browser automation logic runs server-side. The Python library is a
|
|
7
|
+
thin HTTP client that sends tool calls to the server on port 9223.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from publicbrowser import Chrome
|
|
12
|
+
|
|
13
|
+
chrome = Chrome.connect()
|
|
14
|
+
with chrome.new_page() as page:
|
|
15
|
+
page.navigate("https://example.com")
|
|
16
|
+
title = page.evaluate("document.title")
|
|
17
|
+
print(title)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from typing import Any, Generator
|
|
24
|
+
|
|
25
|
+
from publicbrowser.client import ScriptApiClient
|
|
26
|
+
from publicbrowser.page import Page
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Chrome:
|
|
30
|
+
"""Connection to the Public Browser Script API server.
|
|
31
|
+
|
|
32
|
+
Use ``Chrome.connect()`` to create an instance. Do not instantiate directly.
|
|
33
|
+
|
|
34
|
+
The Chrome object holds an HTTP client for the Script API and can create
|
|
35
|
+
new tabs via ``new_page()``. If no server is running, it auto-starts one.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, client: ScriptApiClient) -> None:
|
|
39
|
+
self._client = client
|
|
40
|
+
self._closed = False
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def connect(
|
|
44
|
+
cls,
|
|
45
|
+
host: str = "localhost",
|
|
46
|
+
port: int = 9223,
|
|
47
|
+
*,
|
|
48
|
+
server_path: str | None = None,
|
|
49
|
+
auto_start: bool = True,
|
|
50
|
+
profile: str | None = None,
|
|
51
|
+
) -> Chrome:
|
|
52
|
+
"""Connect to the Public Browser Script API server.
|
|
53
|
+
|
|
54
|
+
If the server is not running and ``auto_start`` is True, starts it
|
|
55
|
+
automatically as a subprocess.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
host: Server host (default: localhost).
|
|
59
|
+
port: Server port (default: 9223).
|
|
60
|
+
server_path: Explicit path to the server binary. If not given,
|
|
61
|
+
the server is found via PATH or npx fallback.
|
|
62
|
+
auto_start: Whether to auto-start the server if not running
|
|
63
|
+
(default: True).
|
|
64
|
+
profile: Chrome profile name (e.g. "Julian", "Business").
|
|
65
|
+
When auto-starting, passes --profile to the server.
|
|
66
|
+
When connecting to a running server, calls /config/profile.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A connected Chrome instance.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ConnectionError: If the server is not reachable and auto_start
|
|
73
|
+
is False.
|
|
74
|
+
FileNotFoundError: If auto_start is True but no server binary
|
|
75
|
+
can be found.
|
|
76
|
+
TimeoutError: If the auto-started server does not become ready.
|
|
77
|
+
"""
|
|
78
|
+
client = ScriptApiClient(host, port)
|
|
79
|
+
|
|
80
|
+
if not client._is_server_running():
|
|
81
|
+
if auto_start:
|
|
82
|
+
if profile is None:
|
|
83
|
+
client.start_server(server_path=server_path)
|
|
84
|
+
else:
|
|
85
|
+
client.start_server(server_path=server_path, profile=profile)
|
|
86
|
+
else:
|
|
87
|
+
raise ConnectionError(
|
|
88
|
+
f"Public Browser server not reachable on {host}:{port}. "
|
|
89
|
+
f"Start it with 'public-browser --script' or set auto_start=True."
|
|
90
|
+
)
|
|
91
|
+
elif profile:
|
|
92
|
+
client.configure_profile(profile)
|
|
93
|
+
|
|
94
|
+
return cls(client)
|
|
95
|
+
|
|
96
|
+
@contextmanager
|
|
97
|
+
def new_page(self) -> Generator[Page, None, None]:
|
|
98
|
+
"""Create a new tab and return a Page as a context manager.
|
|
99
|
+
|
|
100
|
+
The session (tab) is automatically closed when the context manager
|
|
101
|
+
exits, even if an exception occurs.
|
|
102
|
+
|
|
103
|
+
Yields:
|
|
104
|
+
A Page instance for the new tab.
|
|
105
|
+
|
|
106
|
+
Example::
|
|
107
|
+
|
|
108
|
+
with chrome.new_page() as page:
|
|
109
|
+
page.navigate("https://example.com")
|
|
110
|
+
page.click("#button")
|
|
111
|
+
"""
|
|
112
|
+
session_token, target_id, cdp_ws_url, cdp_session_id = (
|
|
113
|
+
self._client.create_session()
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
page = Page(
|
|
117
|
+
client=self._client,
|
|
118
|
+
session_token=session_token,
|
|
119
|
+
target_id=target_id,
|
|
120
|
+
cdp_ws_url=cdp_ws_url,
|
|
121
|
+
cdp_session_id=cdp_session_id,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
yield page
|
|
126
|
+
finally:
|
|
127
|
+
try:
|
|
128
|
+
page.close() # Close Escape Hatch WebSocket if open
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
try:
|
|
132
|
+
self._client.close_session(session_token)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass # Cleanup errors must not propagate
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def closed(self) -> bool:
|
|
138
|
+
"""Whether the Chrome connection is closed."""
|
|
139
|
+
return self._closed
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
"""Close the connection and terminate any auto-started server.
|
|
143
|
+
|
|
144
|
+
Does NOT close Chrome itself — only the HTTP client and any
|
|
145
|
+
auto-started server subprocess.
|
|
146
|
+
"""
|
|
147
|
+
if self._closed:
|
|
148
|
+
return
|
|
149
|
+
self._closed = True
|
|
150
|
+
self._client.close()
|
|
151
|
+
|
|
152
|
+
def __enter__(self) -> Chrome:
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def __exit__(self, *exc: Any) -> None:
|
|
156
|
+
self.close()
|