barebidi 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.
- barebidi/__init__.py +33 -0
- barebidi/browser.py +458 -0
- barebidi/connection.py +123 -0
- barebidi/context.py +96 -0
- barebidi/dialogs.py +87 -0
- barebidi/downloads.py +83 -0
- barebidi/element.py +320 -0
- barebidi/frame.py +291 -0
- barebidi/human.py +150 -0
- barebidi/network.py +354 -0
- barebidi/protocol.py +1996 -0
- barebidi/proxy.py +141 -0
- barebidi/stealth.py +58 -0
- barebidi/tab.py +116 -0
- barebidi/values.py +76 -0
- barebidi-0.1.0.dist-info/METADATA +213 -0
- barebidi-0.1.0.dist-info/RECORD +19 -0
- barebidi-0.1.0.dist-info/WHEEL +4 -0
- barebidi-0.1.0.dist-info/licenses/LICENSE +21 -0
barebidi/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from . import protocol
|
|
2
|
+
from .browser import Browser, find_firefox, start
|
|
3
|
+
from .connection import BidiError, BrowserClosedError, Connection
|
|
4
|
+
from .context import Context
|
|
5
|
+
from .dialogs import Dialog
|
|
6
|
+
from .downloads import Download
|
|
7
|
+
from .element import Element, ElementQuery
|
|
8
|
+
from .frame import Frame, JavaScriptError
|
|
9
|
+
from .network import ExpectedResponse, Intercept, InterceptedRequest, Request, Response
|
|
10
|
+
from .tab import Tab
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BidiError",
|
|
14
|
+
"BrowserClosedError",
|
|
15
|
+
"Browser",
|
|
16
|
+
"Connection",
|
|
17
|
+
"Context",
|
|
18
|
+
"Dialog",
|
|
19
|
+
"Download",
|
|
20
|
+
"Element",
|
|
21
|
+
"ElementQuery",
|
|
22
|
+
"ExpectedResponse",
|
|
23
|
+
"Frame",
|
|
24
|
+
"Intercept",
|
|
25
|
+
"InterceptedRequest",
|
|
26
|
+
"JavaScriptError",
|
|
27
|
+
"Request",
|
|
28
|
+
"Response",
|
|
29
|
+
"Tab",
|
|
30
|
+
"find_firefox",
|
|
31
|
+
"protocol",
|
|
32
|
+
"start",
|
|
33
|
+
]
|
barebidi/browser.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import AsyncIterator, Callable, Iterable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .connection import BidiError, Connection, Handler
|
|
17
|
+
from .context import Context
|
|
18
|
+
from .dialogs import DialogHandler, Dialogs
|
|
19
|
+
from .downloads import Downloads
|
|
20
|
+
from .network import Intercept, InterceptHandler, Network, Phase
|
|
21
|
+
from .proxy import NO_PROXY_PREFS, Proxy, Relay
|
|
22
|
+
from .stealth import CLEAR_AUTOMATION_FLAG, HIDE_WEBDRIVER
|
|
23
|
+
from .stealth import PREFS as STEALTH_PREFS
|
|
24
|
+
from .tab import Tab
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_WS_URL = re.compile(rb"WebDriver BiDi listening on (ws://\S+)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _ExitedDuringStartup(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
DEFAULT_HEADLESS_SCREEN = (1920, 1080)
|
|
35
|
+
|
|
36
|
+
ROUTED_MOUSE = "remote.events.async.mouse.enabled"
|
|
37
|
+
|
|
38
|
+
DEFAULT_PREFS: dict[str, Any] = {
|
|
39
|
+
"browser.shell.checkDefaultBrowser": False,
|
|
40
|
+
"browser.startup.homepage_override.mstone": "ignore",
|
|
41
|
+
"browser.aboutwelcome.enabled": False,
|
|
42
|
+
"browser.tabs.warnOnClose": False,
|
|
43
|
+
"browser.sessionstore.resume_from_crash": False,
|
|
44
|
+
"datareporting.policy.dataSubmissionEnabled": False,
|
|
45
|
+
"toolkit.telemetry.reportingpolicy.firstRun": False,
|
|
46
|
+
"app.update.disabledForTesting": True,
|
|
47
|
+
# Behind a proxy, WebRTC must not reveal the real IP by reaching STUN servers directly.
|
|
48
|
+
"media.peerconnection.ice.proxy_only_if_behind_proxy": True,
|
|
49
|
+
# Mouse and wheel input enters through the browser window and its hit testing, like real input, instead
|
|
50
|
+
# of being synthesized inside the page's process: it reaches cross-site iframes, with coherent coordinates.
|
|
51
|
+
ROUTED_MOUSE: True,
|
|
52
|
+
"remote.events.async.wheel.enabled": True,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_firefox() -> str:
|
|
57
|
+
if path := os.environ.get("FIREFOX_PATH"):
|
|
58
|
+
return path
|
|
59
|
+
candidates = {
|
|
60
|
+
"darwin": ["/Applications/Firefox.app/Contents/MacOS/firefox", "/Applications/Firefox Nightly.app/Contents/MacOS/firefox"],
|
|
61
|
+
"win32": [
|
|
62
|
+
rf"{os.environ.get('PROGRAMFILES', 'C:/Program Files')}\Mozilla Firefox\firefox.exe",
|
|
63
|
+
rf"{os.environ.get('PROGRAMFILES(X86)', 'C:/Program Files (x86)')}\Mozilla Firefox\firefox.exe",
|
|
64
|
+
],
|
|
65
|
+
}.get(sys.platform, [])
|
|
66
|
+
for candidate in candidates:
|
|
67
|
+
if Path(candidate).exists():
|
|
68
|
+
return candidate
|
|
69
|
+
for name in ("firefox", "firefox-esr", "firefox-nightly"):
|
|
70
|
+
if found := shutil.which(name):
|
|
71
|
+
return found
|
|
72
|
+
raise FileNotFoundError("Firefox not found; install it or set FIREFOX_PATH")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_snap(executable: str) -> bool:
|
|
76
|
+
"""A snap-packaged Firefox (Ubuntu's default), directly or through the script Ubuntu puts in /usr/bin."""
|
|
77
|
+
resolved = Path(executable).resolve()
|
|
78
|
+
if resolved.parts[:2] == ("/", "snap"):
|
|
79
|
+
return True
|
|
80
|
+
try:
|
|
81
|
+
with open(resolved, "rb") as file:
|
|
82
|
+
head = file.read(4096)
|
|
83
|
+
except OSError:
|
|
84
|
+
return False
|
|
85
|
+
return head.startswith(b"#!") and b"/snap/" in head
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Browser:
|
|
89
|
+
"""Firefox driven over WebDriver BiDi.
|
|
90
|
+
|
|
91
|
+
`proxy`, `locale` and `timezone` set up the default context (see Context); `profile` keeps its
|
|
92
|
+
cookies and storage between runs instead of starting from a temporary profile. `screen` is the
|
|
93
|
+
size of the virtual screen in headless mode; otherwise the real screen is used. With `humanize`,
|
|
94
|
+
the mouse travels in curves, keys are typed at a human rhythm and scrolling uses the wheel;
|
|
95
|
+
turn it off (here or later through the attribute) for speed. Downloaded files go to `downloads`,
|
|
96
|
+
or to a temporary folder that disappears with the browser. `timeout` is how many seconds Firefox
|
|
97
|
+
gets to answer each command (None waits forever); slow pages can get more through Tab.get.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
headless: bool = False,
|
|
104
|
+
screen: tuple[int, int] | None = None,
|
|
105
|
+
stealth: bool = True,
|
|
106
|
+
humanize: bool = True,
|
|
107
|
+
profile: str | Path | None = None,
|
|
108
|
+
proxy: str | None = None,
|
|
109
|
+
locale: str | None = None,
|
|
110
|
+
timezone: str | None = None,
|
|
111
|
+
executable_path: str | None = None,
|
|
112
|
+
args: Iterable[str] = (),
|
|
113
|
+
prefs: dict[str, Any] | None = None,
|
|
114
|
+
env: dict[str, str] | None = None,
|
|
115
|
+
downloads: str | Path | None = None,
|
|
116
|
+
timeout: float | None = 60.0,
|
|
117
|
+
startup_timeout: float = 30.0,
|
|
118
|
+
):
|
|
119
|
+
if screen and not headless:
|
|
120
|
+
raise ValueError("the screen size can only be chosen in headless mode; otherwise it is the real one")
|
|
121
|
+
self.headless = headless
|
|
122
|
+
self.screen = screen or DEFAULT_HEADLESS_SCREEN
|
|
123
|
+
self.stealth = stealth
|
|
124
|
+
self.humanize = humanize
|
|
125
|
+
self.profile = Path(profile) if profile else None
|
|
126
|
+
self.downloads = Path(downloads) if downloads else None # where files go; a temporary folder if None
|
|
127
|
+
self.proxy = Proxy.parse(proxy) if proxy else None
|
|
128
|
+
self.locale = locale
|
|
129
|
+
self.timezone = timezone
|
|
130
|
+
self.executable_path = executable_path
|
|
131
|
+
self.args = list(args)
|
|
132
|
+
self.env = dict(env or {}) # extra environment for the browser process (e.g. a patched build's config)
|
|
133
|
+
self.prefs = {
|
|
134
|
+
**DEFAULT_PREFS,
|
|
135
|
+
**(STEALTH_PREFS if stealth else {}),
|
|
136
|
+
**(self.proxy.prefs() if self.proxy else NO_PROXY_PREFS),
|
|
137
|
+
**(prefs or {}),
|
|
138
|
+
}
|
|
139
|
+
self.timeout = timeout # seconds Firefox gets to answer each command; mouse and keyboard add their own duration
|
|
140
|
+
self.startup_timeout = startup_timeout
|
|
141
|
+
self.connection: Connection
|
|
142
|
+
self.session_id: str | None = None
|
|
143
|
+
self.capabilities: dict = {}
|
|
144
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
145
|
+
self._profile: str | None = None
|
|
146
|
+
self._stderr_task: asyncio.Task | None = None
|
|
147
|
+
self._parents: dict[str, str] = {}
|
|
148
|
+
self._user_contexts: dict[str, str] = {}
|
|
149
|
+
self._pointers: dict[str, tuple[int, int]] = {}
|
|
150
|
+
self._network = Network(self)
|
|
151
|
+
self._dialogs = Dialogs(self)
|
|
152
|
+
self._downloader = Downloads(self)
|
|
153
|
+
self._downloads_dir: str | None = None
|
|
154
|
+
self._scratch: Path | None = None
|
|
155
|
+
self._relays: list[Relay] = []
|
|
156
|
+
self.context = Context(self, "default")
|
|
157
|
+
|
|
158
|
+
async def __aenter__(self) -> "Browser":
|
|
159
|
+
return await self.start()
|
|
160
|
+
|
|
161
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
162
|
+
await self.stop()
|
|
163
|
+
|
|
164
|
+
async def start(self) -> "Browser":
|
|
165
|
+
executable = self.executable_path or find_firefox()
|
|
166
|
+
# A snap's /tmp is its own: keep our temporary folders where a snap-packaged Firefox can reach them.
|
|
167
|
+
self._scratch = Path.home() / "snap" / "firefox" / "common" if _is_snap(executable) else None
|
|
168
|
+
if self._scratch:
|
|
169
|
+
self._scratch.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
if self.profile:
|
|
171
|
+
self.profile.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
self._profile = str(self.profile)
|
|
173
|
+
else:
|
|
174
|
+
self._profile = tempfile.mkdtemp(prefix="barebidi-", dir=self._scratch)
|
|
175
|
+
if self.proxy and self.proxy.credentials:
|
|
176
|
+
self.prefs |= (await self._relay(self.proxy)).local.prefs()
|
|
177
|
+
prefs = "".join(f"user_pref({json.dumps(k)}, {json.dumps(v)});\n" for k, v in self.prefs.items())
|
|
178
|
+
Path(self._profile, "user.js").write_text(prefs)
|
|
179
|
+
|
|
180
|
+
cmd = [executable, "--remote-debugging-port=0", "--profile", self._profile, "--no-remote"]
|
|
181
|
+
if self.headless:
|
|
182
|
+
cmd.append("--headless")
|
|
183
|
+
env = dict(os.environ)
|
|
184
|
+
if self.stealth:
|
|
185
|
+
# Access to the browser's own scope; older Firefox takes the flag, newer ones only the variable.
|
|
186
|
+
cmd.append("--remote-allow-system-access")
|
|
187
|
+
env["MOZ_REMOTE_ALLOW_SYSTEM_ACCESS"] = "1"
|
|
188
|
+
cmd += [*self.args, "about:blank"]
|
|
189
|
+
if self.headless:
|
|
190
|
+
width, height = self.screen
|
|
191
|
+
env |= {"MOZ_HEADLESS_WIDTH": str(width), "MOZ_HEADLESS_HEIGHT": str(height)}
|
|
192
|
+
env |= self.env # caller's env wins, so it can override our defaults
|
|
193
|
+
try:
|
|
194
|
+
url = await self._launch(cmd, env)
|
|
195
|
+
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
196
|
+
self.connection = await Connection.connect(f"{url}/session", self.timeout)
|
|
197
|
+
# Dialogs wait for us to answer them (see Dialogs); native file pickers are cancelled at once.
|
|
198
|
+
prompts = {"default": "ignore", "file": "dismiss"}
|
|
199
|
+
session = await self.connection.send("session.new", {"capabilities": {"alwaysMatch": {"unhandledPromptBehavior": prompts}}})
|
|
200
|
+
await self._track_frames()
|
|
201
|
+
await self._watch_dialogs_and_downloads()
|
|
202
|
+
await self.context._emulate(self.locale, self.timezone)
|
|
203
|
+
if self.stealth:
|
|
204
|
+
await self._clear_automation_flag()
|
|
205
|
+
tab = await self._replace_initial_tab()
|
|
206
|
+
if self.headless:
|
|
207
|
+
await self._fit_window(tab)
|
|
208
|
+
if self.stealth and not await self._webdriver_hidden(tab):
|
|
209
|
+
log.info("this Firefox keeps navigator.webdriver on; hiding it with a preload script instead")
|
|
210
|
+
await self.connection.send("script.addPreloadScript", {"functionDeclaration": HIDE_WEBDRIVER})
|
|
211
|
+
except BaseException:
|
|
212
|
+
await self._cleanup(grace=0)
|
|
213
|
+
raise
|
|
214
|
+
self.session_id = session["sessionId"]
|
|
215
|
+
self.capabilities = session["capabilities"]
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
async def stop(self) -> None:
|
|
219
|
+
if self._process is None:
|
|
220
|
+
return
|
|
221
|
+
if hasattr(self, "connection") and not self.connection.closed:
|
|
222
|
+
try:
|
|
223
|
+
await asyncio.wait_for(self.connection.send("browser.close"), 5)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
await self.connection.close()
|
|
227
|
+
await self._cleanup()
|
|
228
|
+
|
|
229
|
+
async def tabs(self) -> list[Tab]:
|
|
230
|
+
tree = await self.connection.send("browsingContext.getTree", {"maxDepth": 0})
|
|
231
|
+
return [Tab(self, c["context"]) for c in tree["contexts"]]
|
|
232
|
+
|
|
233
|
+
async def new_tab(self, url: str | None = None, background: bool = False) -> Tab:
|
|
234
|
+
return await self.context.new_tab(url, background)
|
|
235
|
+
|
|
236
|
+
async def get(self, url: str, new_tab: bool = False) -> Tab:
|
|
237
|
+
return await self.context.get(url, new_tab)
|
|
238
|
+
|
|
239
|
+
async def new_context(self, *, proxy: str | None = None, locale: str | None = None, timezone: str | None = None) -> Context:
|
|
240
|
+
"""A new isolated identity, gone when closed or when the browser stops."""
|
|
241
|
+
parsed = Proxy.parse(proxy) if proxy else None
|
|
242
|
+
relay = await self._relay(parsed) if parsed and parsed.credentials else None
|
|
243
|
+
config = relay.local if relay else parsed
|
|
244
|
+
result = await self.connection.send("browser.createUserContext", {"proxy": config.bidi()} if config else {})
|
|
245
|
+
context = Context(self, result["userContext"])
|
|
246
|
+
context._relay = relay
|
|
247
|
+
await context._emulate(locale, timezone)
|
|
248
|
+
return context
|
|
249
|
+
|
|
250
|
+
async def cookies(self, domain: str | None = None) -> list[dict]:
|
|
251
|
+
return await self.context.cookies(domain)
|
|
252
|
+
|
|
253
|
+
async def set_cookies(self, cookies: Iterable[dict]) -> None:
|
|
254
|
+
await self.context.set_cookies(cookies)
|
|
255
|
+
|
|
256
|
+
async def clear_cookies(self) -> None:
|
|
257
|
+
await self.context.clear_cookies()
|
|
258
|
+
|
|
259
|
+
async def on_dialog(self, handler: DialogHandler) -> Callable[[], None]:
|
|
260
|
+
"""Like Tab.on_dialog, for the dialogs of every tab."""
|
|
261
|
+
return self._dialogs.add(handler)
|
|
262
|
+
|
|
263
|
+
async def on(self, event: str, handler: Handler, contexts: list[str] | None = None) -> str:
|
|
264
|
+
"""Subscribe to a BiDi event or module ("network.responseCompleted", "log")."""
|
|
265
|
+
self.connection.on(event, handler)
|
|
266
|
+
params: dict = {"events": [event]}
|
|
267
|
+
if contexts:
|
|
268
|
+
params["contexts"] = contexts
|
|
269
|
+
result = await self.connection.send("session.subscribe", params)
|
|
270
|
+
return result.get("subscription", "")
|
|
271
|
+
|
|
272
|
+
async def intercept(self, handler: InterceptHandler, url: str | None = None, phase: Phase = "request") -> Intercept:
|
|
273
|
+
"""Pause matching requests in every tab and pass them to `handler`.
|
|
274
|
+
|
|
275
|
+
The handler settles each one with continue_(), abort() or fulfill(); if it doesn't,
|
|
276
|
+
the next matching intercept gets it, and if none does the request continues untouched.
|
|
277
|
+
"""
|
|
278
|
+
return await self._network.intercept(handler, url, phase)
|
|
279
|
+
|
|
280
|
+
async def block(self, *urls: str, resource_types: Iterable[str] = ()) -> Intercept:
|
|
281
|
+
"""Abort requests matching any URL pattern or resource type ("image", "font", "media", ...)."""
|
|
282
|
+
return await self._network.block(urls, resource_types)
|
|
283
|
+
|
|
284
|
+
async def set_extra_headers(self, headers: dict[str, str]) -> None:
|
|
285
|
+
"""Add headers to every request; an empty dict removes them."""
|
|
286
|
+
await self._network.set_extra_headers(headers)
|
|
287
|
+
|
|
288
|
+
def top_level(self, context: str) -> str:
|
|
289
|
+
"""The tab a browsing context (e.g. an iframe) belongs to."""
|
|
290
|
+
while context in self._parents:
|
|
291
|
+
context = self._parents[context]
|
|
292
|
+
return context
|
|
293
|
+
|
|
294
|
+
def user_context_of(self, context: str) -> str:
|
|
295
|
+
return self._user_contexts.get(context, "default")
|
|
296
|
+
|
|
297
|
+
async def _relay(self, proxy: Proxy) -> Relay:
|
|
298
|
+
"""Firefox's way to a proxy with credentials, which it then never has to handle (see Relay)."""
|
|
299
|
+
relay = await Relay(proxy).start()
|
|
300
|
+
self._relays.append(relay)
|
|
301
|
+
return relay
|
|
302
|
+
|
|
303
|
+
async def _clear_automation_flag(self) -> None:
|
|
304
|
+
await self._chrome_eval(CLEAR_AUTOMATION_FLAG)
|
|
305
|
+
|
|
306
|
+
@contextlib.asynccontextmanager
|
|
307
|
+
async def _input_inside_frames(self) -> AsyncIterator[None]:
|
|
308
|
+
"""Mouse input sent to a frame goes straight into its document instead of through the window."""
|
|
309
|
+
switch = f"Services.prefs.setBoolPref({json.dumps(ROUTED_MOUSE)}, %s)"
|
|
310
|
+
switched = bool(self.prefs.get(ROUTED_MOUSE)) and await self._chrome_eval(switch % "false")
|
|
311
|
+
if self.prefs.get(ROUTED_MOUSE) and not switched:
|
|
312
|
+
log.warning("no access to the browser's scope (it needs stealth=True): mouse input can't go straight into the frame")
|
|
313
|
+
try:
|
|
314
|
+
yield
|
|
315
|
+
finally:
|
|
316
|
+
if switched:
|
|
317
|
+
await self._chrome_eval(switch % "true")
|
|
318
|
+
|
|
319
|
+
async def _chrome_eval(self, expression: str) -> bool:
|
|
320
|
+
"""Run `expression` in the browser's own scope, which needs the system access stealth mode asks for."""
|
|
321
|
+
try:
|
|
322
|
+
tree = await self.connection.send("browsingContext.getTree", {"moz:scope": "chrome", "maxDepth": 0})
|
|
323
|
+
target = {"context": tree["contexts"][0]["context"]}
|
|
324
|
+
result = await self.connection.send("script.evaluate", {"expression": expression, "target": target, "awaitPromise": False})
|
|
325
|
+
except (BidiError, IndexError) as error:
|
|
326
|
+
log.debug("cannot run in the browser's scope: %s", error)
|
|
327
|
+
return False
|
|
328
|
+
return result.get("type") != "exception"
|
|
329
|
+
|
|
330
|
+
async def _webdriver_hidden(self, tab: Tab) -> bool:
|
|
331
|
+
for _ in range(10): # the flag reaches content processes asynchronously
|
|
332
|
+
if not await tab.evaluate("navigator.webdriver", isolated=True):
|
|
333
|
+
return True
|
|
334
|
+
await asyncio.sleep(0.05)
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
async def _fit_window(self, tab: Tab) -> None:
|
|
338
|
+
# Headless Firefox spreads its window over the whole screen, menu bar and dock included, which no
|
|
339
|
+
# real window does. Place it over the available area instead, like a maximized window.
|
|
340
|
+
x, y, width, height = await tab.evaluate(
|
|
341
|
+
"[screen.availLeft, screen.availTop, screen.availWidth, screen.availHeight]", isolated=True
|
|
342
|
+
)
|
|
343
|
+
window = (await self.connection.send("browser.getClientWindows"))["clientWindows"][0]["clientWindow"]
|
|
344
|
+
await self.connection.send(
|
|
345
|
+
"browser.setClientWindowState",
|
|
346
|
+
{"clientWindow": window, "state": "normal", "x": x, "y": y, "width": width, "height": height},
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
async def _replace_initial_tab(self) -> Tab:
|
|
350
|
+
# Firefox focuses the address bar of the startup about:blank tab, so documents loaded there never
|
|
351
|
+
# get focus (document.hasFocus() stays false). Tabs opened over BiDi get content focus.
|
|
352
|
+
initial = await self.tabs()
|
|
353
|
+
tab = await self.new_tab()
|
|
354
|
+
for old in initial:
|
|
355
|
+
await old.close()
|
|
356
|
+
return tab
|
|
357
|
+
|
|
358
|
+
async def _track_frames(self) -> None:
|
|
359
|
+
def created(params: dict) -> None:
|
|
360
|
+
self._user_contexts[params["context"]] = params.get("userContext", "default")
|
|
361
|
+
if params.get("parent"):
|
|
362
|
+
self._parents[params["context"]] = params["parent"]
|
|
363
|
+
|
|
364
|
+
def destroyed(params: dict) -> None:
|
|
365
|
+
self._parents.pop(params["context"], None)
|
|
366
|
+
self._user_contexts.pop(params["context"], None)
|
|
367
|
+
for child in params.get("children") or ():
|
|
368
|
+
destroyed(child)
|
|
369
|
+
|
|
370
|
+
self.connection.on("browsingContext.contextCreated", created)
|
|
371
|
+
self.connection.on("browsingContext.contextDestroyed", destroyed)
|
|
372
|
+
await self.connection.send(
|
|
373
|
+
"session.subscribe", {"events": ["browsingContext.contextCreated", "browsingContext.contextDestroyed"]}
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
async def _watch_dialogs_and_downloads(self) -> None:
|
|
377
|
+
self.connection.on("browsingContext.userPromptOpened", self._dialogs.opened)
|
|
378
|
+
self.connection.on("browsingContext.downloadWillBegin", self._downloader.begun)
|
|
379
|
+
self.connection.on("browsingContext.downloadEnd", self._downloader.ended)
|
|
380
|
+
events = ["browsingContext.userPromptOpened", "browsingContext.downloadWillBegin", "browsingContext.downloadEnd"]
|
|
381
|
+
await self.connection.send("session.subscribe", {"events": events})
|
|
382
|
+
if self.downloads:
|
|
383
|
+
self.downloads.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
self._downloads_dir = str(self.downloads.resolve())
|
|
385
|
+
else:
|
|
386
|
+
self._downloads_dir = tempfile.mkdtemp(prefix="barebidi-downloads-", dir=self._scratch)
|
|
387
|
+
behavior = {"type": "allowed", "destinationFolder": self._downloads_dir}
|
|
388
|
+
await self.connection.send("browser.setDownloadBehavior", {"downloadBehavior": behavior})
|
|
389
|
+
|
|
390
|
+
async def _launch(self, cmd: list[str], env: dict[str, str]) -> str:
|
|
391
|
+
"""Start Firefox and return its WebDriver BiDi URL."""
|
|
392
|
+
for attempt in range(2):
|
|
393
|
+
log.debug("launching %s", cmd)
|
|
394
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
395
|
+
*cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env
|
|
396
|
+
)
|
|
397
|
+
try:
|
|
398
|
+
return await asyncio.wait_for(self._read_ws_url(), self.startup_timeout)
|
|
399
|
+
except _ExitedDuringStartup:
|
|
400
|
+
# Right after an update Firefox may restart itself to finish it: the copy that carries on
|
|
401
|
+
# isn't one we can talk to, so close it and start again.
|
|
402
|
+
await self._kill_strays()
|
|
403
|
+
if attempt:
|
|
404
|
+
raise
|
|
405
|
+
raise AssertionError("unreachable")
|
|
406
|
+
|
|
407
|
+
async def _read_ws_url(self) -> str:
|
|
408
|
+
assert self._process and self._process.stderr
|
|
409
|
+
output = []
|
|
410
|
+
while line := await self._process.stderr.readline():
|
|
411
|
+
output.append(line)
|
|
412
|
+
if match := _WS_URL.search(line):
|
|
413
|
+
return match.group(1).decode()
|
|
414
|
+
raise _ExitedDuringStartup("Firefox exited during startup:\n" + b"".join(output).decode(errors="replace"))
|
|
415
|
+
|
|
416
|
+
async def _kill_strays(self) -> None:
|
|
417
|
+
"""Close every Firefox still running on our profile, such as one that restarted itself."""
|
|
418
|
+
if not self._profile or sys.platform == "win32":
|
|
419
|
+
return
|
|
420
|
+
ours = f"--remote-debugging-port=0 --profile {self._profile}" # only processes we launched
|
|
421
|
+
for attempt in range(50):
|
|
422
|
+
ps = await asyncio.create_subprocess_exec("ps", "-A", "-ww", "-o", "pid=,command=", stdout=subprocess.PIPE)
|
|
423
|
+
listing = (await ps.communicate())[0].decode(errors="replace")
|
|
424
|
+
pids = [int(line.split(None, 1)[0]) for line in listing.splitlines() if ours in line]
|
|
425
|
+
if not pids:
|
|
426
|
+
return
|
|
427
|
+
for pid in pids:
|
|
428
|
+
with contextlib.suppress(ProcessLookupError):
|
|
429
|
+
os.kill(pid, signal.SIGKILL if attempt >= 30 else signal.SIGTERM)
|
|
430
|
+
await asyncio.sleep(0.1)
|
|
431
|
+
|
|
432
|
+
async def _drain_stderr(self) -> None:
|
|
433
|
+
assert self._process and self._process.stderr
|
|
434
|
+
while line := await self._process.stderr.readline():
|
|
435
|
+
log.debug("firefox: %s", line.decode(errors="replace").rstrip())
|
|
436
|
+
|
|
437
|
+
async def _cleanup(self, grace: float = 10) -> None:
|
|
438
|
+
if self._process and self._process.returncode is None:
|
|
439
|
+
try:
|
|
440
|
+
await asyncio.wait_for(self._process.wait(), grace)
|
|
441
|
+
except TimeoutError:
|
|
442
|
+
self._process.kill()
|
|
443
|
+
await self._process.wait()
|
|
444
|
+
if self._stderr_task:
|
|
445
|
+
self._stderr_task.cancel()
|
|
446
|
+
await self._kill_strays()
|
|
447
|
+
for relay in self._relays:
|
|
448
|
+
relay.close()
|
|
449
|
+
self._relays.clear()
|
|
450
|
+
if self._profile and self.profile is None:
|
|
451
|
+
shutil.rmtree(self._profile, ignore_errors=True)
|
|
452
|
+
if self._downloads_dir and self.downloads is None:
|
|
453
|
+
shutil.rmtree(self._downloads_dir, ignore_errors=True)
|
|
454
|
+
self._process = None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
async def start(**kwargs: Any) -> Browser:
|
|
458
|
+
return await Browser(**kwargs).start()
|
barebidi/connection.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import itertools
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
10
|
+
from websockets.exceptions import ConnectionClosed
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
Handler = Callable[[dict], Any]
|
|
15
|
+
|
|
16
|
+
_GONE = "Firefox closed the connection: it was stopped, exited or crashed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BidiError(Exception):
|
|
20
|
+
def __init__(self, error: str, message: str, stacktrace: str | None = None):
|
|
21
|
+
super().__init__(f"{error}: {message}")
|
|
22
|
+
self.error = error
|
|
23
|
+
self.message = message
|
|
24
|
+
self.stacktrace = stacktrace
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BrowserClosedError(ConnectionError):
|
|
28
|
+
"""Firefox is gone: it was stopped, exited or crashed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Connection:
|
|
32
|
+
def __init__(self, ws: ClientConnection, timeout: float | None = None):
|
|
33
|
+
self._ws = ws
|
|
34
|
+
self.timeout = timeout # default seconds to wait for each answer; None waits forever
|
|
35
|
+
self._ids = itertools.count(1)
|
|
36
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
37
|
+
self._handlers: dict[str, list[Handler]] = defaultdict(list)
|
|
38
|
+
self._tasks: set[asyncio.Task] = set()
|
|
39
|
+
self._reader = asyncio.create_task(self._read_loop())
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
async def connect(cls, url: str, timeout: float | None = None) -> "Connection":
|
|
43
|
+
return cls(await connect(url, max_size=None), timeout)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def closed(self) -> bool:
|
|
47
|
+
return self._reader.done()
|
|
48
|
+
|
|
49
|
+
async def send(self, method: str, params: dict | None = None, timeout: float | None = None) -> dict:
|
|
50
|
+
"""Send a command and wait for its result, at most `timeout` seconds (by default, the connection's)."""
|
|
51
|
+
if self.closed:
|
|
52
|
+
raise BrowserClosedError(_GONE)
|
|
53
|
+
msg_id = next(self._ids)
|
|
54
|
+
future = asyncio.get_running_loop().create_future()
|
|
55
|
+
self._pending[msg_id] = future
|
|
56
|
+
payload = json.dumps({"id": msg_id, "method": method, "params": params or {}})
|
|
57
|
+
log.debug("-> %s", payload)
|
|
58
|
+
timeout = self.timeout if timeout is None else timeout
|
|
59
|
+
try:
|
|
60
|
+
try:
|
|
61
|
+
await self._ws.send(payload)
|
|
62
|
+
except ConnectionClosed as error:
|
|
63
|
+
raise BrowserClosedError(_GONE) from error
|
|
64
|
+
try:
|
|
65
|
+
return await asyncio.wait_for(future, timeout)
|
|
66
|
+
except TimeoutError:
|
|
67
|
+
raise TimeoutError(f"Firefox didn't answer {method} within {timeout}s") from None
|
|
68
|
+
finally:
|
|
69
|
+
self._pending.pop(msg_id, None)
|
|
70
|
+
|
|
71
|
+
def on(self, event: str, handler: Handler) -> None:
|
|
72
|
+
"""Register a local handler. `event` may be a full name or a module ("network")."""
|
|
73
|
+
self._handlers[event].append(handler)
|
|
74
|
+
|
|
75
|
+
def off(self, event: str, handler: Handler) -> None:
|
|
76
|
+
if handler in self._handlers.get(event, []):
|
|
77
|
+
self._handlers[event].remove(handler)
|
|
78
|
+
|
|
79
|
+
async def close(self) -> None:
|
|
80
|
+
await self._ws.close()
|
|
81
|
+
await asyncio.gather(self._reader, return_exceptions=True)
|
|
82
|
+
|
|
83
|
+
async def _read_loop(self) -> None:
|
|
84
|
+
try:
|
|
85
|
+
async for raw in self._ws:
|
|
86
|
+
log.debug("<- %s", raw)
|
|
87
|
+
msg = json.loads(raw)
|
|
88
|
+
if msg.get("type") == "event":
|
|
89
|
+
self._dispatch(msg["method"], msg.get("params", {}))
|
|
90
|
+
else:
|
|
91
|
+
self._resolve(msg)
|
|
92
|
+
except ConnectionClosed:
|
|
93
|
+
pass
|
|
94
|
+
finally:
|
|
95
|
+
for future in self._pending.values():
|
|
96
|
+
if not future.done():
|
|
97
|
+
future.set_exception(BrowserClosedError(_GONE))
|
|
98
|
+
|
|
99
|
+
def _resolve(self, msg: dict) -> None:
|
|
100
|
+
future = self._pending.get(msg.get("id"))
|
|
101
|
+
if future is None:
|
|
102
|
+
if msg.get("type") == "error":
|
|
103
|
+
log.warning("unmatched error: %s", msg)
|
|
104
|
+
return
|
|
105
|
+
if future.done():
|
|
106
|
+
return
|
|
107
|
+
if msg.get("type") == "error":
|
|
108
|
+
future.set_exception(BidiError(msg["error"], msg.get("message", ""), msg.get("stacktrace")))
|
|
109
|
+
else:
|
|
110
|
+
future.set_result(msg.get("result", {}))
|
|
111
|
+
|
|
112
|
+
def _dispatch(self, method: str, params: dict) -> None:
|
|
113
|
+
module = method.split(".", 1)[0]
|
|
114
|
+
for handler in [*self._handlers.get(method, ()), *self._handlers.get(module, ())]:
|
|
115
|
+
try:
|
|
116
|
+
result = handler(params)
|
|
117
|
+
except Exception:
|
|
118
|
+
log.exception("event handler for %s failed", method)
|
|
119
|
+
continue
|
|
120
|
+
if asyncio.iscoroutine(result):
|
|
121
|
+
task = asyncio.create_task(result)
|
|
122
|
+
self._tasks.add(task)
|
|
123
|
+
task.add_done_callback(self._tasks.discard)
|