botmask 0.1.0__tar.gz
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-0.1.0/PKG-INFO +10 -0
- botmask-0.1.0/README.md +86 -0
- botmask-0.1.0/pyproject.toml +17 -0
- botmask-0.1.0/setup.cfg +4 -0
- botmask-0.1.0/src/botmask/__init__.py +1 -0
- botmask-0.1.0/src/botmask/_boot.py +72 -0
- botmask-0.1.0/src/botmask/a11y.py +114 -0
- botmask-0.1.0/src/botmask/config.py +442 -0
- botmask-0.1.0/src/botmask/human_behavior.py +887 -0
- botmask-0.1.0/src/botmask/human_cdp.py +501 -0
- botmask-0.1.0/src/botmask/input_os.py +127 -0
- botmask-0.1.0/src/botmask.egg-info/PKG-INFO +10 -0
- botmask-0.1.0/src/botmask.egg-info/SOURCES.txt +15 -0
- botmask-0.1.0/src/botmask.egg-info/dependency_links.txt +1 -0
- botmask-0.1.0/src/botmask.egg-info/requires.txt +6 -0
- botmask-0.1.0/src/botmask.egg-info/top_level.txt +1 -0
- botmask-0.1.0/tests/test_plugin.py +251 -0
botmask-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: botmask
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Containerized human-behavior browser automation toolkit (Brave + Patchright)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: patchright
|
|
7
|
+
Requires-Dist: python-dotenv
|
|
8
|
+
Requires-Dist: browserforge
|
|
9
|
+
Requires-Dist: PyAutoGUI
|
|
10
|
+
Provides-Extra: dev
|
botmask-0.1.0/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# botmask
|
|
2
|
+
|
|
3
|
+
> Containerized human-behavior browser automation toolkit. Installable as a plugin in other projects that need browser automation while evading anti-bot systems.
|
|
4
|
+
|
|
5
|
+
## What is this?
|
|
6
|
+
|
|
7
|
+
A reusable, containerized browser-automation kit that simulates realistic human behavior
|
|
8
|
+
(Bezier-curve mouse movement, inertia scrolling, natural delays, typo typing, warm-up
|
|
9
|
+
sessions, reading simulation, auth-barrier detection) on top of **Brave Browser + Patchright**.
|
|
10
|
+
|
|
11
|
+
Extracted from the `career-ops` pipeline (`~/Proyectos/ai/jobs`) into an isolated, installable
|
|
12
|
+
package so it can be dropped into any project that needs stealthy browser interaction.
|
|
13
|
+
|
|
14
|
+
## Stack
|
|
15
|
+
|
|
16
|
+
| Layer | Tool |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| Runtime | Docker / Docker Compose |
|
|
19
|
+
| Browser | **Brave** (Chromium; ad-blocking, anti-fingerprinting) |
|
|
20
|
+
| Automation | **Patchright** (undetectable Playwright fork, CDP) |
|
|
21
|
+
| Human behavior | `human_behavior` module (this project) |
|
|
22
|
+
| Config | `config` module, env-driven (inherited from `jobs`) |
|
|
23
|
+
| Hardening | BrowserForge headers (planned), OS-level input fallback (planned) |
|
|
24
|
+
|
|
25
|
+
**Decision (Phase 0, `docs/decision.md`): Brave only.** Firefox engines (camoufox,
|
|
26
|
+
invisible_playwright) were evaluated and discarded. Hard targets are handled with
|
|
27
|
+
OS-level input (Xvfb + xdotool/PyAutoGUI) + graceful challenge detection.
|
|
28
|
+
|
|
29
|
+
## How the AI knows where to click
|
|
30
|
+
|
|
31
|
+
See **`docs/interaction-model.md`**. Short version: the AI picks elements from a numbered
|
|
32
|
+
accessibility tree (never pixel coordinates); the script resolves element → bounding box →
|
|
33
|
+
humanized mouse path. OS-level input maps viewport → screen coords via a one-time window
|
|
34
|
+
offset calibration.
|
|
35
|
+
|
|
36
|
+
## Container usage
|
|
37
|
+
|
|
38
|
+
### Local (real display, like `jobs`) — `develop` target
|
|
39
|
+
|
|
40
|
+
Shares the host display into the container (Wayland socket + X11). `compose.yaml`
|
|
41
|
+
builds the `develop` stage (no virtual display stack).
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cp .env.example .env # adjust DISPLAY, WAYLAND_DISPLAY for your host
|
|
45
|
+
docker compose up -d --build
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Volumes: `.:/app`, `/tmp/.X11-unix`, `/run/user/<UID>/wayland-0`. CDP exposed at `:9222`.
|
|
49
|
+
|
|
50
|
+
### Server (no monitor) — Xvfb + noVNC — `deploy` target
|
|
51
|
+
|
|
52
|
+
The server override lives in **`.gitlab/compose.yaml`**; a future GitLab CI step copies it
|
|
53
|
+
to `compose.override.yaml` (which Docker Compose auto-loads), then:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
docker compose up -d --build # target=deploy via the override
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Runs Brave headful on an Xvfb virtual display; watch it at `http://<host>:6080` (noVNC) or
|
|
60
|
+
VNC at `:5900`. Same element→box→input logic — only the display source differs.
|
|
61
|
+
|
|
62
|
+
`start.sh` auto-detects the mode from the environment: with `DISPLAY`/`WAYLAND_DISPLAY`
|
|
63
|
+
present it runs the browser directly; with none (or `VIRTUAL_DISPLAY=true`) it boots the
|
|
64
|
+
Xvfb stack first. On every start it also provisions Python packages + browser binaries at
|
|
65
|
+
runtime (`pip install .` + `patchright install chromium`), cached in the `cache` volume
|
|
66
|
+
(`/root/.cache` — pip wheels + browsers) so the image itself stays small.
|
|
67
|
+
|
|
68
|
+
## Status
|
|
69
|
+
|
|
70
|
+
- **Phase 0 — Evaluation: COMPLETE** → `docs/decision.md`
|
|
71
|
+
- **Phase 1 — Package extraction & container scaffold: IN PROGRESS**
|
|
72
|
+
- `config.py` migrated ✅ · Dockerfile (develop/deploy) + compose + start.sh scaffolded ✅
|
|
73
|
+
- Fitts + overshoot mouse port ⏳ · `human_behavior.py` migration ⏳ (see `TODO.md`)
|
|
74
|
+
|
|
75
|
+
## Docker targets (multistage)
|
|
76
|
+
|
|
77
|
+
| Target | For | Display | Build |
|
|
78
|
+
| --- | --- | --- | --- |
|
|
79
|
+
| `develop` (default via compose.yaml) | local dev on host Wayland/X11 | host display | `docker build --target develop -t botmask:develop .` |
|
|
80
|
+
| `deploy` (default target of plain `docker build`) | headless machine / CI | Xvfb + noVNC | `docker build --target deploy -t botmask:deploy .` |
|
|
81
|
+
|
|
82
|
+
Base image is Debian `python:slim` — Brave requires glibc and cannot run on Alpine/musl.
|
|
83
|
+
|
|
84
|
+
## Migrating from `jobs`
|
|
85
|
+
|
|
86
|
+
What can and cannot be reused from the `jobs` project: `docs/migration-from-jobs.md`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "botmask"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Containerized human-behavior browser automation toolkit (Brave + Patchright)"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"patchright",
|
|
8
|
+
"python-dotenv",
|
|
9
|
+
"browserforge",
|
|
10
|
+
"PyAutoGUI",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = []
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
botmask-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""botmask — containerized human-behavior browser automation toolkit."""
|
|
@@ -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()
|
|
@@ -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
|
+
}
|