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.
- funbrowser/__init__.py +120 -0
- funbrowser/_cdp.py +181 -0
- funbrowser/_errors.py +32 -0
- funbrowser/_flags.py +89 -0
- funbrowser/_launcher.py +153 -0
- funbrowser/browser.py +281 -0
- funbrowser/context.py +163 -0
- funbrowser/context_pool.py +162 -0
- funbrowser/element.py +258 -0
- funbrowser/fingerprint/__init__.py +14 -0
- funbrowser/fingerprint/data.py +74 -0
- funbrowser/fingerprint/presets.py +588 -0
- funbrowser/geo.py +139 -0
- funbrowser/humanly.py +188 -0
- funbrowser/panel.py +1181 -0
- funbrowser/pool.py +152 -0
- funbrowser/profile.py +73 -0
- funbrowser/proxy.py +236 -0
- funbrowser/py.typed +0 -0
- funbrowser/solver/__init__.py +12 -0
- funbrowser/solver/bridge.py +167 -0
- funbrowser/solver/client.py +244 -0
- funbrowser/solver/scripts/__init__.py +0 -0
- funbrowser/solver/scripts/_bootstrap.js +30 -0
- funbrowser/solver/scripts/funcaptcha.js +74 -0
- funbrowser/solver/scripts/geetest.js +76 -0
- funbrowser/solver/scripts/hcaptcha.js +76 -0
- funbrowser/solver/scripts/recaptcha_v2.js +79 -0
- funbrowser/solver/scripts/recaptcha_v3.js +45 -0
- funbrowser/solver/scripts/turnstile.js +60 -0
- funbrowser/stealth/__init__.py +13 -0
- funbrowser/stealth/flags.py +54 -0
- funbrowser/stealth/patches.py +214 -0
- funbrowser/stealth/scripts/__init__.py +0 -0
- funbrowser/stealth/scripts/_camouflage.js +32 -0
- funbrowser/stealth/scripts/_cleanup.js +8 -0
- funbrowser/stealth/scripts/audio_noise.js +32 -0
- funbrowser/stealth/scripts/canvas_noise.js +43 -0
- funbrowser/stealth/scripts/chrome_runtime.js +53 -0
- funbrowser/stealth/scripts/hardware.js +15 -0
- funbrowser/stealth/scripts/languages.js +13 -0
- funbrowser/stealth/scripts/permissions.js +15 -0
- funbrowser/stealth/scripts/platform.js +18 -0
- funbrowser/stealth/scripts/plugins.js +37 -0
- funbrowser/stealth/scripts/screen_props.js +18 -0
- funbrowser/stealth/scripts/webdriver.js +14 -0
- funbrowser/stealth/scripts/webgl.js +27 -0
- funbrowser/stealth/scripts/webrtc.js +45 -0
- funbrowser/tab.py +345 -0
- funbrowser/tls/__init__.py +25 -0
- funbrowser/tls/ca.py +181 -0
- funbrowser/tls/http.py +145 -0
- funbrowser/tls/mitm.py +326 -0
- funbrowser-0.1.0.dist-info/METADATA +316 -0
- funbrowser-0.1.0.dist-info/RECORD +57 -0
- funbrowser-0.1.0.dist-info/WHEEL +4 -0
- 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
|
funbrowser/_launcher.py
ADDED
|
@@ -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
|