botonomus 0.2.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.
- botonomus/__init__.py +63 -0
- botonomus/_version.py +3 -0
- botonomus/browser/__init__.py +38 -0
- botonomus/browser/arguments.py +57 -0
- botonomus/browser/backend.py +64 -0
- botonomus/browser/chrome.py +227 -0
- botonomus/browser/discovery.py +100 -0
- botonomus/browser/display.py +194 -0
- botonomus/browser/installer.py +588 -0
- botonomus/browser/process.py +77 -0
- botonomus/browser/release.py +353 -0
- botonomus/browser/signing.py +197 -0
- botonomus/browser/version.py +80 -0
- botonomus/cdp/__init__.py +37 -0
- botonomus/cdp/browser.py +89 -0
- botonomus/cdp/connection.py +205 -0
- botonomus/cdp/errors.py +36 -0
- botonomus/cdp/input.py +466 -0
- botonomus/cdp/locator.py +189 -0
- botonomus/cdp/page.py +320 -0
- botonomus/cdp/polling.py +49 -0
- botonomus/cdp/scripts.py +70 -0
- botonomus/cdp/websocket.py +200 -0
- botonomus/cli/__init__.py +10 -0
- botonomus/cli/__main__.py +7 -0
- botonomus/cli/benchmark.py +70 -0
- botonomus/cli/binaries.py +158 -0
- botonomus/cli/browse.py +73 -0
- botonomus/cli/common.py +261 -0
- botonomus/cli/consistency.py +48 -0
- botonomus/cli/detect.py +129 -0
- botonomus/cli/experiment.py +71 -0
- botonomus/cli/info.py +180 -0
- botonomus/cli/main.py +110 -0
- botonomus/cli/probe.py +135 -0
- botonomus/cli/profiles.py +148 -0
- botonomus/cli/proxies.py +155 -0
- botonomus/cli/trace.py +103 -0
- botonomus/config/__init__.py +15 -0
- botonomus/config/browser.py +204 -0
- botonomus/config/proxy.py +85 -0
- botonomus/core/__init__.py +7 -0
- botonomus/core/identity.py +187 -0
- botonomus/core/manager.py +303 -0
- botonomus/core/session.py +76 -0
- botonomus/diagnostics/__init__.py +49 -0
- botonomus/diagnostics/apitrace.py +324 -0
- botonomus/diagnostics/assets/apitrace.js +246 -0
- botonomus/diagnostics/assets/consistency.html +122 -0
- botonomus/diagnostics/assets/probe.html +73 -0
- botonomus/diagnostics/benchmark.py +125 -0
- botonomus/diagnostics/catalogue.py +289 -0
- botonomus/diagnostics/compare.py +45 -0
- botonomus/diagnostics/consistency.py +305 -0
- botonomus/diagnostics/detection.py +596 -0
- botonomus/diagnostics/experiment.py +432 -0
- botonomus/diagnostics/probe.py +103 -0
- botonomus/diagnostics/snapshots.py +80 -0
- botonomus/diagnostics/stats.py +23 -0
- botonomus/drivers/__init__.py +31 -0
- botonomus/drivers/base.py +51 -0
- botonomus/drivers/native.py +58 -0
- botonomus/drivers/playwright.py +91 -0
- botonomus/errors.py +88 -0
- botonomus/fingerprint/__init__.py +35 -0
- botonomus/fingerprint/gpus.py +142 -0
- botonomus/fingerprint/host.py +84 -0
- botonomus/fingerprint/persona.py +263 -0
- botonomus/fingerprint/seed.py +113 -0
- botonomus/fingerprint/switches.py +36 -0
- botonomus/human/__init__.py +56 -0
- botonomus/human/actionability.py +104 -0
- botonomus/human/clock.py +28 -0
- botonomus/human/config.py +154 -0
- botonomus/human/human.py +370 -0
- botonomus/human/keyboard.py +233 -0
- botonomus/human/page.py +215 -0
- botonomus/human/paths.py +79 -0
- botonomus/human/timing.py +119 -0
- botonomus/network/__init__.py +34 -0
- botonomus/network/forwarder.py +219 -0
- botonomus/network/geoip.py +426 -0
- botonomus/network/proxies.py +128 -0
- botonomus/network/tunnel.py +197 -0
- botonomus/profiles/__init__.py +16 -0
- botonomus/profiles/lease.py +100 -0
- botonomus/profiles/store.py +90 -0
- botonomus/profiles/warmup.py +260 -0
- botonomus/py.typed +0 -0
- botonomus-0.2.0.dist-info/METADATA +368 -0
- botonomus-0.2.0.dist-info/RECORD +95 -0
- botonomus-0.2.0.dist-info/WHEEL +5 -0
- botonomus-0.2.0.dist-info/entry_points.txt +2 -0
- botonomus-0.2.0.dist-info/licenses/LICENSE +21 -0
- botonomus-0.2.0.dist-info/top_level.txt +1 -0
botonomus/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Botonomus: stealth browser automation on real Chrome.
|
|
2
|
+
|
|
3
|
+
Typical use:
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from botonomus import Botonomus, BrowserConfig
|
|
7
|
+
|
|
8
|
+
async def main() -> None:
|
|
9
|
+
async with Botonomus(max_instances=10, config=BrowserConfig()) as bot:
|
|
10
|
+
async with bot.open(profile="acct-01") as session:
|
|
11
|
+
await session.page.goto("https://example.com")
|
|
12
|
+
|
|
13
|
+
asyncio.run(main())
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .config import BrowserConfig, ProxySpec, parse_proxy
|
|
18
|
+
from .core import Botonomus, Session
|
|
19
|
+
from .errors import (
|
|
20
|
+
BinaryDownloadError,
|
|
21
|
+
BinaryNotInstalledError,
|
|
22
|
+
BinaryVerificationError,
|
|
23
|
+
BotonomusError,
|
|
24
|
+
BrowserCleanupError,
|
|
25
|
+
BrowserStartupError,
|
|
26
|
+
BrowserUnavailableError,
|
|
27
|
+
ConfigurationError,
|
|
28
|
+
GeoLookupError,
|
|
29
|
+
GeoMismatchError,
|
|
30
|
+
ManagerClosedError,
|
|
31
|
+
PersonaUnsupportedError,
|
|
32
|
+
ProfileInUseError,
|
|
33
|
+
)
|
|
34
|
+
from .human import Human, HumanConfig, HumanPage, HumanProfile
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"PersonaUnsupportedError",
|
|
38
|
+
"GeoMismatchError",
|
|
39
|
+
"GeoLookupError",
|
|
40
|
+
"BinaryVerificationError",
|
|
41
|
+
"BinaryNotInstalledError",
|
|
42
|
+
"BinaryDownloadError",
|
|
43
|
+
"BinaryDownloadError",
|
|
44
|
+
"BinaryNotInstalledError",
|
|
45
|
+
"BinaryVerificationError",
|
|
46
|
+
"Botonomus",
|
|
47
|
+
"BotonomusError",
|
|
48
|
+
"BrowserCleanupError",
|
|
49
|
+
"BrowserConfig",
|
|
50
|
+
"BrowserStartupError",
|
|
51
|
+
"BrowserUnavailableError",
|
|
52
|
+
"ConfigurationError",
|
|
53
|
+
"Human",
|
|
54
|
+
"HumanConfig",
|
|
55
|
+
"HumanPage",
|
|
56
|
+
"HumanProfile",
|
|
57
|
+
"ManagerClosedError",
|
|
58
|
+
"ProfileInUseError",
|
|
59
|
+
"ProxySpec",
|
|
60
|
+
"Session",
|
|
61
|
+
"__version__",
|
|
62
|
+
"parse_proxy",
|
|
63
|
+
]
|
botonomus/_version.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Browser executables, processes, installed builds and the Chrome backend."""
|
|
2
|
+
|
|
3
|
+
from .arguments import launch_arguments
|
|
4
|
+
from .backend import Backend, BrowserHandle
|
|
5
|
+
from .chrome import ChromeBackend, ChromeHandle
|
|
6
|
+
from .discovery import (
|
|
7
|
+
BUILD_MARKER,
|
|
8
|
+
find_botonomus_chromium,
|
|
9
|
+
find_chrome,
|
|
10
|
+
is_botonomus_build,
|
|
11
|
+
is_testing_build,
|
|
12
|
+
)
|
|
13
|
+
from .display import VirtualDisplay, container_flags, needs_virtual_display
|
|
14
|
+
from .installer import InstalledBinary, find_installed, install, installed_binaries, uninstall
|
|
15
|
+
from .version import executable_sha256, executable_version
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BUILD_MARKER",
|
|
19
|
+
"Backend",
|
|
20
|
+
"BrowserHandle",
|
|
21
|
+
"ChromeBackend",
|
|
22
|
+
"ChromeHandle",
|
|
23
|
+
"InstalledBinary",
|
|
24
|
+
"VirtualDisplay",
|
|
25
|
+
"container_flags",
|
|
26
|
+
"executable_sha256",
|
|
27
|
+
"executable_version",
|
|
28
|
+
"find_botonomus_chromium",
|
|
29
|
+
"find_chrome",
|
|
30
|
+
"find_installed",
|
|
31
|
+
"install",
|
|
32
|
+
"installed_binaries",
|
|
33
|
+
"is_botonomus_build",
|
|
34
|
+
"is_testing_build",
|
|
35
|
+
"launch_arguments",
|
|
36
|
+
"needs_virtual_display",
|
|
37
|
+
"uninstall",
|
|
38
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Command-line construction for a native, unobservable browser launch."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..config import BrowserConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def launch_arguments(
|
|
10
|
+
executable: Path,
|
|
11
|
+
profile_path: Path,
|
|
12
|
+
port: int,
|
|
13
|
+
config: BrowserConfig,
|
|
14
|
+
*,
|
|
15
|
+
proxy_server: str | None = None,
|
|
16
|
+
platform_args: Sequence[str] = (),
|
|
17
|
+
) -> list[str]:
|
|
18
|
+
"""Build the full browser command line.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
executable: Browser executable.
|
|
22
|
+
profile_path: Dedicated ``--user-data-dir``.
|
|
23
|
+
port: Explicit, nonzero loopback debugging port.
|
|
24
|
+
config: Session configuration.
|
|
25
|
+
proxy_server: Credential-free ``--proxy-server`` value, if a proxy is used.
|
|
26
|
+
Credentials are handled by the loopback forwarder and never appear here.
|
|
27
|
+
platform_args: Flags the platform needs (container flags, virtual-display
|
|
28
|
+
window geometry), placed before the user's ``extra_args``.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Arguments in launch order, ending with the initial ``about:blank`` page.
|
|
32
|
+
"""
|
|
33
|
+
arguments = [
|
|
34
|
+
str(executable),
|
|
35
|
+
f"--user-data-dir={profile_path}",
|
|
36
|
+
f"--remote-debugging-port={port}",
|
|
37
|
+
"--remote-debugging-address=127.0.0.1",
|
|
38
|
+
"--no-first-run",
|
|
39
|
+
"--no-default-browser-check",
|
|
40
|
+
]
|
|
41
|
+
if config.headless:
|
|
42
|
+
arguments.append("--headless=new")
|
|
43
|
+
if config.render_when_occluded:
|
|
44
|
+
arguments.append("--disable-backgrounding-occluded-windows")
|
|
45
|
+
if config.locale is not None:
|
|
46
|
+
arguments += [f"--lang={config.locale}", f"--accept-lang={config.locale}"]
|
|
47
|
+
arguments += platform_args
|
|
48
|
+
arguments += config.extra_args
|
|
49
|
+
arguments += config.persona_switches
|
|
50
|
+
if proxy_server is not None:
|
|
51
|
+
# WebRTC UDP would otherwise bypass the proxy and reveal the real address.
|
|
52
|
+
arguments += [
|
|
53
|
+
f"--proxy-server={proxy_server}",
|
|
54
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
55
|
+
]
|
|
56
|
+
arguments.append("about:blank")
|
|
57
|
+
return arguments
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The contract between the session manager and a browser backend.
|
|
2
|
+
|
|
3
|
+
The backend owns operating-system processes; the manager owns admission and
|
|
4
|
+
profile leases. A backend may only report successful cleanup once it has confirmed
|
|
5
|
+
that everything it started has stopped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from ..config import BrowserConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BrowserHandle(Protocol):
|
|
15
|
+
"""One running browser attached to a dedicated profile."""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def context(self) -> Any:
|
|
19
|
+
"""The browser context (native ``Context`` or a Playwright ``BrowserContext``)."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def page(self) -> Any:
|
|
24
|
+
"""The initial page (native ``Page`` or a Playwright ``Page``)."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
async def close(self) -> None:
|
|
28
|
+
"""Stop the browser.
|
|
29
|
+
|
|
30
|
+
Returns only after every owned process is confirmed stopped.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
BrowserCleanupError: If that cannot be confirmed.
|
|
34
|
+
"""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Backend(Protocol):
|
|
39
|
+
"""Starts browsers and guarantees their shutdown."""
|
|
40
|
+
|
|
41
|
+
async def start(self) -> None:
|
|
42
|
+
"""Prepare shared resources such as a driver runtime. Idempotent."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
async def launch(self, profile_path: Path, config: BrowserConfig) -> BrowserHandle:
|
|
46
|
+
"""Launch a browser for ``profile_path`` and attach to it.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
BrowserUnavailableError: If no executable is available.
|
|
50
|
+
BrowserStartupError: If the process or attachment fails; anything started
|
|
51
|
+
has been cleaned up.
|
|
52
|
+
BrowserCleanupError: If a failed launch left processes that could not be
|
|
53
|
+
confirmed stopped.
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
async def close(self) -> None:
|
|
58
|
+
"""Stop every browser this backend owns and release shared resources.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
BrowserCleanupError: If any owned browser could not be confirmed stopped;
|
|
62
|
+
ownership is retained.
|
|
63
|
+
"""
|
|
64
|
+
...
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Chrome backend: native process launch, then loopback CDP attachment.
|
|
2
|
+
|
|
3
|
+
The browser starts exactly as a user would start it, plus a dedicated profile and
|
|
4
|
+
an explicit loopback debugging port. The driver attaches afterwards without
|
|
5
|
+
applying context overrides, so pages observe an ordinary browser.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import psutil
|
|
16
|
+
|
|
17
|
+
from ..config import BrowserConfig, DriverName
|
|
18
|
+
from ..drivers import Attachment, Driver, create_driver
|
|
19
|
+
from ..errors import BrowserCleanupError, BrowserStartupError
|
|
20
|
+
from ..network import ProxyForwarder
|
|
21
|
+
from .arguments import launch_arguments
|
|
22
|
+
from .discovery import find_chrome, is_testing_build
|
|
23
|
+
from .display import VirtualDisplay, host_container_flags, needs_virtual_display
|
|
24
|
+
from .process import available_port, descendants, owns_listener, stop_owned
|
|
25
|
+
|
|
26
|
+
_log = logging.getLogger("botonomus")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChromeHandle:
|
|
30
|
+
"""One launched browser process, its attachment and its proxy forwarder.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
process: The browser's root process.
|
|
34
|
+
timeout: Seconds for the whole close sequence (graceful, then forced).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, process: asyncio.subprocess.Process, timeout: float) -> None:
|
|
38
|
+
self.process = process
|
|
39
|
+
self.identity = psutil.Process(process.pid)
|
|
40
|
+
self.identity.create_time() # Cache identity before any later PID reuse.
|
|
41
|
+
self.attachment: Attachment | None = None
|
|
42
|
+
self.forwarder: ProxyForwarder | None = None
|
|
43
|
+
self._timeout = timeout
|
|
44
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def context(self) -> Any:
|
|
48
|
+
"""The driver's browser context.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
BrowserStartupError: If the driver has not attached yet.
|
|
52
|
+
"""
|
|
53
|
+
if self.attachment is None:
|
|
54
|
+
raise BrowserStartupError("Browser context is not ready")
|
|
55
|
+
return self.attachment.context
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def page(self) -> Any:
|
|
59
|
+
"""The driver's initial page.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
BrowserStartupError: If the driver has not attached yet.
|
|
63
|
+
"""
|
|
64
|
+
if self.attachment is None:
|
|
65
|
+
raise BrowserStartupError("Browser page is not ready")
|
|
66
|
+
return self.attachment.page
|
|
67
|
+
|
|
68
|
+
async def close(self) -> None:
|
|
69
|
+
"""Close gracefully, then terminate owned processes. Idempotent and shielded.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
BrowserCleanupError: If owned processes could not be confirmed stopped.
|
|
73
|
+
"""
|
|
74
|
+
if self._close_task is None:
|
|
75
|
+
self._close_task = asyncio.create_task(self._close())
|
|
76
|
+
await asyncio.shield(self._close_task)
|
|
77
|
+
|
|
78
|
+
async def _close(self) -> None:
|
|
79
|
+
children = await asyncio.to_thread(descendants, self.identity)
|
|
80
|
+
try:
|
|
81
|
+
if self.attachment is not None:
|
|
82
|
+
with suppress(TimeoutError):
|
|
83
|
+
async with asyncio.timeout(self._timeout / 2):
|
|
84
|
+
await self.attachment.request_close()
|
|
85
|
+
await self.process.wait()
|
|
86
|
+
await asyncio.to_thread(stop_owned, [*children, self.identity], self._timeout / 2)
|
|
87
|
+
await asyncio.wait_for(self.process.wait(), self._timeout / 2)
|
|
88
|
+
if self.attachment is not None:
|
|
89
|
+
with suppress(TimeoutError):
|
|
90
|
+
await asyncio.wait_for(self.attachment.disconnect(), self._timeout / 2)
|
|
91
|
+
if self.forwarder is not None:
|
|
92
|
+
await asyncio.wait_for(self.forwarder.close(), self._timeout / 2)
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
raise BrowserCleanupError("Could not confirm browser process cleanup") from exc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ChromeBackend:
|
|
98
|
+
"""Launches Chrome (or a named Chromium build) and attaches a driver.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
driver: A driver name, or a [`Driver`][botonomus.drivers.Driver] instance.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, driver: DriverName | Driver = "native") -> None:
|
|
105
|
+
self._driver: Driver = create_driver(driver) if isinstance(driver, str) else driver
|
|
106
|
+
self._started = False
|
|
107
|
+
self._handles: set[ChromeHandle] = set()
|
|
108
|
+
self._display: VirtualDisplay | None = None
|
|
109
|
+
self._display_lock = asyncio.Lock()
|
|
110
|
+
|
|
111
|
+
async def start(self) -> None:
|
|
112
|
+
"""Start the driver runtime. Idempotent.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
BrowserStartupError: If the driver cannot start.
|
|
116
|
+
"""
|
|
117
|
+
if not self._started:
|
|
118
|
+
await self._driver.start()
|
|
119
|
+
self._started = True
|
|
120
|
+
|
|
121
|
+
async def launch(self, profile_path: Path, config: BrowserConfig) -> ChromeHandle:
|
|
122
|
+
"""Launch a browser for ``profile_path`` and attach the driver.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
BrowserUnavailableError: If no executable is found.
|
|
126
|
+
ConfigurationError: If ``virtual_display=True`` is requested off Linux.
|
|
127
|
+
BrowserStartupError: If launch, attachment or the virtual display fails or
|
|
128
|
+
times out.
|
|
129
|
+
BrowserCleanupError: If a failed launch could not be cleaned up.
|
|
130
|
+
"""
|
|
131
|
+
executable = find_chrome(config.executable_path)
|
|
132
|
+
if is_testing_build(executable):
|
|
133
|
+
_log.warning("testing_build_executable")
|
|
134
|
+
if not self._started:
|
|
135
|
+
raise BrowserStartupError("Backend is not started")
|
|
136
|
+
# Outside the try below, so display errors keep their own message and type.
|
|
137
|
+
platform_args, env = await self._platform(config)
|
|
138
|
+
port = available_port()
|
|
139
|
+
handle: ChromeHandle | None = None
|
|
140
|
+
forwarder: ProxyForwarder | None = None
|
|
141
|
+
try:
|
|
142
|
+
proxy_server = None
|
|
143
|
+
if (proxy := config.proxy_spec) is not None:
|
|
144
|
+
proxy_server = proxy.server
|
|
145
|
+
if proxy.has_credentials:
|
|
146
|
+
forwarder = ProxyForwarder(proxy)
|
|
147
|
+
await forwarder.start()
|
|
148
|
+
proxy_server = forwarder.server
|
|
149
|
+
arguments = launch_arguments(
|
|
150
|
+
executable,
|
|
151
|
+
profile_path,
|
|
152
|
+
port,
|
|
153
|
+
config,
|
|
154
|
+
proxy_server=proxy_server,
|
|
155
|
+
platform_args=platform_args,
|
|
156
|
+
)
|
|
157
|
+
process = await asyncio.create_subprocess_exec(
|
|
158
|
+
*arguments,
|
|
159
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
160
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
161
|
+
env=env,
|
|
162
|
+
)
|
|
163
|
+
handle = ChromeHandle(process, config.close_timeout)
|
|
164
|
+
handle.forwarder, forwarder = forwarder, None
|
|
165
|
+
self._handles.add(handle)
|
|
166
|
+
async with asyncio.timeout(config.launch_timeout):
|
|
167
|
+
while not await asyncio.to_thread(owns_listener, handle.identity, port):
|
|
168
|
+
if process.returncode is not None:
|
|
169
|
+
raise BrowserStartupError("Browser exited before its endpoint was ready")
|
|
170
|
+
await asyncio.sleep(0.05)
|
|
171
|
+
handle.attachment = await self._driver.attach(port, config.launch_timeout)
|
|
172
|
+
return handle
|
|
173
|
+
except BaseException as exc:
|
|
174
|
+
if handle is not None:
|
|
175
|
+
await handle.close()
|
|
176
|
+
self._handles.discard(handle)
|
|
177
|
+
if forwarder is not None:
|
|
178
|
+
await forwarder.close()
|
|
179
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
180
|
+
raise
|
|
181
|
+
raise BrowserStartupError("Browser launch or attachment failed") from exc
|
|
182
|
+
|
|
183
|
+
async def _platform(
|
|
184
|
+
self, config: BrowserConfig
|
|
185
|
+
) -> tuple[tuple[str, ...], dict[str, str] | None]:
|
|
186
|
+
"""Container flags, plus the shared virtual display when ``config`` needs one.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Extra launch flags and the environment for the browser process (``None``
|
|
190
|
+
inherits this process's environment).
|
|
191
|
+
"""
|
|
192
|
+
flags = host_container_flags()
|
|
193
|
+
if not needs_virtual_display(config):
|
|
194
|
+
return flags, None
|
|
195
|
+
async with self._display_lock:
|
|
196
|
+
if self._display is None:
|
|
197
|
+
display = VirtualDisplay()
|
|
198
|
+
await display.start()
|
|
199
|
+
self._display = display
|
|
200
|
+
assert self._display.display is not None
|
|
201
|
+
geometry = (
|
|
202
|
+
# Chrome would otherwise prefer a Wayland session it inherited.
|
|
203
|
+
"--ozone-platform=x11",
|
|
204
|
+
"--window-position=0,0",
|
|
205
|
+
f"--window-size={self._display.width},{self._display.height}",
|
|
206
|
+
)
|
|
207
|
+
env = {k: v for k, v in os.environ.items() if k != "WAYLAND_DISPLAY"}
|
|
208
|
+
env["DISPLAY"] = self._display.display
|
|
209
|
+
return (*flags, *geometry), env
|
|
210
|
+
|
|
211
|
+
async def close(self) -> None:
|
|
212
|
+
"""Stop every owned browser, then the virtual display and the driver runtime.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
BrowserCleanupError: If any owned browser could not be stopped; the driver
|
|
216
|
+
is left running and ownership retained. The virtual display is stopped
|
|
217
|
+
regardless, so no Xvfb server outlives the manager.
|
|
218
|
+
"""
|
|
219
|
+
results = await asyncio.gather(*(h.close() for h in self._handles), return_exceptions=True)
|
|
220
|
+
if self._display is not None:
|
|
221
|
+
display, self._display = self._display, None
|
|
222
|
+
await display.stop()
|
|
223
|
+
if any(isinstance(result, BaseException) for result in results):
|
|
224
|
+
raise BrowserCleanupError("Some owned browsers could not be stopped")
|
|
225
|
+
self._handles.clear()
|
|
226
|
+
await self._driver.stop()
|
|
227
|
+
self._started = False
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Locating a browser executable.
|
|
2
|
+
|
|
3
|
+
Unbranded Chromium is never a silent substitute for Chrome: `find_chrome` only
|
|
4
|
+
returns Google Chrome unless the caller names an executable. Botonomus Chromium
|
|
5
|
+
builds are found separately and identified by a marker file shipped beside them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
from ..errors import BrowserUnavailableError
|
|
15
|
+
from . import installer
|
|
16
|
+
|
|
17
|
+
BUILD_MARKER: Final = "botonomus-build.json"
|
|
18
|
+
"""File placed next to the executable of every Botonomus Chromium build."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_botonomus_build(executable: Path) -> bool:
|
|
22
|
+
"""Whether ``executable`` is a Botonomus Chromium build.
|
|
23
|
+
|
|
24
|
+
True when the build marker sits next to the executable, or the executable lives
|
|
25
|
+
inside a verified install made by
|
|
26
|
+
[`botonomus.browser.install`][botonomus.browser.install]. Blocking.
|
|
27
|
+
"""
|
|
28
|
+
if (executable.parent / BUILD_MARKER).is_file():
|
|
29
|
+
return True
|
|
30
|
+
return any((parent / installer.INSTALL_RECORD).is_file() for parent in executable.parents[:3])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_chrome(explicit: Path | None = None) -> Path:
|
|
34
|
+
"""Return the browser executable to launch.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
explicit: A caller-supplied executable. When given, it is the only candidate.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The resolved path of an existing executable.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
BrowserUnavailableError: If ``explicit`` does not exist, or no installed Google
|
|
44
|
+
Chrome is found in the standard locations or on ``PATH``.
|
|
45
|
+
"""
|
|
46
|
+
if explicit is not None:
|
|
47
|
+
if explicit.is_file():
|
|
48
|
+
return explicit
|
|
49
|
+
raise BrowserUnavailableError("Configured browser executable does not exist")
|
|
50
|
+
for candidate in _chrome_candidates():
|
|
51
|
+
if candidate.is_file():
|
|
52
|
+
return candidate.resolve()
|
|
53
|
+
raise BrowserUnavailableError(
|
|
54
|
+
"Chrome not found; set BrowserConfig(executable_path=...) explicitly"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_botonomus_chromium(
|
|
59
|
+
version: str | None = None, *, cache_dir: Path | None = None
|
|
60
|
+
) -> Path | None:
|
|
61
|
+
"""Return the executable of an installed Botonomus Chromium build, if any.
|
|
62
|
+
|
|
63
|
+
Not consulted by `find_chrome`; callers decide the precedence between an
|
|
64
|
+
installed Botonomus build and Google Chrome.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
version: A specific build version, or ``None`` for the newest installed.
|
|
68
|
+
cache_dir: Botonomus data directory; ``None`` uses the default location.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The executable path, or ``None`` if no matching build is installed.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ConfigurationError: If ``version`` is malformed.
|
|
75
|
+
"""
|
|
76
|
+
return installer.find_installed(version, cache_dir=cache_dir)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_testing_build(executable: Path) -> bool:
|
|
80
|
+
"""Whether ``executable`` looks like Chrome for Testing or a Playwright download.
|
|
81
|
+
|
|
82
|
+
Those builds show a permanent "only for automated testing" infobar, which also
|
|
83
|
+
shrinks the viewport, and are not what ordinary users run.
|
|
84
|
+
"""
|
|
85
|
+
text = str(executable).replace("\\", "/").lower()
|
|
86
|
+
return "ms-playwright" in text or "chrome for testing" in text or "chrome-win64/" in text
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _chrome_candidates() -> list[Path]:
|
|
90
|
+
candidates: list[Path] = []
|
|
91
|
+
if sys.platform == "win32":
|
|
92
|
+
for key in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
|
93
|
+
if root := os.environ.get(key):
|
|
94
|
+
candidates.append(Path(root) / "Google/Chrome/Application/chrome.exe")
|
|
95
|
+
elif sys.platform == "darwin":
|
|
96
|
+
candidates.append(Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"))
|
|
97
|
+
for name in ("google-chrome", "google-chrome-stable", "chrome"):
|
|
98
|
+
if found := shutil.which(name):
|
|
99
|
+
candidates.append(Path(found))
|
|
100
|
+
return candidates
|