botmask 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.
botmask/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """botmask — containerized human-behavior browser automation toolkit."""
botmask/_boot.py ADDED
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ botmask bootstrap — launch Brave with CDP exposed, hold until killed.
4
+
5
+ Called by start.sh after deps are installed. Does NOT run pip install
6
+ itself — that belongs in start.sh or the Dockerfile.
7
+ """
8
+ import json
9
+ import os
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ # Ensure package is importable when run directly
16
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "src"))
17
+
18
+
19
+ def main():
20
+ from botmask.config import get_browser_config, get_browser_args
21
+
22
+ cfg = get_browser_config()
23
+ args = get_browser_args()
24
+
25
+ # Ensure user data dir exists
26
+ data_dir = Path(cfg["user_data_dir"])
27
+ data_dir.mkdir(parents=True, exist_ok=True)
28
+
29
+ # Remove stale lock files from previous unclean shutdowns
30
+ for lock in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
31
+ (data_dir / lock).unlink(missing_ok=True)
32
+
33
+ # Disable P3A telemetry in the profile's Local State
34
+ local_state = data_dir / "Local State"
35
+ if local_state.exists():
36
+ try:
37
+ state = json.loads(local_state.read_text())
38
+ except (json.JSONDecodeError, OSError):
39
+ state = {}
40
+ else:
41
+ state = {}
42
+ state.setdefault("brave", {})
43
+ state["brave"].setdefault("p3a", {})["enabled"] = False
44
+ state["brave"].setdefault("stats", {})["reporting_enabled"] = False
45
+ local_state.write_text(json.dumps(state))
46
+
47
+ cdp_port = os.getenv("BROWSER_CDP_PORT", "9222")
48
+ cdp_host = os.getenv("BROWSER_CDP_HOST", "0.0.0.0")
49
+
50
+ cmd = [cfg["executable_path"]] + args + [
51
+ f"--remote-debugging-port={cdp_port}",
52
+ f"--remote-debugging-address={cdp_host}",
53
+ f"--user-data-dir={cfg['user_data_dir']}",
54
+ ]
55
+
56
+ proc = subprocess.Popen(cmd)
57
+ print(f"[botmask] Brave launched (pid={proc.pid}), CDP on {cdp_host}:{cdp_port}",
58
+ flush=True)
59
+
60
+ # Forward signals to Brave so docker stop works cleanly
61
+ signal.signal(signal.SIGTERM, lambda *_: proc.terminate())
62
+ signal.signal(signal.SIGINT, lambda *_: proc.terminate())
63
+
64
+ try:
65
+ sys.exit(proc.wait())
66
+ except KeyboardInterrupt:
67
+ proc.terminate()
68
+ sys.exit(0)
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
botmask/a11y.py ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Accessibility Snapshot Module — numbered interactive elements for AI targeting.
4
+
5
+ The AI never computes pixel coordinates. It picks an element by index from a
6
+ numbered snapshot; this module resolves index -> locator -> bounding_box.
7
+
8
+ Usage:
9
+ from botmask.a11y import snapshot_interactives, get_locator
10
+
11
+ items = snapshot_interactives(page) # [{"index": 0, "tag": "a", ...}, ...]
12
+ locator = get_locator(page, 42) # -> page.locator(...).nth(42)
13
+ """
14
+
15
+ from typing import Dict, List, Optional
16
+
17
+ from patchright.sync_api import Locator, Page
18
+
19
+ # Must match the JS in snapshot_interactives so index -> locator is stable.
20
+ INTERACTIVE_SELECTOR = (
21
+ "a, button, input, select, textarea, summary, "
22
+ "[role=button], [role=link], [role=tab], [role=menuitem], "
23
+ "[contenteditable=true], [onclick], [data-testid]"
24
+ )
25
+
26
+ MAX_ITEMS = 300
27
+
28
+
29
+ def snapshot_interactives(page: Page, limit: int = MAX_ITEMS) -> List[Dict]:
30
+ """Collect interactive elements in document order, numbered from 0.
31
+
32
+ Indices are positions in the full (unfiltered) INTERACTIVE_SELECTOR list,
33
+ so ``get_locator(page, index)`` always resolves to the same element even
34
+ after the page changes. Hidden elements are marked ``visible: false`` and
35
+ kept in the list only to preserve stable numbering.
36
+
37
+ Args:
38
+ page: The Playwright page.
39
+ limit: Maximum number of entries to return.
40
+
41
+ Returns:
42
+ List of dicts with index, tag, role, type, name, href, visible, box.
43
+ """
44
+ items = page.evaluate(
45
+ """(arg) => {
46
+ const sel = arg.sel;
47
+ const maxItems = arg.maxItems;
48
+ const els = Array.from(document.querySelectorAll(sel));
49
+ const isVisible = (el) => {
50
+ const r = el.getBoundingClientRect();
51
+ if (!r.width && !r.height) return false;
52
+ const s = getComputedStyle(el);
53
+ if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') return false;
54
+ return true;
55
+ };
56
+ const name = (el) => {
57
+ let t = (el.getAttribute('aria-label')
58
+ || el.value
59
+ || el.placeholder
60
+ || el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 80);
61
+ return t;
62
+ };
63
+ const box = (el) => {
64
+ const r = el.getBoundingClientRect();
65
+ return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
66
+ };
67
+ const out = [];
68
+ for (let i = 0; i < els.length; i++) {
69
+ const el = els[i];
70
+ const b = box(el);
71
+ if (out.length >= maxItems) break;
72
+ out.push({
73
+ index: i,
74
+ tag: el.tagName.toLowerCase(),
75
+ role: el.getAttribute('role') || '',
76
+ type: el.getAttribute('type') || '',
77
+ name: name(el),
78
+ href: el.getAttribute('href') || '',
79
+ visible: isVisible(el),
80
+ box: b,
81
+ });
82
+ }
83
+ return out;
84
+ }""",
85
+ {"sel": INTERACTIVE_SELECTOR, "maxItems": limit},
86
+ )
87
+ return items
88
+
89
+
90
+ def get_locator(page: Page, index: int) -> Locator:
91
+ """Resolve a snapshot index to a locator on the same INTERACTIVE_SELECTOR list."""
92
+ return page.locator(INTERACTIVE_SELECTOR).nth(index)
93
+
94
+
95
+ def resolve_target(page: Page, index: int, require_visible: bool = True) -> Optional[Dict]:
96
+ """Resolve index to (locator, box). Returns None when the element is gone/hidden.
97
+
98
+ Args:
99
+ page: The Playwright page.
100
+ index: Snapshot index.
101
+ require_visible: Reject hidden elements (no bounding box).
102
+
103
+ Returns:
104
+ dict with "locator", "box" and "center", or None.
105
+ """
106
+ locator = get_locator(page, index)
107
+ box = locator.bounding_box()
108
+ if not box or (require_visible and (box["width"] == 0 or box["height"] == 0)):
109
+ return None
110
+ return {
111
+ "locator": locator,
112
+ "box": box,
113
+ "center": (box["x"] + box["width"] / 2, box["y"] + box["height"] / 2),
114
+ }
botmask/config.py ADDED
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Browser Configuration Module - botmask
4
+
5
+ Centralized browser configuration for the humanized browser automation toolkit.
6
+ All browser options are sourced from .env file (inherited from the jobs project).
7
+
8
+ Usage:
9
+ from botmask.config import get_browser_config, get_launch_options
10
+
11
+ config = get_browser_config()
12
+ launch_options = get_launch_options()
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import random
18
+ import shlex
19
+ import tomllib # stdlib (Python 3.11+); falls back gracefully
20
+ from pathlib import Path
21
+ from typing import Optional
22
+ from dotenv import load_dotenv
23
+
24
+ load_dotenv()
25
+
26
+
27
+ def get_env(key: str, default=None):
28
+ """Get environment variable or default."""
29
+ return os.getenv(key, default)
30
+
31
+
32
+ def _find_toml() -> dict:
33
+ """Look for a botmask.toml config file.
34
+
35
+ Search order:
36
+ 1. ``BOTMASK_CONFIG`` env var (absolute path)
37
+ 2. ``botmask.toml`` in the current working directory
38
+ 3. ``~/.config/botmask/botmask.toml``
39
+ Returns an empty dict if none is found.
40
+ """
41
+ # 1. explicit path
42
+ explicit = os.getenv("BOTMASK_CONFIG")
43
+ if explicit and Path(explicit).is_file():
44
+ with open(explicit, "rb") as f:
45
+ return tomllib.load(f)
46
+
47
+ # 2. cwd
48
+ cwd = Path("botmask.toml")
49
+ if cwd.is_file():
50
+ with open(cwd, "rb") as f:
51
+ return tomllib.load(f)
52
+
53
+ # 3. XDG config dir
54
+ xdg = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "botmask" / "botmask.toml"
55
+ if xdg.is_file():
56
+ with open(xdg, "rb") as f:
57
+ return tomllib.load(f)
58
+
59
+ return {}
60
+
61
+
62
+ def _env_overrides() -> dict:
63
+ """Read ``BOTMASK_*`` env vars and map them into config keys.
64
+
65
+ This namespace is **isolated** from the host's generic ``BROWSER_*`` /
66
+ ``DISPLAY`` etc., so embedding botmask in another project never collides.
67
+
68
+ Supported mappings (env key → config dict key):
69
+ BOTMASK_HEADLESS → config["headless"]
70
+ BOTMASK_LOCALE → config["locale"]
71
+ BOTMASK_TIMEZONE → config["timezone"]
72
+ BOTMASK_NAVIGATION_TIMEOUT → config["navigation_timeout"]
73
+ BOTMASK_IMPLICIT_WAIT → config["implicit_wait"]
74
+ BOTMASK_DELAY_MIN → config["human_delay_min"]
75
+ BOTMASK_DELAY_MAX → config["human_delay_max"]
76
+ """
77
+ overrides = {}
78
+ mapping = {
79
+ "HEADLESS": "headless",
80
+ "LOCALE": "locale",
81
+ "TIMEZONE": "timezone",
82
+ "NAVIGATION_TIMEOUT": "navigation_timeout",
83
+ "IMPLICIT_WAIT": "implicit_wait",
84
+ "DELAY_MIN": "human_delay_min",
85
+ "DELAY_MAX": "human_delay_max",
86
+ }
87
+ for env_key, cfg_key in mapping.items():
88
+ val = os.getenv("BOTMASK_%s" % env_key)
89
+ if val is not None:
90
+ # Convert booleans / ints / floats where sensible
91
+ if cfg_key == "headless":
92
+ overrides[cfg_key] = val.lower() in ("true", "1", "yes")
93
+ elif cfg_key in ("navigation_timeout", "implicit_wait"):
94
+ try:
95
+ overrides[cfg_key] = int(val)
96
+ except ValueError:
97
+ pass
98
+ elif cfg_key in ("human_delay_min", "human_delay_max"):
99
+ try:
100
+ overrides[cfg_key] = float(val)
101
+ except ValueError:
102
+ pass
103
+ else:
104
+ overrides[cfg_key] = val
105
+ return overrides
106
+
107
+
108
+ def get_browser_config() -> dict:
109
+ """
110
+ Get browser configuration, with TOML as the primary source.
111
+
112
+ Precedence (highest first):
113
+
114
+ 1. ``botmask.toml`` config file — all browser/profile/cdp/behavior settings.
115
+ If a key is present in TOML, it is used exclusively; the env vars listed
116
+ below are *only* used for the display vars noted below.
117
+
118
+ 2. Environment variables — **only** the display vars ``DISPLAY`` and
119
+ ``WAYLAND_DISPLAY`` are read from the host environment; they override
120
+ any corresponding TOML values so that a running container / bare-metal
121
+ setup can still locate its display surface.
122
+
123
+ 3. Built‑in defaults — used when a TOML key is absent and the env var
124
+ is also absent. These defaults ensure the project starts immediately
125
+ without any config file or env var.
126
+
127
+ Keys that always come from the environment (never from TOML):
128
+
129
+ - ``display`` — X11 display server address (e.g. ``:0``)
130
+ - ``wayland_display`` — Wayland display socket name
131
+ (e.g. ``wayland-0``)
132
+
133
+ All other keys (browser paths, profile, timeouts, human delays, cdp
134
+ settings, etc.) are driven exclusively by the TOML file or the project
135
+ built‑in defaults.
136
+
137
+ Returns:
138
+ Dictionary with all browser configuration options.
139
+ """
140
+
141
+ # ---------- 1. Load TOML config file ----------
142
+ toml = _find_toml() # may be {}
143
+ if toml:
144
+ merged = dict(toml)
145
+ else:
146
+ merged = {}
147
+
148
+ # ---------- 2. Override display vars from the environment ----------
149
+ # These MUST come from the host environment; never from TOML.
150
+ merged["display"] = os.getenv("DISPLAY")
151
+ merged["wayland_display"] = os.getenv("WAYLAND_DISPLAY")
152
+
153
+ # ---------- 3. Ensure critical keys have sane defaults ----------
154
+ # If the TOML file is missing or a key is absent, fall back to defaults
155
+ # only for keys that have no reasonable alternative source.
156
+ defaults = {
157
+ "user_data_dir": "/app/browser_data",
158
+ "executable_path": "/usr/bin/brave-browser",
159
+ "headless": False,
160
+ "locale": "es-VE",
161
+ "timezone": "America/Caracas",
162
+ "latitude": 10.4806,
163
+ "longitude": -66.9036,
164
+ "navigation_timeout": 30,
165
+ "implicit_wait": 10,
166
+ "human_delay_min": 1.0,
167
+ "human_delay_max": 3.0,
168
+ }
169
+ for k, v in defaults.items():
170
+ merged.setdefault(k, v)
171
+
172
+ # --- BOTMASK_* env overrides are no longer the primary mechanism;
173
+ # the TOML file is. Keep a tiny namespace‑safe fallback so that a user
174
+ # can quickly toggle a single flag at the shell without editing a file:
175
+ tiny_over = {}
176
+ for key in ("headless", "locale", "timezone", "navigation_timeout",
177
+ "implicit_wait", "human_delay_min", "human_delay_max"):
178
+ val = os.getenv(f"BOTMASK_{key.upper()}")
179
+ if val is not None:
180
+ tiny_over[key] = val
181
+ merged.update(tiny_over) # BOTMASK_* still wins over defaults, but
182
+ # TOML keys already set are preserved because
183
+ # dict.update() only inserts missing keys when
184
+ # using dict.setdefault — but update() overrides.
185
+ # Actually, to keep TOML as supreme, we should NOT update with tiny_over
186
+ # if the key already exists in merged from TOML. Let's do it properly:
187
+ for k, v in tiny_over.items():
188
+ if k not in merged or merged[k] is None:
189
+ merged[k] = v
190
+
191
+ return merged
192
+
193
+
194
+ def get_browser_args() -> list:
195
+ """
196
+ Get browser launch arguments from BROWSER_ARGS env var.
197
+
198
+ Enhanced with anti-detection flags.
199
+
200
+ Returns:
201
+ List of browser arguments
202
+ """
203
+ args_str = get_env("BROWSER_ARGS", "")
204
+
205
+ if not args_str:
206
+ args = [
207
+ "--disable-blink-features=AutomationControlled",
208
+ "--disable-dev-shm-usage",
209
+ "--no-sandbox",
210
+ "--disable-setuid-sandbox",
211
+ "--disable-infobars",
212
+ "--no-first-run",
213
+ "--no-default-browser-check",
214
+ "--password-store=basic",
215
+ "--use-mock-keychain",
216
+ "--disable-features=IsolateOrigins,site-per-process",
217
+ "--disable-ipc-flooding-protection",
218
+ "--disable-renderer-backgrounding",
219
+ "--disable-backgrounding-occluded-windows",
220
+ "--disable-background-timer-throttling",
221
+ "--window-size=1920,1080",
222
+ ]
223
+
224
+ # Auto-detect Wayland and set ozone platform
225
+ wayland_display = os.getenv("WAYLAND_DISPLAY")
226
+ if wayland_display:
227
+ args.append("--ozone-platform=wayland")
228
+ else:
229
+ args.append("--ozone-platform=x11")
230
+
231
+ cdp_port = get_env("BROWSER_CDP_PORT", "")
232
+ if cdp_port:
233
+ cdp_host = get_env("BROWSER_CDP_HOST", "0.0.0.0")
234
+ args.append(f"--remote-debugging-port={cdp_port}")
235
+ args.append(f"--remote-debugging-address={cdp_host}")
236
+
237
+ return args
238
+
239
+ try:
240
+ return shlex.split(args_str)
241
+ except Exception as e:
242
+ print(f"Warning: Failed to parse BROWSER_ARGS: {e}", file=sys.stderr)
243
+ print(f"Using default arguments instead", file=sys.stderr)
244
+ return [
245
+ "--disable-blink-features=AutomationControlled",
246
+ "--disable-dev-shm-usage",
247
+ "--no-sandbox",
248
+ "--disable-setuid-sandbox",
249
+ ]
250
+
251
+
252
+ def get_launch_options(persistent: bool = False) -> dict:
253
+ """
254
+ Get launch options for Playwright browser.
255
+
256
+ Uses the merged configuration from :func:`get_browser_config`, so the
257
+ priority order is:
258
+
259
+ 1. ``BOTMASK_*`` environment variables (namespace‑safe, no collision with
260
+ the host project's env vars).
261
+ 2. ``botmask.toml`` config file.
262
+ 3. Legacy bare env vars (``BROWSER_*``, ``DISPLAY``, etc.) – only used
263
+ when the above sources do not provide a value.
264
+
265
+ Args:
266
+ persistent: Whether to use persistent context (for login-required sites)
267
+
268
+ Returns:
269
+ Dictionary with launch options
270
+ """
271
+ config = get_browser_config()
272
+
273
+ # Headless from the merged config (BOTMASK_* > TOML > defaults > legacy)
274
+ headless = config["headless"]
275
+
276
+ # Browser args: prefer those from the config file / BOTMASK_* env,
277
+ # otherwise fall back to reading ``BROWSER_ARGS`` env var.
278
+ args = config.get("browser", {}).get("args") or get_browser_args()
279
+
280
+ return {
281
+ "executable_path": config["executable_path"],
282
+ "headless": headless,
283
+ "args": args,
284
+ "ignore_default_args": ["--enable-automation"],
285
+ }
286
+
287
+
288
+ USER_AGENT_POOL = [
289
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
290
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
291
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
292
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
293
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
294
+ ]
295
+
296
+ VIEWPORT_POOL = [
297
+ {"width": 1920, "height": 1080},
298
+ {"width": 1536, "height": 864},
299
+ {"width": 1440, "height": 900},
300
+ {"width": 1366, "height": 768},
301
+ {"width": 2560, "height": 1440},
302
+ ]
303
+
304
+
305
+ def get_rotated_user_agent() -> str:
306
+ """Get a random User-Agent from the pool, or use BROWSER_USER_AGENT from .env."""
307
+ env_ua = get_env("BROWSER_USER_AGENT", "")
308
+ if env_ua:
309
+ return env_ua
310
+ return random.choice(USER_AGENT_POOL)
311
+
312
+
313
+ def get_rotated_viewport() -> dict:
314
+ """Get a random viewport from the pool, or use BROWSER_VIEWPORT from .env."""
315
+ env_viewport = get_env("BROWSER_VIEWPORT", "")
316
+ if env_viewport:
317
+ try:
318
+ w, h = env_viewport.split("x")
319
+ return {"width": int(w), "height": int(h)}
320
+ except (ValueError, AttributeError):
321
+ pass
322
+ return random.choice(VIEWPORT_POOL)
323
+
324
+
325
+ def get_browserforge_headers() -> Optional[dict]:
326
+ """Generate a coherent HTTP header set (UA + sec-ch-ua + Sec-Fetch-*) via BrowserForge.
327
+
328
+ Fallback: returns None when BrowserForge is unavailable or generation fails,
329
+ so the static pools below remain the fallback.
330
+
331
+ Returns:
332
+ dict of HTTP headers, or None
333
+ """
334
+ try:
335
+ from browserforge.headers import HeaderGenerator
336
+ except ImportError as e:
337
+ print(f"Warning: BrowserForge not available, using static pools: {e}", file=sys.stderr)
338
+ return None
339
+ try:
340
+ locale = get_env("BROWSER_LOCALE", "es-VE")
341
+ hg = HeaderGenerator(browser=["chrome"], os=["linux"], locale=[locale])
342
+ return dict(hg.generate())
343
+ except Exception as e:
344
+ print(f"Warning: BrowserForge header generation failed, using static pools: {e}", file=sys.stderr)
345
+ return None
346
+
347
+
348
+ def get_context_options(persistent: bool = False, extra_headers: dict = None) -> dict:
349
+ """
350
+ Get context options for Playwright browser context.
351
+
352
+ HTTP headers and User-Agent come from a coherent BrowserForge set
353
+ (matching sec-ch-ua / Accept-Language / Sec-Fetch-*), falling back to the
354
+ static pools when unavailable. .env overrides (BROWSER_USER_AGENT,
355
+ BROWSER_VIEWPORT) always win.
356
+
357
+ Args:
358
+ persistent: Whether to use persistent context
359
+ extra_headers: Additional HTTP headers to add
360
+
361
+ Returns:
362
+ Dictionary with context options
363
+ """
364
+ config = get_browser_config()
365
+ headers = get_browserforge_headers()
366
+
367
+ # User-Agent precedence: .env > BrowserForge coherent UA > static pool
368
+ env_ua = get_env("BROWSER_USER_AGENT", "")
369
+ if env_ua:
370
+ user_agent = env_ua
371
+ elif headers and headers.get("User-Agent"):
372
+ user_agent = headers["User-Agent"]
373
+ else:
374
+ user_agent = get_rotated_user_agent()
375
+
376
+ http_headers = dict(headers) if headers else {}
377
+ http_headers.setdefault("Accept-Language", f"{config['locale']},es;q=0.9,en;q=0.8")
378
+ http_headers.setdefault("Referer", "https://www.google.com/")
379
+
380
+ # Add extra headers if provided
381
+ if extra_headers:
382
+ http_headers.update(extra_headers)
383
+
384
+ options = {
385
+ "locale": config["locale"],
386
+ "timezone_id": config["timezone"],
387
+ "viewport": get_rotated_viewport(),
388
+ "user_agent": user_agent,
389
+ "extra_http_headers": http_headers,
390
+ }
391
+
392
+ # Add geolocation if coordinates are provided
393
+ if config["latitude"] and config["longitude"]:
394
+ options["geolocation"] = {
395
+ "latitude": config["latitude"],
396
+ "longitude": config["longitude"],
397
+ }
398
+ options["permissions"] = ["geolocation"]
399
+
400
+ # Add user data dir for persistent contexts
401
+ if persistent:
402
+ options["user_data_dir"] = config["user_data_dir"]
403
+
404
+ return options
405
+
406
+
407
+ def ensure_user_data_dir():
408
+ """Ensure browser user data directory exists."""
409
+ config = get_browser_config()
410
+ Path(config["user_data_dir"]).mkdir(parents=True, exist_ok=True)
411
+
412
+
413
+ def get_cdp_url() -> str:
414
+ """
415
+ Get the CDP endpoint URL for connecting to the already-running browser.
416
+
417
+ The browser is started by the container entrypoint (start.sh)
418
+ with --remote-debugging-port. All scripts connect via CDP instead
419
+ of launching their own browser instance.
420
+
421
+ Returns:
422
+ CDP WebSocket URL (e.g. http://127.0.0.1:9222)
423
+ """
424
+ port = get_env("BROWSER_CDP_PORT", "9222")
425
+ host = get_env("BROWSER_CDP_HOST", "127.0.0.1")
426
+ return f"http://{host}:{port}"
427
+
428
+
429
+ # Export main functions
430
+ __all__ = [
431
+ "get_browser_config",
432
+ "get_browser_args",
433
+ "get_launch_options",
434
+ "get_context_options",
435
+ "ensure_user_data_dir",
436
+ "get_cdp_url",
437
+ "get_rotated_user_agent",
438
+ "get_rotated_viewport",
439
+ "get_browserforge_headers",
440
+ "USER_AGENT_POOL",
441
+ "VIEWPORT_POOL",
442
+ ]