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 +1 -0
- botmask/_boot.py +72 -0
- botmask/a11y.py +114 -0
- botmask/config.py +442 -0
- botmask/human_behavior.py +887 -0
- botmask/human_cdp.py +501 -0
- botmask/input_os.py +127 -0
- botmask-0.1.0.dist-info/METADATA +10 -0
- botmask-0.1.0.dist-info/RECORD +11 -0
- botmask-0.1.0.dist-info/WHEEL +5 -0
- botmask-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
]
|