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.
Files changed (95) hide show
  1. botonomus/__init__.py +63 -0
  2. botonomus/_version.py +3 -0
  3. botonomus/browser/__init__.py +38 -0
  4. botonomus/browser/arguments.py +57 -0
  5. botonomus/browser/backend.py +64 -0
  6. botonomus/browser/chrome.py +227 -0
  7. botonomus/browser/discovery.py +100 -0
  8. botonomus/browser/display.py +194 -0
  9. botonomus/browser/installer.py +588 -0
  10. botonomus/browser/process.py +77 -0
  11. botonomus/browser/release.py +353 -0
  12. botonomus/browser/signing.py +197 -0
  13. botonomus/browser/version.py +80 -0
  14. botonomus/cdp/__init__.py +37 -0
  15. botonomus/cdp/browser.py +89 -0
  16. botonomus/cdp/connection.py +205 -0
  17. botonomus/cdp/errors.py +36 -0
  18. botonomus/cdp/input.py +466 -0
  19. botonomus/cdp/locator.py +189 -0
  20. botonomus/cdp/page.py +320 -0
  21. botonomus/cdp/polling.py +49 -0
  22. botonomus/cdp/scripts.py +70 -0
  23. botonomus/cdp/websocket.py +200 -0
  24. botonomus/cli/__init__.py +10 -0
  25. botonomus/cli/__main__.py +7 -0
  26. botonomus/cli/benchmark.py +70 -0
  27. botonomus/cli/binaries.py +158 -0
  28. botonomus/cli/browse.py +73 -0
  29. botonomus/cli/common.py +261 -0
  30. botonomus/cli/consistency.py +48 -0
  31. botonomus/cli/detect.py +129 -0
  32. botonomus/cli/experiment.py +71 -0
  33. botonomus/cli/info.py +180 -0
  34. botonomus/cli/main.py +110 -0
  35. botonomus/cli/probe.py +135 -0
  36. botonomus/cli/profiles.py +148 -0
  37. botonomus/cli/proxies.py +155 -0
  38. botonomus/cli/trace.py +103 -0
  39. botonomus/config/__init__.py +15 -0
  40. botonomus/config/browser.py +204 -0
  41. botonomus/config/proxy.py +85 -0
  42. botonomus/core/__init__.py +7 -0
  43. botonomus/core/identity.py +187 -0
  44. botonomus/core/manager.py +303 -0
  45. botonomus/core/session.py +76 -0
  46. botonomus/diagnostics/__init__.py +49 -0
  47. botonomus/diagnostics/apitrace.py +324 -0
  48. botonomus/diagnostics/assets/apitrace.js +246 -0
  49. botonomus/diagnostics/assets/consistency.html +122 -0
  50. botonomus/diagnostics/assets/probe.html +73 -0
  51. botonomus/diagnostics/benchmark.py +125 -0
  52. botonomus/diagnostics/catalogue.py +289 -0
  53. botonomus/diagnostics/compare.py +45 -0
  54. botonomus/diagnostics/consistency.py +305 -0
  55. botonomus/diagnostics/detection.py +596 -0
  56. botonomus/diagnostics/experiment.py +432 -0
  57. botonomus/diagnostics/probe.py +103 -0
  58. botonomus/diagnostics/snapshots.py +80 -0
  59. botonomus/diagnostics/stats.py +23 -0
  60. botonomus/drivers/__init__.py +31 -0
  61. botonomus/drivers/base.py +51 -0
  62. botonomus/drivers/native.py +58 -0
  63. botonomus/drivers/playwright.py +91 -0
  64. botonomus/errors.py +88 -0
  65. botonomus/fingerprint/__init__.py +35 -0
  66. botonomus/fingerprint/gpus.py +142 -0
  67. botonomus/fingerprint/host.py +84 -0
  68. botonomus/fingerprint/persona.py +263 -0
  69. botonomus/fingerprint/seed.py +113 -0
  70. botonomus/fingerprint/switches.py +36 -0
  71. botonomus/human/__init__.py +56 -0
  72. botonomus/human/actionability.py +104 -0
  73. botonomus/human/clock.py +28 -0
  74. botonomus/human/config.py +154 -0
  75. botonomus/human/human.py +370 -0
  76. botonomus/human/keyboard.py +233 -0
  77. botonomus/human/page.py +215 -0
  78. botonomus/human/paths.py +79 -0
  79. botonomus/human/timing.py +119 -0
  80. botonomus/network/__init__.py +34 -0
  81. botonomus/network/forwarder.py +219 -0
  82. botonomus/network/geoip.py +426 -0
  83. botonomus/network/proxies.py +128 -0
  84. botonomus/network/tunnel.py +197 -0
  85. botonomus/profiles/__init__.py +16 -0
  86. botonomus/profiles/lease.py +100 -0
  87. botonomus/profiles/store.py +90 -0
  88. botonomus/profiles/warmup.py +260 -0
  89. botonomus/py.typed +0 -0
  90. botonomus-0.2.0.dist-info/METADATA +368 -0
  91. botonomus-0.2.0.dist-info/RECORD +95 -0
  92. botonomus-0.2.0.dist-info/WHEEL +5 -0
  93. botonomus-0.2.0.dist-info/entry_points.txt +2 -0
  94. botonomus-0.2.0.dist-info/licenses/LICENSE +21 -0
  95. 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,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.2.0"
@@ -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