funbrowser 0.1.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.
Files changed (57) hide show
  1. funbrowser/__init__.py +120 -0
  2. funbrowser/_cdp.py +181 -0
  3. funbrowser/_errors.py +32 -0
  4. funbrowser/_flags.py +89 -0
  5. funbrowser/_launcher.py +153 -0
  6. funbrowser/browser.py +281 -0
  7. funbrowser/context.py +163 -0
  8. funbrowser/context_pool.py +162 -0
  9. funbrowser/element.py +258 -0
  10. funbrowser/fingerprint/__init__.py +14 -0
  11. funbrowser/fingerprint/data.py +74 -0
  12. funbrowser/fingerprint/presets.py +588 -0
  13. funbrowser/geo.py +139 -0
  14. funbrowser/humanly.py +188 -0
  15. funbrowser/panel.py +1181 -0
  16. funbrowser/pool.py +152 -0
  17. funbrowser/profile.py +73 -0
  18. funbrowser/proxy.py +236 -0
  19. funbrowser/py.typed +0 -0
  20. funbrowser/solver/__init__.py +12 -0
  21. funbrowser/solver/bridge.py +167 -0
  22. funbrowser/solver/client.py +244 -0
  23. funbrowser/solver/scripts/__init__.py +0 -0
  24. funbrowser/solver/scripts/_bootstrap.js +30 -0
  25. funbrowser/solver/scripts/funcaptcha.js +74 -0
  26. funbrowser/solver/scripts/geetest.js +76 -0
  27. funbrowser/solver/scripts/hcaptcha.js +76 -0
  28. funbrowser/solver/scripts/recaptcha_v2.js +79 -0
  29. funbrowser/solver/scripts/recaptcha_v3.js +45 -0
  30. funbrowser/solver/scripts/turnstile.js +60 -0
  31. funbrowser/stealth/__init__.py +13 -0
  32. funbrowser/stealth/flags.py +54 -0
  33. funbrowser/stealth/patches.py +214 -0
  34. funbrowser/stealth/scripts/__init__.py +0 -0
  35. funbrowser/stealth/scripts/_camouflage.js +32 -0
  36. funbrowser/stealth/scripts/_cleanup.js +8 -0
  37. funbrowser/stealth/scripts/audio_noise.js +32 -0
  38. funbrowser/stealth/scripts/canvas_noise.js +43 -0
  39. funbrowser/stealth/scripts/chrome_runtime.js +53 -0
  40. funbrowser/stealth/scripts/hardware.js +15 -0
  41. funbrowser/stealth/scripts/languages.js +13 -0
  42. funbrowser/stealth/scripts/permissions.js +15 -0
  43. funbrowser/stealth/scripts/platform.js +18 -0
  44. funbrowser/stealth/scripts/plugins.js +37 -0
  45. funbrowser/stealth/scripts/screen_props.js +18 -0
  46. funbrowser/stealth/scripts/webdriver.js +14 -0
  47. funbrowser/stealth/scripts/webgl.js +27 -0
  48. funbrowser/stealth/scripts/webrtc.js +45 -0
  49. funbrowser/tab.py +345 -0
  50. funbrowser/tls/__init__.py +25 -0
  51. funbrowser/tls/ca.py +181 -0
  52. funbrowser/tls/http.py +145 -0
  53. funbrowser/tls/mitm.py +326 -0
  54. funbrowser-0.1.0.dist-info/METADATA +316 -0
  55. funbrowser-0.1.0.dist-info/RECORD +57 -0
  56. funbrowser-0.1.0.dist-info/WHEEL +4 -0
  57. funbrowser-0.1.0.dist-info/licenses/LICENSE +21 -0
