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 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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ }