camoufox-cli 0.7.2__tar.gz → 0.7.6__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.
- {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/.gitignore +1 -0
- {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/PKG-INFO +9 -2
- {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/README.md +7 -0
- {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/pyproject.toml +13 -1
- {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/src/camoufox_cli/cli.py +74 -16
- {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/src/camoufox_cli/commands.py +75 -11
- {camoufox_cli-0.7.2 → camoufox_cli-0.7.6}/src/camoufox_cli/install.py +36 -7
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/refs.py +38 -11
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/server.py +6 -0
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/.claude-plugin/marketplace.json +0 -20
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/.gitignore +0 -30
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/Makefile +0 -30
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/README.md +0 -272
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/.gitignore +0 -2
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/LICENSE +0 -21
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/package-lock.json +0 -2785
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/package.json +0 -47
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/pnpm-lock.yaml +0 -1599
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/scripts/postinstall.js +0 -11
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/browser.ts +0 -371
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/cli.ts +0 -538
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/commands.ts +0 -364
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/config.ts +0 -140
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/daemon.ts +0 -27
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/identity.ts +0 -206
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/install.ts +0 -126
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/protocol.ts +0 -32
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/proxy.ts +0 -40
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/refs.ts +0 -65
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/src/server.ts +0 -200
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/browser.test.ts +0 -98
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/cli.test.ts +0 -475
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/commands.test.ts +0 -189
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/e2e.test.ts +0 -294
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/install.test.ts +0 -153
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/protocol.test.ts +0 -98
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/proxy.test.ts +0 -55
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/refs.test.ts +0 -150
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tests/server.test.ts +0 -257
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/tsconfig.json +0 -14
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/js/vitest.config.ts +0 -7
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/pyproject.toml +0 -46
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/SKILL.md +0 -386
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/references/commands.md +0 -150
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/skills/camoufox-cli/references/snapshot-refs.md +0 -221
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/cli.py +0 -550
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/commands.py +0 -406
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/src/camoufox_cli/install.py +0 -127
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/__init__.py +0 -0
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/fixture.html +0 -22
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_cli.py +0 -418
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_commands.py +0 -556
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_e2e.py +0 -302
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_install.py +0 -150
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_protocol.py +0 -63
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_proxy.py +0 -37
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_refs.py +0 -112
- camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/tests/test_server.py +0 -101
- camoufox_cli-0.7.2/.claude-plugin/marketplace.json +0 -20
- camoufox_cli-0.7.2/LICENSE +0 -21
- camoufox_cli-0.7.2/Makefile +0 -30
- camoufox_cli-0.7.2/js/.gitignore +0 -2
- camoufox_cli-0.7.2/js/LICENSE +0 -21
- camoufox_cli-0.7.2/js/package-lock.json +0 -2785
- camoufox_cli-0.7.2/js/package.json +0 -47
- camoufox_cli-0.7.2/js/pnpm-lock.yaml +0 -1599
- camoufox_cli-0.7.2/js/scripts/postinstall.js +0 -11
- camoufox_cli-0.7.2/js/src/browser.ts +0 -401
- camoufox_cli-0.7.2/js/src/cli.ts +0 -588
- camoufox_cli-0.7.2/js/src/commands.ts +0 -364
- camoufox_cli-0.7.2/js/src/config.ts +0 -140
- camoufox_cli-0.7.2/js/src/daemon.ts +0 -27
- camoufox_cli-0.7.2/js/src/identity.ts +0 -206
- camoufox_cli-0.7.2/js/src/install.ts +0 -179
- camoufox_cli-0.7.2/js/src/protocol.ts +0 -32
- camoufox_cli-0.7.2/js/src/proxy.ts +0 -40
- camoufox_cli-0.7.2/js/src/refs.ts +0 -65
- camoufox_cli-0.7.2/js/src/server.ts +0 -214
- camoufox_cli-0.7.2/js/tests/browser.test.ts +0 -122
- camoufox_cli-0.7.2/js/tests/cli.test.ts +0 -475
- camoufox_cli-0.7.2/js/tests/commands.test.ts +0 -189
- camoufox_cli-0.7.2/js/tests/e2e.test.ts +0 -294
- camoufox_cli-0.7.2/js/tests/install.test.ts +0 -205
- camoufox_cli-0.7.2/js/tests/protocol.test.ts +0 -98
- camoufox_cli-0.7.2/js/tests/proxy.test.ts +0 -55
- camoufox_cli-0.7.2/js/tests/refs.test.ts +0 -150
- camoufox_cli-0.7.2/js/tests/server.test.ts +0 -300
- camoufox_cli-0.7.2/js/tsconfig.json +0 -14
- camoufox_cli-0.7.2/js/vitest.config.ts +0 -7
- camoufox_cli-0.7.2/skills/camoufox-cli/SKILL.md +0 -386
- camoufox_cli-0.7.2/skills/camoufox-cli/references/commands.md +0 -150
- camoufox_cli-0.7.2/skills/camoufox-cli/references/snapshot-refs.md +0 -221
- camoufox_cli-0.7.2/src/camoufox_cli/__init__.py +0 -0
- camoufox_cli-0.7.2/src/camoufox_cli/__main__.py +0 -38
- camoufox_cli-0.7.2/src/camoufox_cli/browser.py +0 -316
- camoufox_cli-0.7.2/src/camoufox_cli/config.py +0 -130
- camoufox_cli-0.7.2/src/camoufox_cli/identity.py +0 -215
- camoufox_cli-0.7.2/src/camoufox_cli/protocol.py +0 -24
- camoufox_cli-0.7.2/src/camoufox_cli/proxy.py +0 -30
- camoufox_cli-0.7.2/src/camoufox_cli/refs.py +0 -83
- camoufox_cli-0.7.2/src/camoufox_cli/server.py +0 -189
- camoufox_cli-0.7.2/tests/__init__.py +0 -0
- camoufox_cli-0.7.2/tests/fixture.html +0 -22
- camoufox_cli-0.7.2/tests/test_cli.py +0 -418
- camoufox_cli-0.7.2/tests/test_commands.py +0 -556
- camoufox_cli-0.7.2/tests/test_e2e.py +0 -302
- camoufox_cli-0.7.2/tests/test_install.py +0 -201
- camoufox_cli-0.7.2/tests/test_protocol.py +0 -63
- camoufox_cli-0.7.2/tests/test_proxy.py +0 -37
- camoufox_cli-0.7.2/tests/test_refs.py +0 -112
- camoufox_cli-0.7.2/tests/test_server.py +0 -101
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/LICENSE +0 -0
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/__init__.py +0 -0
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/__main__.py +0 -0
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/browser.py +0 -0
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/config.py +0 -0
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/identity.py +0 -0
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/protocol.py +0 -0
- {camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1 → camoufox_cli-0.7.6}/src/camoufox_cli/proxy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: camoufox-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.6
|
|
4
4
|
Summary: Anti-detect browser CLI & Skills for AI agents, powered by Camoufox
|
|
5
5
|
Project-URL: Homepage, https://github.com/Bin-Huang/camoufox-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/Bin-Huang/camoufox-cli
|
|
@@ -122,10 +122,16 @@ camoufox-cli type @e3 "append text" # Type without clearing
|
|
|
122
122
|
camoufox-cli select @e5 "option text" # Select dropdown option
|
|
123
123
|
camoufox-cli check @e6 # Toggle checkbox
|
|
124
124
|
camoufox-cli hover @e2 # Hover over element
|
|
125
|
+
camoufox-cli mouse click 660 380 # Click at viewport coordinates
|
|
125
126
|
camoufox-cli press Enter # Press keyboard key
|
|
126
127
|
camoufox-cli press "Control+a" # Key combination
|
|
127
128
|
```
|
|
128
129
|
|
|
130
|
+
`mouse click` reaches elements that have no ref, such as a checkbox inside a
|
|
131
|
+
cross-origin iframe (for example a Cloudflare Turnstile widget). Coordinates are
|
|
132
|
+
viewport CSS pixels. At the default `devicePixelRatio` of 1, they equal the pixels
|
|
133
|
+
of a non-`--full` screenshot.
|
|
134
|
+
|
|
129
135
|
### Data Extraction
|
|
130
136
|
|
|
131
137
|
```bash
|
|
@@ -289,6 +295,7 @@ The CLI sends JSON commands to a long-running daemon process via Unix socket. Th
|
|
|
289
295
|
- [reddit-ads-cli](https://github.com/Bin-Huang/reddit-ads-cli) -- Reddit Ads CLI & Skills for AI agents (and humans)
|
|
290
296
|
- [spotify-ads-cli](https://github.com/Bin-Huang/spotify-ads-cli) -- Spotify Ads CLI & Skills for AI agents (and humans)
|
|
291
297
|
- [apple-ads-cli](https://github.com/Bin-Huang/apple-ads-cli) -- Apple Ads CLI & Skills for AI agents (and humans)
|
|
298
|
+
|
|
292
299
|
## License
|
|
293
300
|
|
|
294
301
|
MIT
|
|
@@ -100,10 +100,16 @@ camoufox-cli type @e3 "append text" # Type without clearing
|
|
|
100
100
|
camoufox-cli select @e5 "option text" # Select dropdown option
|
|
101
101
|
camoufox-cli check @e6 # Toggle checkbox
|
|
102
102
|
camoufox-cli hover @e2 # Hover over element
|
|
103
|
+
camoufox-cli mouse click 660 380 # Click at viewport coordinates
|
|
103
104
|
camoufox-cli press Enter # Press keyboard key
|
|
104
105
|
camoufox-cli press "Control+a" # Key combination
|
|
105
106
|
```
|
|
106
107
|
|
|
108
|
+
`mouse click` reaches elements that have no ref, such as a checkbox inside a
|
|
109
|
+
cross-origin iframe (for example a Cloudflare Turnstile widget). Coordinates are
|
|
110
|
+
viewport CSS pixels. At the default `devicePixelRatio` of 1, they equal the pixels
|
|
111
|
+
of a non-`--full` screenshot.
|
|
112
|
+
|
|
107
113
|
### Data Extraction
|
|
108
114
|
|
|
109
115
|
```bash
|
|
@@ -267,6 +273,7 @@ The CLI sends JSON commands to a long-running daemon process via Unix socket. Th
|
|
|
267
273
|
- [reddit-ads-cli](https://github.com/Bin-Huang/reddit-ads-cli) -- Reddit Ads CLI & Skills for AI agents (and humans)
|
|
268
274
|
- [spotify-ads-cli](https://github.com/Bin-Huang/spotify-ads-cli) -- Spotify Ads CLI & Skills for AI agents (and humans)
|
|
269
275
|
- [apple-ads-cli](https://github.com/Bin-Huang/apple-ads-cli) -- Apple Ads CLI & Skills for AI agents (and humans)
|
|
276
|
+
|
|
270
277
|
## License
|
|
271
278
|
|
|
272
279
|
MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "camoufox-cli"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.6"
|
|
4
4
|
description = "Anti-detect browser CLI & Skills for AI agents, powered by Camoufox"
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
6
|
license = "MIT"
|
|
@@ -37,6 +37,18 @@ camoufox-cli = "camoufox_cli.cli:main"
|
|
|
37
37
|
requires = ["hatchling"]
|
|
38
38
|
build-backend = "hatchling.build"
|
|
39
39
|
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
# List the sdist contents explicitly. By default hatchling packs every file
|
|
42
|
+
# that the repo .gitignore does not exclude, which picks up local untracked
|
|
43
|
+
# files and the JS package. A leading "/" anchors each pattern to the repo
|
|
44
|
+
# root, so js/LICENSE does not match.
|
|
45
|
+
include = [
|
|
46
|
+
"/src/camoufox_cli",
|
|
47
|
+
"/README.md",
|
|
48
|
+
"/LICENSE",
|
|
49
|
+
"/pyproject.toml",
|
|
50
|
+
]
|
|
51
|
+
|
|
40
52
|
[tool.hatch.build.targets.wheel]
|
|
41
53
|
packages = ["src/camoufox_cli"]
|
|
42
54
|
|
|
@@ -7,18 +7,38 @@ import os
|
|
|
7
7
|
import socket
|
|
8
8
|
import subprocess
|
|
9
9
|
import sys
|
|
10
|
+
import tempfile
|
|
10
11
|
import time
|
|
11
12
|
|
|
12
13
|
from .config import load_defaults
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
SOCKET_PREFIX = "/tmp/camoufox-cli-"
|
|
17
|
+
# A unix socket path must fit in sun_path: 104 bytes on macOS (108 on Linux),
|
|
18
|
+
# including the terminating NUL.
|
|
19
|
+
_MAX_SOCKET_PATH_BYTES = 103
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
def get_socket_path(session: str) -> str:
|
|
19
23
|
return f"{SOCKET_PREFIX}{session}.sock"
|
|
20
24
|
|
|
21
25
|
|
|
26
|
+
def session_name_error(session: str) -> str | None:
|
|
27
|
+
"""Why ``session`` cannot be used as a session name, or None if it can.
|
|
28
|
+
|
|
29
|
+
The name becomes part of the daemon's /tmp socket, pid and lock file names
|
|
30
|
+
and of the default --persistent profile path.
|
|
31
|
+
"""
|
|
32
|
+
if session in ("", ".", ".."):
|
|
33
|
+
return "must be a non-empty name other than '.' or '..'"
|
|
34
|
+
if "/" in session:
|
|
35
|
+
return "must not contain '/'"
|
|
36
|
+
max_bytes = _MAX_SOCKET_PATH_BYTES - len(get_socket_path("").encode())
|
|
37
|
+
if len(session.encode()) > max_bytes:
|
|
38
|
+
return f"must be at most {max_bytes} bytes (unix socket path limit)"
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
22
42
|
def send_command(sock_path: str, command: dict) -> dict:
|
|
23
43
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
24
44
|
s.connect(sock_path)
|
|
@@ -47,21 +67,30 @@ def spawn_daemon(session: str, headed: bool, timeout: int, persistent: str | Non
|
|
|
47
67
|
if locale:
|
|
48
68
|
cmd.extend(["--locale", locale])
|
|
49
69
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
# The daemon writes stderr to an anonymous temp file, so a failed start can
|
|
71
|
+
# report its real cause instead of only a timeout.
|
|
72
|
+
with tempfile.TemporaryFile() as err:
|
|
73
|
+
subprocess.Popen(
|
|
74
|
+
cmd,
|
|
75
|
+
stdin=subprocess.DEVNULL,
|
|
76
|
+
stdout=subprocess.DEVNULL,
|
|
77
|
+
stderr=err,
|
|
78
|
+
start_new_session=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
sock_path = get_socket_path(session)
|
|
82
|
+
for _ in range(50):
|
|
83
|
+
if os.path.exists(sock_path):
|
|
84
|
+
return
|
|
85
|
+
time.sleep(0.1)
|
|
57
86
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if os.path.exists(sock_path):
|
|
61
|
-
return
|
|
62
|
-
time.sleep(0.1)
|
|
87
|
+
# pread: the daemon shares this file's offset, so do not seek it.
|
|
88
|
+
output = os.pread(err.fileno(), os.fstat(err.fileno()).st_size, 0)
|
|
63
89
|
|
|
64
90
|
print("Error: Daemon did not start within 5 seconds", file=sys.stderr)
|
|
91
|
+
output_lines = output.decode(errors="replace").strip().splitlines()
|
|
92
|
+
if output_lines:
|
|
93
|
+
print("Daemon output:\n" + "\n".join(output_lines[-20:]), file=sys.stderr)
|
|
65
94
|
sys.exit(1)
|
|
66
95
|
|
|
67
96
|
|
|
@@ -180,6 +209,10 @@ def parse_args(args: list[str]) -> tuple[dict, dict]:
|
|
|
180
209
|
|
|
181
210
|
# session selects which config block applies, so it comes only from the CLI.
|
|
182
211
|
session = cli.get("session", builtin["session"])
|
|
212
|
+
error = session_name_error(session)
|
|
213
|
+
if error:
|
|
214
|
+
print(f"Error: invalid --session {session!r}: {error}", file=sys.stderr)
|
|
215
|
+
sys.exit(1)
|
|
183
216
|
flags = {**builtin, **load_defaults(session), **cli}
|
|
184
217
|
|
|
185
218
|
action = rest[0]
|
|
@@ -244,6 +277,18 @@ def build_command(action: str, rest: list[str]) -> dict:
|
|
|
244
277
|
case "hover":
|
|
245
278
|
ref = _require(rest, 1, "Usage: camoufox-cli hover @e1")
|
|
246
279
|
return {"id": "r1", "action": "hover", "params": {"ref": ref}}
|
|
280
|
+
case "mouse":
|
|
281
|
+
usage = "Usage: camoufox-cli mouse click <x> <y>"
|
|
282
|
+
if _require(rest, 1, usage) != "click":
|
|
283
|
+
print(usage, file=sys.stderr)
|
|
284
|
+
sys.exit(1)
|
|
285
|
+
try:
|
|
286
|
+
x = float(_require(rest, 2, usage))
|
|
287
|
+
y = float(_require(rest, 3, usage))
|
|
288
|
+
except ValueError:
|
|
289
|
+
print(usage, file=sys.stderr)
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
return {"id": "r1", "action": "mouse_click", "params": {"x": x, "y": y}}
|
|
247
292
|
case "press":
|
|
248
293
|
key = _require(rest, 1, "Usage: camoufox-cli press Enter")
|
|
249
294
|
return {"id": "r1", "action": "press", "params": {"key": key}}
|
|
@@ -408,6 +453,18 @@ def _resolve_apt_deps(deps: list[str]) -> list[str]:
|
|
|
408
453
|
]
|
|
409
454
|
|
|
410
455
|
|
|
456
|
+
def _run_as_root(argv: list[str]) -> None:
|
|
457
|
+
"""Run a privileged package-manager command.
|
|
458
|
+
|
|
459
|
+
Prefer bare invocation when already root (Docker/CI images rarely ship
|
|
460
|
+
sudo). Fall back to sudo for unprivileged interactive installs.
|
|
461
|
+
"""
|
|
462
|
+
if os.geteuid() == 0:
|
|
463
|
+
subprocess.run(argv, check=True)
|
|
464
|
+
else:
|
|
465
|
+
subprocess.run(["sudo", *argv], check=True)
|
|
466
|
+
|
|
467
|
+
|
|
411
468
|
def _install_system_deps() -> None:
|
|
412
469
|
import platform
|
|
413
470
|
import shutil
|
|
@@ -419,14 +476,14 @@ def _install_system_deps() -> None:
|
|
|
419
476
|
print("[camoufox-cli] Installing system dependencies...", file=sys.stderr)
|
|
420
477
|
|
|
421
478
|
if shutil.which("apt-get"):
|
|
422
|
-
|
|
479
|
+
_run_as_root(["apt-get", "update", "-y"])
|
|
423
480
|
# Resolve AFTER update: dry-run resolution needs a populated apt cache.
|
|
424
481
|
deps = _resolve_apt_deps(_APT_DEPS)
|
|
425
|
-
|
|
482
|
+
_run_as_root(["apt-get", "install", "-y", *deps])
|
|
426
483
|
elif shutil.which("dnf"):
|
|
427
|
-
|
|
484
|
+
_run_as_root(["dnf", "install", "-y", *_DNF_DEPS])
|
|
428
485
|
elif shutil.which("yum"):
|
|
429
|
-
|
|
486
|
+
_run_as_root(["yum", "install", "-y", *_YUM_DEPS])
|
|
430
487
|
else:
|
|
431
488
|
print("[camoufox-cli] Could not detect a supported package manager (apt-get, dnf, yum).", file=sys.stderr)
|
|
432
489
|
sys.exit(1)
|
|
@@ -547,6 +604,7 @@ Interaction:
|
|
|
547
604
|
select @ref "option" Select dropdown option
|
|
548
605
|
check @ref Toggle checkbox
|
|
549
606
|
hover @ref Hover over element
|
|
607
|
+
mouse click <x> <y> Click at viewport coordinates (no ref needed)
|
|
550
608
|
press <key> Press key (e.g. Enter, Control+a)
|
|
551
609
|
|
|
552
610
|
Data:
|
|
@@ -5,9 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
import base64
|
|
6
6
|
import io
|
|
7
7
|
import json
|
|
8
|
+
import math
|
|
8
9
|
|
|
9
10
|
from .browser import BrowserManager, TabView
|
|
10
11
|
from .protocol import ok_response, error_response
|
|
12
|
+
from .refs import UNNAMED_RE
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
def execute(manager: BrowserManager, command: dict) -> dict:
|
|
@@ -41,10 +43,22 @@ def _resolve_ref(manager: TabView, ref_str: str):
|
|
|
41
43
|
if entry is None:
|
|
42
44
|
raise ValueError(f"Ref @{ref_str.lstrip('@')} not found. Run 'camoufox-cli snapshot' to refresh refs.")
|
|
43
45
|
page = manager.get_page()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
|
|
47
|
+
# Always filter by name, even for an unnamed ref: nth counts only elements
|
|
48
|
+
# that the snapshot shows with this name, so an unfiltered role query
|
|
49
|
+
# would pick the wrong element when named and unnamed elements share a role.
|
|
50
|
+
name = entry.name or UNNAMED_RE
|
|
51
|
+
|
|
52
|
+
def by_role(root):
|
|
53
|
+
return root.get_by_role(entry.role, name=name, exact=True) # type: ignore[arg-type]
|
|
54
|
+
|
|
55
|
+
locator = by_role(page)
|
|
56
|
+
scope = manager.refs.scope
|
|
57
|
+
if scope:
|
|
58
|
+
# nth was counted within the scoped snapshot, which includes the scope
|
|
59
|
+
# element itself, so resolve within the same subtree.
|
|
60
|
+
root = page.locator(scope)
|
|
61
|
+
locator = root.and_(locator).or_(by_role(root))
|
|
48
62
|
return locator.nth(entry.nth)
|
|
49
63
|
|
|
50
64
|
|
|
@@ -140,7 +154,7 @@ def _cmd_snapshot(manager: TabView, cmd_id: str, params: dict) -> dict:
|
|
|
140
154
|
|
|
141
155
|
target = page.locator(selector) if selector else page.locator("body")
|
|
142
156
|
aria_text = target.aria_snapshot()
|
|
143
|
-
annotated = manager.refs.build_from_snapshot(aria_text, interactive_only=interactive)
|
|
157
|
+
annotated = manager.refs.build_from_snapshot(aria_text, interactive_only=interactive, scope=selector)
|
|
144
158
|
return ok_response(cmd_id, {"snapshot": annotated})
|
|
145
159
|
|
|
146
160
|
|
|
@@ -148,6 +162,39 @@ def _cmd_snapshot(manager: TabView, cmd_id: str, params: dict) -> dict:
|
|
|
148
162
|
# Interaction
|
|
149
163
|
# ---------------------------------------------------------------------------
|
|
150
164
|
|
|
165
|
+
# DOM-click the element and return the href the caller must navigate to, or
|
|
166
|
+
# null. For links, a window-level listener runs after the page's own handlers:
|
|
167
|
+
# if none of them called preventDefault() (onclick, SPA routers), it cancels
|
|
168
|
+
# the native navigation and returns the href. If a page handler stops
|
|
169
|
+
# propagation (e.g. React's e.stopPropagation()), that listener never runs and
|
|
170
|
+
# the native navigation is not cancelled, so the href is still returned when
|
|
171
|
+
# the click was not prevented: page.goto() then replaces that navigation.
|
|
172
|
+
# closest() finds the <a> when the locator resolves to a child element.
|
|
173
|
+
_CLICK_JS = """el => {
|
|
174
|
+
const link = el.closest('a');
|
|
175
|
+
if (!(link instanceof HTMLAnchorElement) || !link.href || link.protocol === 'javascript:') {
|
|
176
|
+
el.click();
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
let event = null;
|
|
180
|
+
let href = null;
|
|
181
|
+
const capture = e => { event = e; };
|
|
182
|
+
const probe = e => {
|
|
183
|
+
if (!e.defaultPrevented) { e.preventDefault(); href = link.href; }
|
|
184
|
+
};
|
|
185
|
+
window.addEventListener('click', capture, true);
|
|
186
|
+
window.addEventListener('click', probe);
|
|
187
|
+
try {
|
|
188
|
+
el.click();
|
|
189
|
+
} finally {
|
|
190
|
+
window.removeEventListener('click', capture, true);
|
|
191
|
+
window.removeEventListener('click', probe);
|
|
192
|
+
}
|
|
193
|
+
if (href === null && event && !event.defaultPrevented) href = link.href;
|
|
194
|
+
return href;
|
|
195
|
+
}"""
|
|
196
|
+
|
|
197
|
+
|
|
151
198
|
def _cmd_click(manager: TabView, cmd_id: str, params: dict) -> dict:
|
|
152
199
|
ref_str = params.get("ref", "")
|
|
153
200
|
if not ref_str:
|
|
@@ -156,16 +203,15 @@ def _cmd_click(manager: TabView, cmd_id: str, params: dict) -> dict:
|
|
|
156
203
|
page = manager.get_page()
|
|
157
204
|
url_before = page.url
|
|
158
205
|
|
|
159
|
-
#
|
|
160
|
-
#
|
|
206
|
+
# Click via el.click() and navigate links via page.goto(). This avoids two
|
|
207
|
+
# Camoufox issues:
|
|
161
208
|
# 1. Playwright's .click() times out when sticky headers/overlays intercept pointer events
|
|
162
209
|
# 2. Camoufox ignores target="_blank" clicks (both .click() and el.click() silently fail)
|
|
163
|
-
#
|
|
164
|
-
|
|
210
|
+
# Links still get the click first, so page handlers that take it over
|
|
211
|
+
# (href="#" + onclick, SPA routing) work; see _CLICK_JS.
|
|
212
|
+
link_href = locator.evaluate(_CLICK_JS)
|
|
165
213
|
if link_href:
|
|
166
214
|
page.goto(link_href, wait_until="domcontentloaded")
|
|
167
|
-
else:
|
|
168
|
-
locator.evaluate("el => el.click()")
|
|
169
215
|
|
|
170
216
|
url_after = page.url
|
|
171
217
|
if url_after != url_before:
|
|
@@ -220,6 +266,23 @@ def _cmd_hover(manager: TabView, cmd_id: str, params: dict) -> dict:
|
|
|
220
266
|
return ok_response(cmd_id)
|
|
221
267
|
|
|
222
268
|
|
|
269
|
+
def _cmd_mouse_click(manager: TabView, cmd_id: str, params: dict) -> dict:
|
|
270
|
+
"""Click at viewport coordinates.
|
|
271
|
+
|
|
272
|
+
Reaches targets that have no ref, such as a checkbox inside a
|
|
273
|
+
cross-origin iframe (e.g. a Cloudflare Turnstile widget).
|
|
274
|
+
"""
|
|
275
|
+
try:
|
|
276
|
+
x = float(params["x"])
|
|
277
|
+
y = float(params["y"])
|
|
278
|
+
except (KeyError, TypeError, ValueError):
|
|
279
|
+
return error_response(cmd_id, "Missing or invalid 'x'/'y' parameters")
|
|
280
|
+
if not (math.isfinite(x) and math.isfinite(y)):
|
|
281
|
+
return error_response(cmd_id, "Missing or invalid 'x'/'y' parameters")
|
|
282
|
+
manager.get_page().mouse.click(x, y)
|
|
283
|
+
return ok_response(cmd_id)
|
|
284
|
+
|
|
285
|
+
|
|
223
286
|
def _cmd_press(manager: TabView, cmd_id: str, params: dict) -> dict:
|
|
224
287
|
key = params.get("key", "")
|
|
225
288
|
if not key:
|
|
@@ -389,6 +452,7 @@ _HANDLERS = {
|
|
|
389
452
|
"select": _cmd_select,
|
|
390
453
|
"check": _cmd_check,
|
|
391
454
|
"hover": _cmd_hover,
|
|
455
|
+
"mouse_click": _cmd_mouse_click,
|
|
392
456
|
"press": _cmd_press,
|
|
393
457
|
# Data extraction
|
|
394
458
|
"text": _cmd_text,
|
|
@@ -148,9 +148,22 @@ def ensure_mmdb() -> None:
|
|
|
148
148
|
print(f"[camoufox-cli] GeoIP database download failed ({e}).", file=sys.stderr)
|
|
149
149
|
|
|
150
150
|
|
|
151
|
+
def _browser_present() -> bool:
|
|
152
|
+
"""True when the browser's launch executable is actually on disk."""
|
|
153
|
+
from camoufox.exceptions import CamoufoxNotInstalled
|
|
154
|
+
from camoufox.pkgman import launch_path
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
launch_path()
|
|
158
|
+
return True
|
|
159
|
+
except (CamoufoxNotInstalled, FileNotFoundError):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
151
163
|
def install_browser() -> None:
|
|
152
|
-
"""Download and install the Camoufox browser
|
|
153
|
-
from camoufox.
|
|
164
|
+
"""Download and install the Camoufox browser, GeoIP database and addons."""
|
|
165
|
+
from camoufox.addons import DefaultAddons, maybe_download_addons
|
|
166
|
+
from camoufox.pkgman import CamoufoxFetcher, installed_verstr, launch_path
|
|
154
167
|
|
|
155
168
|
class ResilientFetcher(CamoufoxFetcher):
|
|
156
169
|
def get_asset(self):
|
|
@@ -159,9 +172,25 @@ def install_browser() -> None:
|
|
|
159
172
|
return data
|
|
160
173
|
self.missing_asset_error()
|
|
161
174
|
|
|
162
|
-
ResilientFetcher()
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
|
|
175
|
+
fetcher = ResilientFetcher()
|
|
176
|
+
# Match the JS installer: skip the ~600MB download when version.json
|
|
177
|
+
# already matches AND the binary is actually present. Without the
|
|
178
|
+
# presence check a broken install (version file, no binary) would be
|
|
179
|
+
# masked forever; without the version check every `install` re-downloads.
|
|
180
|
+
try:
|
|
181
|
+
installed = installed_verstr()
|
|
182
|
+
except Exception:
|
|
183
|
+
installed = None
|
|
184
|
+
if installed == fetcher.verstr and _browser_present():
|
|
185
|
+
print(f"[camoufox-cli] Camoufox v{installed} is already up to date.", file=sys.stderr)
|
|
186
|
+
else:
|
|
187
|
+
fetcher.install()
|
|
188
|
+
# A broken release asset (e.g. one missing the browser binary) would
|
|
189
|
+
# otherwise "install" successfully, write version.json, and mask the
|
|
190
|
+
# failure forever. Fail loudly here instead of at the first `open`.
|
|
191
|
+
launch_path() # raises CamoufoxNotInstalled if the executable is absent
|
|
192
|
+
|
|
167
193
|
ensure_mmdb()
|
|
194
|
+
# Default UBO addon — same as `camoufox fetch`. Without this, first open
|
|
195
|
+
# either downloads lazily or fails if an empty addons/ dir was left behind.
|
|
196
|
+
maybe_download_addons(list(DefaultAddons))
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import re
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
|
|
@@ -22,24 +23,51 @@ INTERACTIVE_ROLES = frozenset({
|
|
|
22
23
|
"select", "listbox", "searchbox",
|
|
23
24
|
})
|
|
24
25
|
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
# Playwright renders each node as `- role "name" [attrs]`, with the name
|
|
27
|
+
# JSON-encoded, except that a name which starts and ends with "/" (including
|
|
28
|
+
# "/" itself) is written as is. If the key contains YAML-special text (e.g.
|
|
29
|
+
# ": " or " #"), the whole key is wrapped in YAML single quotes, with ' escaped
|
|
30
|
+
# as ''. A plain key never contains ":" followed by whitespace, so that marks
|
|
31
|
+
# where its value starts.
|
|
32
|
+
_ARIA_ITEM_RE = re.compile(
|
|
33
|
+
r"^\s*-\s+" # leading indent + dash
|
|
34
|
+
r"(?:'((?:[^']|'')*)'|((?:[^:]|:(?!\s|$))*))" # single-quoted key, or plain key
|
|
31
35
|
)
|
|
36
|
+
_ARIA_KEY_RE = re.compile(
|
|
37
|
+
r'^(\w+)' # role
|
|
38
|
+
r'(?:\s+("(?:[^"\\]|\\.)*"' # optional JSON-encoded name,
|
|
39
|
+
r'|/(?:.*/)?(?=(?:\s\[[^\]]*\])*$)))?' # or a "/.../" or "/" name before the attrs
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Playwright omits names longer than 900 characters, so an unnamed node is an
|
|
43
|
+
# element whose accessible name is empty or over that limit.
|
|
44
|
+
UNNAMED_RE = re.compile(r"^$|^[\s\S]{901,}$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_aria_line(line: str) -> tuple[str, str] | None:
|
|
48
|
+
"""Return (role, name) for an aria snapshot node line, or None."""
|
|
49
|
+
item = _ARIA_ITEM_RE.match(line)
|
|
50
|
+
if not item:
|
|
51
|
+
return None
|
|
52
|
+
key = item.group(1).replace("''", "'") if item.group(1) is not None else item.group(2)
|
|
53
|
+
m = _ARIA_KEY_RE.match(key)
|
|
54
|
+
if not m:
|
|
55
|
+
return None
|
|
56
|
+
name = m.group(2) or ""
|
|
57
|
+
return m.group(1), json.loads(name) if name.startswith('"') else name
|
|
32
58
|
|
|
33
59
|
|
|
34
60
|
class RefRegistry:
|
|
35
61
|
def __init__(self):
|
|
36
62
|
self._entries: dict[str, RefEntry] = {} # ref_str -> RefEntry
|
|
37
63
|
self._counter = 0
|
|
64
|
+
self.scope: str | None = None # selector of a scoped snapshot; refs resolve within it
|
|
38
65
|
|
|
39
|
-
def build_from_snapshot(self, aria_text: str, interactive_only: bool = False) -> str:
|
|
66
|
+
def build_from_snapshot(self, aria_text: str, interactive_only: bool = False, scope: str | None = None) -> str:
|
|
40
67
|
"""Parse aria snapshot text, assign refs, return annotated text."""
|
|
41
68
|
self._entries.clear()
|
|
42
69
|
self._counter = 0
|
|
70
|
+
self.scope = scope
|
|
43
71
|
|
|
44
72
|
# Track role+name occurrences for nth disambiguation
|
|
45
73
|
seen: dict[tuple[str, str], int] = {}
|
|
@@ -47,14 +75,13 @@ class RefRegistry:
|
|
|
47
75
|
result_lines = []
|
|
48
76
|
|
|
49
77
|
for line in lines:
|
|
50
|
-
|
|
51
|
-
if
|
|
78
|
+
parsed = _parse_aria_line(line)
|
|
79
|
+
if parsed is None:
|
|
52
80
|
if not interactive_only:
|
|
53
81
|
result_lines.append(line)
|
|
54
82
|
continue
|
|
55
83
|
|
|
56
|
-
role =
|
|
57
|
-
name = m.group(3) or ""
|
|
84
|
+
role, name = parsed
|
|
58
85
|
|
|
59
86
|
if interactive_only and role not in INTERACTIVE_ROLES:
|
|
60
87
|
continue
|
|
@@ -14,6 +14,11 @@ from .browser import BrowserManager
|
|
|
14
14
|
from .commands import execute
|
|
15
15
|
from .protocol import parse_command, serialize_response
|
|
16
16
|
|
|
17
|
+
# Max seconds a client connection may take to send its command (and to take
|
|
18
|
+
# the response). This daemon serves one connection at a time, so a client
|
|
19
|
+
# that connects but never sends would otherwise block every other tab.
|
|
20
|
+
CONNECTION_TIMEOUT = 10.0
|
|
21
|
+
|
|
17
22
|
|
|
18
23
|
class DaemonServer:
|
|
19
24
|
def __init__(self, session: str = "default", headless: bool = True, timeout: int = 1800, persistent: str | None = None, proxy: str | None = None, geoip: bool = True, locale: str | None = None):
|
|
@@ -74,6 +79,7 @@ class DaemonServer:
|
|
|
74
79
|
self._shutdown()
|
|
75
80
|
|
|
76
81
|
def _handle_connection(self, conn: socket.socket) -> None:
|
|
82
|
+
conn.settimeout(CONNECTION_TIMEOUT)
|
|
77
83
|
data = b""
|
|
78
84
|
while True:
|
|
79
85
|
chunk = conn.recv(4096)
|
camoufox_cli-0.7.2/.claude/worktrees/cli-version-command-8852a1/.claude-plugin/marketplace.json
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "camoufox-cli",
|
|
3
|
-
"owner": {
|
|
4
|
-
"name": "Benn Huang"
|
|
5
|
-
},
|
|
6
|
-
"metadata": {
|
|
7
|
-
"description": "Anti-detect browser automation CLI & Skills for AI agents, powered by Camoufox",
|
|
8
|
-
"version": "0.1.0"
|
|
9
|
-
},
|
|
10
|
-
"plugins": [
|
|
11
|
-
{
|
|
12
|
-
"name": "camoufox-cli",
|
|
13
|
-
"description": "Anti-detect browser automation skill with fingerprint spoofing for AI agents",
|
|
14
|
-
"source": "./",
|
|
15
|
-
"skills": [
|
|
16
|
-
"./skills/camoufox-cli"
|
|
17
|
-
]
|
|
18
|
-
}
|
|
19
|
-
]
|
|
20
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# Python
|
|
2
|
-
__pycache__/
|
|
3
|
-
*.pyc
|
|
4
|
-
*.pyo
|
|
5
|
-
*.egg-info/
|
|
6
|
-
dist/
|
|
7
|
-
build/
|
|
8
|
-
.venv/
|
|
9
|
-
.mypy_cache/
|
|
10
|
-
uv.lock
|
|
11
|
-
|
|
12
|
-
# Node
|
|
13
|
-
node_modules/
|
|
14
|
-
js/README.md
|
|
15
|
-
|
|
16
|
-
# IDE
|
|
17
|
-
.idea/
|
|
18
|
-
.vscode/
|
|
19
|
-
*.swp
|
|
20
|
-
*.swo
|
|
21
|
-
|
|
22
|
-
# OS
|
|
23
|
-
.DS_Store
|
|
24
|
-
|
|
25
|
-
# Env
|
|
26
|
-
.env
|
|
27
|
-
|
|
28
|
-
# Runtime
|
|
29
|
-
*.sock
|
|
30
|
-
*.pid
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
.PHONY: test test-unit test-integration test-e2e build publish publish-pip publish-npm clean
|
|
2
|
-
|
|
3
|
-
test:
|
|
4
|
-
python -m pytest tests/ -v
|
|
5
|
-
|
|
6
|
-
test-unit:
|
|
7
|
-
python -m pytest tests/ -v -m "not integration"
|
|
8
|
-
|
|
9
|
-
test-integration:
|
|
10
|
-
python -m pytest tests/ -v -m integration
|
|
11
|
-
|
|
12
|
-
test-e2e:
|
|
13
|
-
python -m pytest tests/test_e2e.py -v
|
|
14
|
-
cd js && npx vitest run tests/e2e.test.ts
|
|
15
|
-
|
|
16
|
-
build: clean
|
|
17
|
-
uv build
|
|
18
|
-
cd js && npm run build
|
|
19
|
-
|
|
20
|
-
publish: publish-pip publish-npm
|
|
21
|
-
|
|
22
|
-
publish-pip: build
|
|
23
|
-
uvx twine upload dist/*
|
|
24
|
-
|
|
25
|
-
publish-npm: build
|
|
26
|
-
cd js && npm publish --access public
|
|
27
|
-
|
|
28
|
-
clean:
|
|
29
|
-
rm -rf dist/
|
|
30
|
-
rm -rf js/dist/
|