funbrowser/__init__.py ADDED
@@ -0,0 +1,120 @@
1
+ """FunBrowser — undetect browser with built-in captcha solving via funsolver.com."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ from . import humanly as humanly_mod
9
+ from . import tls as tls_mod
10
+ from ._errors import (
11
+ BrowserLaunchError,
12
+ BrowserNotFoundError,
13
+ CDPConnectionClosed,
14
+ CDPError,
15
+ FunBrowserError,
16
+ TargetClosed,
17
+ )
18
+ from .browser import Browser
19
+ from .context import BrowserContext
20
+ from .context_pool import ContextPool
21
+ from .element import ElementHandle
22
+ from .fingerprint import Fingerprint, presets
23
+ from .geo import GeoInfo, lookup_proxy_geo
24
+ from .humanly import HumanBehavior
25
+ from .pool import BrowserPool
26
+
27
+ # Panel is optional — only available when aiohttp is installed.
28
+ try:
29
+ from .panel import Panel
30
+ except ImportError:
31
+ Panel = None # type: ignore[assignment, misc]
32
+ from .profile import Profile
33
+ from .proxy import Proxy, ProxyParseError
34
+ from .proxy import parse as parse_proxy
35
+ from .solver import FunSolverClient, FunSolverError
36
+ from .tab import Tab
37
+
38
+ humanly = humanly_mod
39
+ tls = tls_mod
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = [
44
+ "Browser",
45
+ "BrowserContext",
46
+ "BrowserLaunchError",
47
+ "BrowserNotFoundError",
48
+ "BrowserPool",
49
+ "CDPConnectionClosed",
50
+ "CDPError",
51
+ "ContextPool",
52
+ "ElementHandle",
53
+ "Fingerprint",
54
+ "FunBrowserError",
55
+ "FunSolverClient",
56
+ "FunSolverError",
57
+ "GeoInfo",
58
+ "HumanBehavior",
59
+ "Panel",
60
+ "Profile",
61
+ "Proxy",
62
+ "ProxyParseError",
63
+ "Tab",
64
+ "TargetClosed",
65
+ "__version__",
66
+ "humanly",
67
+ "lookup_proxy_geo",
68
+ "parse_proxy",
69
+ "presets",
70
+ "start",
71
+ "tls",
72
+ ]
73
+
74
+
75
+ async def start(
76
+ *,
77
+ executable: str | Path | None = None,
78
+ user_data_dir: str | Path | None = None,
79
+ headless: bool = False,
80
+ stealth: bool = True,
81
+ fingerprint: Fingerprint | None = None,
82
+ proxy: str | Proxy | None = None,
83
+ geo_autoconfigure: bool = True,
84
+ humanly: bool | HumanBehavior = False,
85
+ mini: bool = False,
86
+ api_key: str | None = None,
87
+ auto_solve: bool = True,
88
+ solver_base_url: str | None = None,
89
+ args: Sequence[str] = (),
90
+ ) -> Browser:
91
+ """Launch Chrome and return a connected Browser.
92
+
93
+ ``proxy`` accepts any string format common in proxy lists — see
94
+ :mod:`funbrowser.proxy` for the full list — or a pre-built
95
+ :class:`Proxy`. HTTP/HTTPS auth is handled automatically via CDP;
96
+ SOCKS auth is not (front with a local HTTP proxy).
97
+
98
+ Pass ``fingerprint=`` (a :class:`Fingerprint` or a value from
99
+ :mod:`funbrowser.presets`) to override the JS-visible identity values
100
+ (UA, platform, languages, CPU cores, screen, WebGL strings, etc.).
101
+
102
+ If ``api_key`` is provided and ``auto_solve`` is true, captchas
103
+ detected on each tab will be sent to funsolver.com and solved
104
+ automatically.
105
+ """
106
+ return await Browser.start(
107
+ executable=executable,
108
+ user_data_dir=user_data_dir,
109
+ headless=headless,
110
+ stealth=stealth,
111
+ fingerprint=fingerprint,
112
+ proxy=proxy,
113
+ geo_autoconfigure=geo_autoconfigure,
114
+ humanly=humanly,
115
+ mini=mini,
116
+ api_key=api_key,
117
+ auto_solve=auto_solve,
118
+ solver_base_url=solver_base_url,
119
+ args=args,
120
+ )
funbrowser/_cdp.py ADDED
@@ -0,0 +1,181 @@
1
+ """Raw CDP transport over WebSocket.
2
+
3
+ Spec: https://chromedevtools.github.io/devtools-protocol/
4
+
5
+ The connection multiplexes:
6
+ - request/response by sequential id (per the CDP spec)
7
+ - event broadcasts dispatched to per-method listeners
8
+ - flat session sub-targets — every outgoing message may carry a sessionId
9
+ obtained from Target.attachToTarget(flatten=True); incoming events with a
10
+ sessionId are routed to session-scoped listeners.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ from collections.abc import Awaitable, Callable
19
+ from typing import Any
20
+
21
+ from websockets.asyncio.client import ClientConnection
22
+ from websockets.asyncio.client import connect as ws_connect
23
+
24
+ from ._errors import CDPConnectionClosed, CDPError
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ EventHandler = Callable[[dict[str, Any]], Awaitable[None] | None]
29
+ Unsubscribe = Callable[[], None]
30
+
31
+
32
+ class CDPConnection:
33
+ def __init__(self, ws_url: str) -> None:
34
+ self._ws_url = ws_url
35
+ self._next_id = 0
36
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
37
+ self._listeners: dict[str, list[EventHandler]] = {}
38
+ self._session_listeners: dict[tuple[str, str], list[EventHandler]] = {}
39
+ self._ws: ClientConnection | None = None
40
+ self._recv_task: asyncio.Task[None] | None = None
41
+ self._listener_tasks: set[asyncio.Task[None]] = set()
42
+ self._closed = asyncio.Event()
43
+ self._send_lock = asyncio.Lock()
44
+
45
+ @property
46
+ def closed(self) -> bool:
47
+ return self._closed.is_set()
48
+
49
+ async def connect(self) -> None:
50
+ self._ws = await ws_connect(self._ws_url, max_size=None, ping_interval=None)
51
+ self._recv_task = asyncio.create_task(self._receive_loop(), name="funbrowser-cdp-recv")
52
+
53
+ async def send(
54
+ self,
55
+ method: str,
56
+ params: dict[str, Any] | None = None,
57
+ *,
58
+ session_id: str | None = None,
59
+ timeout: float | None = 30.0,
60
+ ) -> dict[str, Any]:
61
+ if self._ws is None or self._closed.is_set():
62
+ raise CDPConnectionClosed("CDP connection is not open")
63
+ self._next_id += 1
64
+ msg_id = self._next_id
65
+ message: dict[str, Any] = {"id": msg_id, "method": method, "params": params or {}}
66
+ if session_id is not None:
67
+ message["sessionId"] = session_id
68
+
69
+ loop = asyncio.get_running_loop()
70
+ fut: asyncio.Future[dict[str, Any]] = loop.create_future()
71
+ self._pending[msg_id] = fut
72
+ try:
73
+ async with self._send_lock:
74
+ await self._ws.send(json.dumps(message))
75
+ if timeout is None:
76
+ return await fut
77
+ return await asyncio.wait_for(fut, timeout=timeout)
78
+ finally:
79
+ self._pending.pop(msg_id, None)
80
+
81
+ def on(
82
+ self,
83
+ method: str,
84
+ handler: EventHandler,
85
+ *,
86
+ session_id: str | None = None,
87
+ ) -> Unsubscribe:
88
+ """Subscribe to a CDP event. Returns an unsubscribe callable."""
89
+ if session_id is None:
90
+ lst = self._listeners.setdefault(method, [])
91
+ else:
92
+ lst = self._session_listeners.setdefault((session_id, method), [])
93
+ lst.append(handler)
94
+
95
+ def _unsub() -> None:
96
+ try:
97
+ lst.remove(handler)
98
+ except ValueError:
99
+ pass
100
+
101
+ return _unsub
102
+
103
+ async def _receive_loop(self) -> None:
104
+ assert self._ws is not None
105
+ try:
106
+ async for raw in self._ws:
107
+ try:
108
+ msg = json.loads(raw)
109
+ except json.JSONDecodeError:
110
+ logger.warning("CDP: failed to decode message")
111
+ continue
112
+ self._dispatch(msg)
113
+ except Exception:
114
+ logger.debug("CDP: receive loop ended", exc_info=True)
115
+ finally:
116
+ self._closed.set()
117
+ for fut in self._pending.values():
118
+ if not fut.done():
119
+ fut.set_exception(CDPConnectionClosed("CDP connection closed mid-request"))
120
+ self._pending.clear()
121
+
122
+ def _dispatch(self, msg: dict[str, Any]) -> None:
123
+ if "id" in msg:
124
+ fut = self._pending.pop(msg["id"], None)
125
+ if fut is None or fut.done():
126
+ return
127
+ if "error" in msg:
128
+ err = msg["error"]
129
+ fut.set_exception(
130
+ CDPError(
131
+ err.get("message", "CDP error"),
132
+ code=err.get("code"),
133
+ data=err.get("data"),
134
+ )
135
+ )
136
+ else:
137
+ fut.set_result(msg.get("result", {}))
138
+ return
139
+
140
+ method = msg.get("method")
141
+ if not method:
142
+ return
143
+ session_id = msg.get("sessionId")
144
+ params = msg.get("params", {})
145
+ if session_id is not None:
146
+ for h in list(self._session_listeners.get((session_id, method), [])):
147
+ self._safe_invoke(h, params)
148
+ for h in list(self._listeners.get(method, [])):
149
+ self._safe_invoke(h, params)
150
+
151
+ def _safe_invoke(self, handler: EventHandler, params: dict[str, Any]) -> None:
152
+ try:
153
+ result = handler(params)
154
+ except Exception:
155
+ logger.exception("CDP listener raised")
156
+ return
157
+ if asyncio.iscoroutine(result):
158
+ task = asyncio.create_task(self._run_async(result))
159
+ self._listener_tasks.add(task)
160
+ task.add_done_callback(self._listener_tasks.discard)
161
+
162
+ async def _run_async(self, coro: Awaitable[None]) -> None:
163
+ try:
164
+ await coro
165
+ except Exception:
166
+ logger.exception("CDP async listener raised")
167
+
168
+ async def close(self) -> None:
169
+ if self._closed.is_set():
170
+ return
171
+ if self._ws is not None:
172
+ try:
173
+ await self._ws.close()
174
+ except Exception:
175
+ pass
176
+ if self._recv_task is not None:
177
+ try:
178
+ await asyncio.wait_for(self._recv_task, timeout=2.0)
179
+ except (TimeoutError, asyncio.CancelledError):
180
+ self._recv_task.cancel()
181
+ self._closed.set()
funbrowser/_errors.py ADDED
@@ -0,0 +1,32 @@
1
+ """Exceptions raised by funbrowser."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class FunBrowserError(Exception):
7
+ """Base class for all funbrowser errors."""
8
+
9
+
10
+ class BrowserNotFoundError(FunBrowserError):
11
+ """No Chrome / Chromium executable could be located."""
12
+
13
+
14
+ class BrowserLaunchError(FunBrowserError):
15
+ """The browser process failed to start or expose a DevTools endpoint."""
16
+
17
+
18
+ class CDPError(FunBrowserError):
19
+ """A CDP command returned an error response."""
20
+
21
+ def __init__(self, message: str, *, code: int | None = None, data: object = None) -> None:
22
+ super().__init__(message)
23
+ self.code = code
24
+ self.data = data
25
+
26
+
27
+ class CDPConnectionClosed(FunBrowserError):
28
+ """The CDP WebSocket connection closed unexpectedly."""
29
+
30
+
31
+ class TargetClosed(FunBrowserError):
32
+ """Operation attempted on a tab whose target has been destroyed."""
funbrowser/_flags.py ADDED
@@ -0,0 +1,89 @@
1
+ """Chrome launch flag presets and a merger that unions multi-value flags.
2
+
3
+ Chrome accepts ``--disable-features=A,B`` once. Pass it twice and only the
4
+ last wins. Stealth flags + mini flags + user-supplied flags can each carry
5
+ their own ``--disable-features``, so we union all of them before launch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def mini_flags() -> list[str]:
12
+ """Chrome flags that cut RAM / CPU / disk per browser.
13
+
14
+ Designed to be combined with :func:`funbrowser.stealth.stealth_flags`:
15
+ does **not** turn off the GPU (stealth needs the real-GPU WebGL
16
+ fingerprint), does **not** force ``--single-process`` (too crash-prone).
17
+ What it does turn off:
18
+
19
+ - site-per-process / IsolateOrigins → far fewer renderer processes
20
+ - background timers, occluded windows, renderer backgrounding
21
+ - extensions, sync, translate, breakpad, component-update
22
+ - audio output, metrics, domain reliability, hang monitor
23
+ - small disk / media caches, capped V8 heap per renderer
24
+
25
+ Typical effect on a 10-browser farm: ~50-60% lower RSS vs default
26
+ headless Chrome.
27
+ """
28
+ return [
29
+ "--mute-audio",
30
+ # Bundle all "feature off" toggles into a single --disable-features
31
+ # so merge_flags doesn't have to fight with stealth_flags.
32
+ "--disable-features="
33
+ "IsolateOrigins,site-per-process,"
34
+ "BackForwardCache,InterestFeedV2,"
35
+ "GlobalMediaControls,CalculateNativeWinOcclusion,"
36
+ "AudioServiceOutOfProcess",
37
+ "--disable-background-networking",
38
+ "--disable-background-timer-throttling",
39
+ "--disable-backgrounding-occluded-windows",
40
+ "--disable-renderer-backgrounding",
41
+ "--disable-breakpad",
42
+ "--disable-client-side-phishing-detection",
43
+ "--disable-component-update",
44
+ "--disable-domain-reliability",
45
+ "--disable-hang-monitor",
46
+ "--disable-ipc-flooding-protection",
47
+ "--disable-popup-blocking",
48
+ "--disable-prompt-on-repost",
49
+ "--disable-sync",
50
+ "--disable-translate",
51
+ "--disable-extensions",
52
+ "--disable-component-extensions-with-background-pages",
53
+ "--disk-cache-size=10485760",
54
+ "--media-cache-size=10485760",
55
+ "--js-flags=--max-old-space-size=192",
56
+ "--metrics-recording-only",
57
+ ]
58
+
59
+
60
+ def merge_flags(*flag_lists: list[str]) -> list[str]:
61
+ """Combine flag lists, unioning multi-value flags and deduplicating others.
62
+
63
+ Chrome only honours the **last** ``--disable-features=`` argument on
64
+ the command line. Without this merger, stealth_flags + mini_flags
65
+ would silently drop one of their feature sets. We collect every
66
+ feature mentioned across all lists and emit a single
67
+ ``--disable-features=`` (same for ``--enable-features=``) at the end.
68
+ """
69
+ disabled: list[str] = []
70
+ enabled: list[str] = []
71
+ rest: list[str] = []
72
+ seen: set[str] = set()
73
+
74
+ for lst in flag_lists:
75
+ for f in lst:
76
+ if f.startswith("--disable-features="):
77
+ disabled.extend(p for p in f.removeprefix("--disable-features=").split(",") if p)
78
+ elif f.startswith("--enable-features="):
79
+ enabled.extend(p for p in f.removeprefix("--enable-features=").split(",") if p)
80
+ elif f not in seen:
81
+ seen.add(f)
82
+ rest.append(f)
83
+
84
+ out: list[str] = list(rest)
85
+ if disabled:
86
+ out.append("--disable-features=" + ",".join(sorted(set(disabled))))
87
+ if enabled:
88
+ out.append("--enable-features=" + ",".join(sorted(set(enabled))))
89
+ return out
@@ -0,0 +1,153 @@
1
+ """Find and launch Chrome / Chromium with remote debugging enabled."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ import shutil
9
+ import sys
10
+ import tempfile
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from ._errors import BrowserLaunchError, BrowserNotFoundError
16
+
17
+ DEVTOOLS_RE = re.compile(rb"DevTools listening on (ws://\S+)")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class LaunchedBrowser:
22
+ process: asyncio.subprocess.Process
23
+ ws_url: str
24
+ user_data_dir: Path
25
+ user_data_dir_is_tmp: bool
26
+
27
+
28
+ def _candidate_paths() -> list[Path]:
29
+ if sys.platform == "win32":
30
+ roots = [
31
+ os.environ.get("PROGRAMFILES", r"C:\Program Files"),
32
+ os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"),
33
+ os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")),
34
+ ]
35
+ rels = [
36
+ r"Google\Chrome\Application\chrome.exe",
37
+ r"Chromium\Application\chrome.exe",
38
+ r"Google\Chrome Beta\Application\chrome.exe",
39
+ r"Google\Chrome Dev\Application\chrome.exe",
40
+ r"BraveSoftware\Brave-Browser\Application\brave.exe",
41
+ r"Microsoft\Edge\Application\msedge.exe",
42
+ ]
43
+ return [Path(r) / rel for r in roots if r for rel in rels]
44
+ if sys.platform == "darwin":
45
+ return [
46
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
47
+ Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
48
+ Path("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
49
+ Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
50
+ ]
51
+ return [
52
+ Path("/usr/bin/google-chrome"),
53
+ Path("/usr/bin/google-chrome-stable"),
54
+ Path("/usr/bin/chromium"),
55
+ Path("/usr/bin/chromium-browser"),
56
+ Path("/snap/bin/chromium"),
57
+ ]
58
+
59
+
60
+ def find_chrome() -> Path | None:
61
+ """Return the first Chrome/Chromium-family binary found on this system."""
62
+ env = os.environ.get("FUNBROWSER_CHROME")
63
+ if env:
64
+ p = Path(env)
65
+ if p.is_file():
66
+ return p
67
+ for path in _candidate_paths():
68
+ if path.is_file():
69
+ return path
70
+ for name in (
71
+ "google-chrome",
72
+ "google-chrome-stable",
73
+ "chromium",
74
+ "chromium-browser",
75
+ "chrome",
76
+ ):
77
+ found = shutil.which(name)
78
+ if found:
79
+ return Path(found)
80
+ return None
81
+
82
+
83
+ def _base_args(user_data_dir: Path, headless: bool, port: int) -> list[str]:
84
+ args = [
85
+ f"--remote-debugging-port={port}",
86
+ f"--user-data-dir={user_data_dir}",
87
+ "--no-first-run",
88
+ "--no-default-browser-check",
89
+ "--disable-blink-features=AutomationControlled",
90
+ ]
91
+ if headless:
92
+ args.append("--headless=new")
93
+ return args
94
+
95
+
96
+ async def launch_chrome(
97
+ *,
98
+ executable: Path | None = None,
99
+ user_data_dir: Path | None = None,
100
+ headless: bool = False,
101
+ extra_args: Sequence[str] = (),
102
+ port: int = 0,
103
+ startup_timeout: float = 30.0,
104
+ ) -> LaunchedBrowser:
105
+ """Spawn Chrome with remote debugging and return its DevTools websocket URL."""
106
+ exe = executable or find_chrome()
107
+ if exe is None or not exe.is_file():
108
+ raise BrowserNotFoundError(
109
+ "No Chrome/Chromium binary located. Set the FUNBROWSER_CHROME env var "
110
+ "or pass executable= explicitly."
111
+ )
112
+
113
+ tmp_profile = False
114
+ if user_data_dir is None:
115
+ user_data_dir = Path(tempfile.mkdtemp(prefix="funbrowser-profile-"))
116
+ tmp_profile = True
117
+ else:
118
+ user_data_dir = Path(user_data_dir)
119
+ user_data_dir.mkdir(parents=True, exist_ok=True)
120
+
121
+ args = [str(exe), *_base_args(user_data_dir, headless, port), *extra_args]
122
+
123
+ proc = await asyncio.create_subprocess_exec(
124
+ *args,
125
+ stdout=asyncio.subprocess.DEVNULL,
126
+ stderr=asyncio.subprocess.PIPE,
127
+ )
128
+ ws_url = await _read_devtools_url(proc, startup_timeout)
129
+ return LaunchedBrowser(
130
+ process=proc,
131
+ ws_url=ws_url,
132
+ user_data_dir=user_data_dir,
133
+ user_data_dir_is_tmp=tmp_profile,
134
+ )
135
+
136
+
137
+ async def _read_devtools_url(proc: asyncio.subprocess.Process, timeout: float) -> str:
138
+ assert proc.stderr is not None
139
+ try:
140
+ async with asyncio.timeout(timeout):
141
+ while True:
142
+ line = await proc.stderr.readline()
143
+ if not line:
144
+ code = await proc.wait()
145
+ raise BrowserLaunchError(
146
+ f"Chrome exited (rc={code}) before printing DevTools URL"
147
+ )
148
+ m = DEVTOOLS_RE.search(line)
149
+ if m:
150
+ return m.group(1).decode("ascii")
151
+ except TimeoutError as exc:
152
+ proc.kill()
153
+ raise BrowserLaunchError("Timed out waiting for Chrome to print its DevTools URL") from exc