camoufox-cli 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.
- camoufox_cli/__init__.py +0 -0
- camoufox_cli/__main__.py +32 -0
- camoufox_cli/browser.py +99 -0
- camoufox_cli/cli.py +385 -0
- camoufox_cli/commands.py +343 -0
- camoufox_cli/protocol.py +24 -0
- camoufox_cli/refs.py +83 -0
- camoufox_cli/server.py +145 -0
- camoufox_cli-0.1.0.dist-info/METADATA +158 -0
- camoufox_cli-0.1.0.dist-info/RECORD +13 -0
- camoufox_cli-0.1.0.dist-info/WHEEL +4 -0
- camoufox_cli-0.1.0.dist-info/entry_points.txt +2 -0
- camoufox_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
camoufox_cli/__init__.py
ADDED
|
File without changes
|
camoufox_cli/__main__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Entry point: python -m camoufox_cli"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .server import DaemonServer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(description="camoufox-cli daemon server")
|
|
11
|
+
parser.add_argument("--session", default="default", help="Session name")
|
|
12
|
+
parser.add_argument("--headless", action="store_true", default=True, help="Run headless (default)")
|
|
13
|
+
parser.add_argument("--headed", action="store_true", help="Show browser window")
|
|
14
|
+
parser.add_argument("--timeout", type=int, default=1800, help="Idle timeout in seconds")
|
|
15
|
+
parser.add_argument("--persistent", default=None, help="Path for persistent browser profile")
|
|
16
|
+
args = parser.parse_args()
|
|
17
|
+
|
|
18
|
+
headless = not args.headed
|
|
19
|
+
|
|
20
|
+
server = DaemonServer(
|
|
21
|
+
session=args.session,
|
|
22
|
+
headless=headless,
|
|
23
|
+
timeout=args.timeout,
|
|
24
|
+
persistent=args.persistent,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
print(f"[camoufox-cli] Starting daemon session={args.session} headless={headless}", file=sys.stderr)
|
|
28
|
+
server.start()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main()
|
camoufox_cli/browser.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Browser manager: launches and manages Camoufox instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from camoufox.sync_api import Camoufox
|
|
6
|
+
from playwright.sync_api import BrowserContext, Page
|
|
7
|
+
|
|
8
|
+
from .refs import RefRegistry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrowserManager:
|
|
12
|
+
def __init__(self, persistent: str | None = None):
|
|
13
|
+
self._camoufox: Camoufox | None = None
|
|
14
|
+
self._context: BrowserContext | None = None
|
|
15
|
+
self._page: Page | None = None
|
|
16
|
+
self.refs = RefRegistry()
|
|
17
|
+
self._headless: bool = True
|
|
18
|
+
self._persistent = persistent
|
|
19
|
+
|
|
20
|
+
def launch(self, headless: bool = True) -> None:
|
|
21
|
+
if self._camoufox is not None:
|
|
22
|
+
return
|
|
23
|
+
self._headless = headless
|
|
24
|
+
|
|
25
|
+
kwargs: dict = {"headless": headless}
|
|
26
|
+
if self._persistent:
|
|
27
|
+
kwargs["persistent_context"] = True
|
|
28
|
+
kwargs["user_data_dir"] = self._persistent
|
|
29
|
+
|
|
30
|
+
self._camoufox = Camoufox(**kwargs)
|
|
31
|
+
result = self._camoufox.__enter__()
|
|
32
|
+
|
|
33
|
+
if self._persistent:
|
|
34
|
+
# persistent_context returns BrowserContext directly
|
|
35
|
+
self._context = result
|
|
36
|
+
pages = self._context.pages
|
|
37
|
+
self._page = pages[0] if pages else self._context.new_page()
|
|
38
|
+
else:
|
|
39
|
+
# Normal mode: result is Browser, new_page() creates default context + page
|
|
40
|
+
self._page = result.new_page()
|
|
41
|
+
self._context = self._page.context
|
|
42
|
+
|
|
43
|
+
def get_page(self) -> Page:
|
|
44
|
+
if self._page is None:
|
|
45
|
+
raise RuntimeError("Browser not launched. Send 'open' command first.")
|
|
46
|
+
return self._page
|
|
47
|
+
|
|
48
|
+
def get_context(self) -> BrowserContext:
|
|
49
|
+
if self._context is None:
|
|
50
|
+
raise RuntimeError("Browser not launched. Send 'open' command first.")
|
|
51
|
+
return self._context
|
|
52
|
+
|
|
53
|
+
def get_tabs(self) -> list[dict]:
|
|
54
|
+
ctx = self.get_context()
|
|
55
|
+
tabs = []
|
|
56
|
+
for i, p in enumerate(ctx.pages):
|
|
57
|
+
tabs.append({
|
|
58
|
+
"index": i,
|
|
59
|
+
"url": p.url,
|
|
60
|
+
"title": p.title(),
|
|
61
|
+
"active": p is self._page,
|
|
62
|
+
})
|
|
63
|
+
return tabs
|
|
64
|
+
|
|
65
|
+
def switch_to_tab(self, index: int) -> Page:
|
|
66
|
+
ctx = self.get_context()
|
|
67
|
+
pages = ctx.pages
|
|
68
|
+
if index < 0 or index >= len(pages):
|
|
69
|
+
raise IndexError(f"Tab index {index} out of range (0-{len(pages) - 1})")
|
|
70
|
+
self._page = pages[index]
|
|
71
|
+
self._page.bring_to_front()
|
|
72
|
+
return self._page
|
|
73
|
+
|
|
74
|
+
def close_current_tab(self) -> None:
|
|
75
|
+
ctx = self.get_context()
|
|
76
|
+
pages = ctx.pages
|
|
77
|
+
if len(pages) <= 1:
|
|
78
|
+
raise RuntimeError("Cannot close the last tab. Use 'close' to shut down the browser.")
|
|
79
|
+
current = self._page
|
|
80
|
+
# Switch to another tab before closing
|
|
81
|
+
idx = pages.index(current)
|
|
82
|
+
new_idx = idx - 1 if idx > 0 else 1
|
|
83
|
+
self._page = pages[new_idx]
|
|
84
|
+
self._page.bring_to_front()
|
|
85
|
+
current.close()
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
if self._camoufox is not None:
|
|
89
|
+
try:
|
|
90
|
+
self._camoufox.__exit__(None, None, None)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
self._camoufox = None
|
|
94
|
+
self._context = None
|
|
95
|
+
self._page = None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_running(self) -> bool:
|
|
99
|
+
return self._camoufox is not None
|
camoufox_cli/cli.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""CLI client: parses args, starts daemon if needed, sends command via Unix socket."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SOCKET_PREFIX = "/tmp/camoufox-cli-"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_socket_path(session: str) -> str:
|
|
17
|
+
return f"{SOCKET_PREFIX}{session}.sock"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def send_command(sock_path: str, command: dict) -> dict:
|
|
21
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
22
|
+
s.connect(sock_path)
|
|
23
|
+
s.sendall(json.dumps(command).encode() + b"\n")
|
|
24
|
+
s.shutdown(socket.SHUT_WR)
|
|
25
|
+
data = b""
|
|
26
|
+
while True:
|
|
27
|
+
chunk = s.recv(4096)
|
|
28
|
+
if not chunk:
|
|
29
|
+
break
|
|
30
|
+
data += chunk
|
|
31
|
+
s.close()
|
|
32
|
+
return json.loads(data.decode())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def spawn_daemon(session: str, headed: bool, timeout: int, persistent: str | None) -> None:
|
|
36
|
+
cmd = [sys.executable, "-m", "camoufox_cli", "--session", session, "--timeout", str(timeout)]
|
|
37
|
+
if headed:
|
|
38
|
+
cmd.append("--headed")
|
|
39
|
+
if persistent:
|
|
40
|
+
cmd.extend(["--persistent", persistent])
|
|
41
|
+
|
|
42
|
+
subprocess.Popen(
|
|
43
|
+
cmd,
|
|
44
|
+
stdin=subprocess.DEVNULL,
|
|
45
|
+
stdout=subprocess.DEVNULL,
|
|
46
|
+
stderr=subprocess.DEVNULL,
|
|
47
|
+
start_new_session=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
sock_path = get_socket_path(session)
|
|
51
|
+
for _ in range(50):
|
|
52
|
+
if os.path.exists(sock_path):
|
|
53
|
+
return
|
|
54
|
+
time.sleep(0.1)
|
|
55
|
+
|
|
56
|
+
print("Error: Daemon did not start within 5 seconds", file=sys.stderr)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ensure_daemon(session: str, headed: bool, timeout: int, persistent: str | None) -> None:
|
|
61
|
+
sock_path = get_socket_path(session)
|
|
62
|
+
if not os.path.exists(sock_path):
|
|
63
|
+
spawn_daemon(session, headed, timeout, persistent)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def list_sessions() -> list[str]:
|
|
67
|
+
sessions = []
|
|
68
|
+
try:
|
|
69
|
+
for name in os.listdir("/tmp"):
|
|
70
|
+
if name.startswith("camoufox-cli-") and name.endswith(".sock"):
|
|
71
|
+
sessions.append(name[len("camoufox-cli-"):-len(".sock")])
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
sessions.sort()
|
|
75
|
+
return sessions
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_args(args: list[str]) -> tuple[dict, dict]:
|
|
79
|
+
"""Parse CLI args into (flags, command). Returns (flags_dict, command_json)."""
|
|
80
|
+
flags = {"session": "default", "headed": False, "timeout": 1800, "json": False, "persistent": None}
|
|
81
|
+
rest = []
|
|
82
|
+
|
|
83
|
+
i = 0
|
|
84
|
+
while i < len(args):
|
|
85
|
+
if args[i] == "--session":
|
|
86
|
+
i += 1
|
|
87
|
+
if i >= len(args):
|
|
88
|
+
print("Error: --session requires a value", file=sys.stderr)
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
flags["session"] = args[i]
|
|
91
|
+
elif args[i] == "--headed":
|
|
92
|
+
flags["headed"] = True
|
|
93
|
+
elif args[i] == "--timeout":
|
|
94
|
+
i += 1
|
|
95
|
+
if i >= len(args):
|
|
96
|
+
print("Error: --timeout requires a value", file=sys.stderr)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
flags["timeout"] = int(args[i])
|
|
99
|
+
elif args[i] == "--json":
|
|
100
|
+
flags["json"] = True
|
|
101
|
+
elif args[i] == "--persistent":
|
|
102
|
+
i += 1
|
|
103
|
+
if i >= len(args):
|
|
104
|
+
print("Error: --persistent requires a value", file=sys.stderr)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
flags["persistent"] = args[i]
|
|
107
|
+
else:
|
|
108
|
+
rest.append(args[i])
|
|
109
|
+
i += 1
|
|
110
|
+
|
|
111
|
+
if not rest:
|
|
112
|
+
print(USAGE, file=sys.stderr)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
action = rest[0]
|
|
116
|
+
cmd = build_command(action, rest)
|
|
117
|
+
return flags, cmd
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_command(action: str, rest: list[str]) -> dict:
|
|
121
|
+
"""Build JSON command from action and remaining args."""
|
|
122
|
+
match action:
|
|
123
|
+
# Navigation
|
|
124
|
+
case "open":
|
|
125
|
+
url = _require(rest, 1, "Usage: camoufox-cli open <url>")
|
|
126
|
+
return {"id": "r1", "action": "open", "params": {"url": url}}
|
|
127
|
+
case "back":
|
|
128
|
+
return {"id": "r1", "action": "back", "params": {}}
|
|
129
|
+
case "forward":
|
|
130
|
+
return {"id": "r1", "action": "forward", "params": {}}
|
|
131
|
+
case "reload":
|
|
132
|
+
return {"id": "r1", "action": "reload", "params": {}}
|
|
133
|
+
case "url":
|
|
134
|
+
return {"id": "r1", "action": "url", "params": {}}
|
|
135
|
+
case "title":
|
|
136
|
+
return {"id": "r1", "action": "title", "params": {}}
|
|
137
|
+
case "close":
|
|
138
|
+
all_flag = "--all" in rest
|
|
139
|
+
return {"id": "r1", "action": "close", "params": {"all": all_flag}}
|
|
140
|
+
|
|
141
|
+
# Snapshot
|
|
142
|
+
case "snapshot":
|
|
143
|
+
interactive = "-i" in rest
|
|
144
|
+
selector = None
|
|
145
|
+
if "-s" in rest:
|
|
146
|
+
idx = rest.index("-s")
|
|
147
|
+
selector = _require(rest, idx + 1, "Usage: camoufox-cli snapshot -s <selector>")
|
|
148
|
+
params = {"interactive": interactive}
|
|
149
|
+
if selector:
|
|
150
|
+
params["selector"] = selector
|
|
151
|
+
return {"id": "r1", "action": "snapshot", "params": params}
|
|
152
|
+
|
|
153
|
+
# Interaction
|
|
154
|
+
case "click":
|
|
155
|
+
ref = _require(rest, 1, "Usage: camoufox-cli click @e1")
|
|
156
|
+
return {"id": "r1", "action": "click", "params": {"ref": ref}}
|
|
157
|
+
case "fill":
|
|
158
|
+
ref = _require(rest, 1, "Usage: camoufox-cli fill @e1 \"text\"")
|
|
159
|
+
text = _require(rest, 2, "Usage: camoufox-cli fill @e1 \"text\"")
|
|
160
|
+
return {"id": "r1", "action": "fill", "params": {"ref": ref, "text": text}}
|
|
161
|
+
case "type":
|
|
162
|
+
ref = _require(rest, 1, "Usage: camoufox-cli type @e1 \"text\"")
|
|
163
|
+
text = _require(rest, 2, "Usage: camoufox-cli type @e1 \"text\"")
|
|
164
|
+
return {"id": "r1", "action": "type", "params": {"ref": ref, "text": text}}
|
|
165
|
+
case "select":
|
|
166
|
+
ref = _require(rest, 1, "Usage: camoufox-cli select @e1 \"option\"")
|
|
167
|
+
value = _require(rest, 2, "Usage: camoufox-cli select @e1 \"option\"")
|
|
168
|
+
return {"id": "r1", "action": "select", "params": {"ref": ref, "value": value}}
|
|
169
|
+
case "check":
|
|
170
|
+
ref = _require(rest, 1, "Usage: camoufox-cli check @e1")
|
|
171
|
+
return {"id": "r1", "action": "check", "params": {"ref": ref}}
|
|
172
|
+
case "hover":
|
|
173
|
+
ref = _require(rest, 1, "Usage: camoufox-cli hover @e1")
|
|
174
|
+
return {"id": "r1", "action": "hover", "params": {"ref": ref}}
|
|
175
|
+
case "press":
|
|
176
|
+
key = _require(rest, 1, "Usage: camoufox-cli press Enter")
|
|
177
|
+
return {"id": "r1", "action": "press", "params": {"key": key}}
|
|
178
|
+
|
|
179
|
+
# Data extraction
|
|
180
|
+
case "text":
|
|
181
|
+
target = _require(rest, 1, "Usage: camoufox-cli text @e1 | camoufox-cli text body")
|
|
182
|
+
return {"id": "r1", "action": "text", "params": {"target": target}}
|
|
183
|
+
case "eval":
|
|
184
|
+
expr = _require(rest, 1, "Usage: camoufox-cli eval \"document.title\"")
|
|
185
|
+
return {"id": "r1", "action": "eval", "params": {"expression": expr}}
|
|
186
|
+
case "screenshot":
|
|
187
|
+
params = {}
|
|
188
|
+
for arg in rest[1:]:
|
|
189
|
+
if arg == "--full":
|
|
190
|
+
params["full_page"] = True
|
|
191
|
+
else:
|
|
192
|
+
params["path"] = arg
|
|
193
|
+
return {"id": "r1", "action": "screenshot", "params": params}
|
|
194
|
+
case "pdf":
|
|
195
|
+
path = _require(rest, 1, "Usage: camoufox-cli pdf output.pdf")
|
|
196
|
+
return {"id": "r1", "action": "pdf", "params": {"path": path}}
|
|
197
|
+
|
|
198
|
+
# Scroll & Wait
|
|
199
|
+
case "scroll":
|
|
200
|
+
direction = _require(rest, 1, "Usage: camoufox-cli scroll down [px]")
|
|
201
|
+
amount = int(rest[2]) if len(rest) > 2 else 500
|
|
202
|
+
return {"id": "r1", "action": "scroll", "params": {"direction": direction, "amount": amount}}
|
|
203
|
+
case "wait":
|
|
204
|
+
target = _require(rest, 1, "Usage: camoufox-cli wait @e1 | camoufox-cli wait 2000 | camoufox-cli wait --url \"pattern\"")
|
|
205
|
+
if target == "--url":
|
|
206
|
+
pattern = _require(rest, 2, "Usage: camoufox-cli wait --url \"*/dashboard\"")
|
|
207
|
+
return {"id": "r1", "action": "wait", "params": {"url": pattern}}
|
|
208
|
+
elif target.startswith("@"):
|
|
209
|
+
return {"id": "r1", "action": "wait", "params": {"ref": target}}
|
|
210
|
+
elif target[0].isdigit():
|
|
211
|
+
return {"id": "r1", "action": "wait", "params": {"ms": int(target)}}
|
|
212
|
+
else:
|
|
213
|
+
return {"id": "r1", "action": "wait", "params": {"selector": target}}
|
|
214
|
+
|
|
215
|
+
# Tab management
|
|
216
|
+
case "tabs":
|
|
217
|
+
return {"id": "r1", "action": "tabs", "params": {}}
|
|
218
|
+
case "switch":
|
|
219
|
+
index = _require(rest, 1, "Usage: camoufox-cli switch <tab-index>")
|
|
220
|
+
return {"id": "r1", "action": "switch", "params": {"index": int(index)}}
|
|
221
|
+
case "close-tab":
|
|
222
|
+
return {"id": "r1", "action": "close-tab", "params": {}}
|
|
223
|
+
|
|
224
|
+
# Session & Cookies
|
|
225
|
+
case "sessions":
|
|
226
|
+
return {"id": "r1", "action": "sessions", "params": {}}
|
|
227
|
+
case "cookies":
|
|
228
|
+
if len(rest) > 1 and rest[1] == "import":
|
|
229
|
+
path = _require(rest, 2, "Usage: camoufox-cli cookies import file.json")
|
|
230
|
+
return {"id": "r1", "action": "cookies", "params": {"op": "import", "path": path}}
|
|
231
|
+
elif len(rest) > 1 and rest[1] == "export":
|
|
232
|
+
path = _require(rest, 2, "Usage: camoufox-cli cookies export file.json")
|
|
233
|
+
return {"id": "r1", "action": "cookies", "params": {"op": "export", "path": path}}
|
|
234
|
+
else:
|
|
235
|
+
return {"id": "r1", "action": "cookies", "params": {"op": "list"}}
|
|
236
|
+
|
|
237
|
+
case _:
|
|
238
|
+
print(f"Unknown command: {action}\n{USAGE}", file=sys.stderr)
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _require(args: list[str], idx: int, usage: str) -> str:
|
|
243
|
+
if idx >= len(args):
|
|
244
|
+
print(usage, file=sys.stderr)
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
return args[idx]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def print_response(response: dict, json_mode: bool) -> None:
|
|
250
|
+
if json_mode:
|
|
251
|
+
print(json.dumps(response, indent=2, ensure_ascii=False))
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
if not response.get("success"):
|
|
255
|
+
print(f"Error: {response.get('error', 'Unknown error')}", file=sys.stderr)
|
|
256
|
+
sys.exit(1)
|
|
257
|
+
|
|
258
|
+
data = response.get("data")
|
|
259
|
+
if not data:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
if "snapshot" in data:
|
|
263
|
+
print(data["snapshot"])
|
|
264
|
+
elif "text" in data:
|
|
265
|
+
print(data["text"])
|
|
266
|
+
elif "result" in data:
|
|
267
|
+
v = data["result"]
|
|
268
|
+
print("null" if v is None else json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v)
|
|
269
|
+
elif data.get("closed"):
|
|
270
|
+
pass # silent
|
|
271
|
+
elif "url" in data:
|
|
272
|
+
if "title" in data:
|
|
273
|
+
print(data["title"])
|
|
274
|
+
print(data["url"])
|
|
275
|
+
elif "title" in data:
|
|
276
|
+
print(data["title"])
|
|
277
|
+
elif not data:
|
|
278
|
+
pass # silent success
|
|
279
|
+
else:
|
|
280
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def main():
|
|
284
|
+
args = sys.argv[1:]
|
|
285
|
+
flags, command = parse_args(args)
|
|
286
|
+
|
|
287
|
+
action = command.get("action", "")
|
|
288
|
+
|
|
289
|
+
# Client-side: sessions
|
|
290
|
+
if action == "sessions":
|
|
291
|
+
sessions = list_sessions()
|
|
292
|
+
if flags["json"]:
|
|
293
|
+
print(json.dumps(sessions, indent=2))
|
|
294
|
+
elif not sessions:
|
|
295
|
+
print("No active sessions.")
|
|
296
|
+
else:
|
|
297
|
+
for s in sessions:
|
|
298
|
+
print(s)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# Client-side: close --all
|
|
302
|
+
if action == "close" and command.get("params", {}).get("all"):
|
|
303
|
+
sessions = list_sessions()
|
|
304
|
+
if not sessions:
|
|
305
|
+
print("No active sessions.")
|
|
306
|
+
return
|
|
307
|
+
close_cmd = {"id": "r1", "action": "close", "params": {}}
|
|
308
|
+
for session in sessions:
|
|
309
|
+
sock_path = get_socket_path(session)
|
|
310
|
+
try:
|
|
311
|
+
send_command(sock_path, close_cmd)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
print(f"Failed to close session {session}: {e}", file=sys.stderr)
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
# Ensure daemon is running
|
|
317
|
+
ensure_daemon(flags["session"], flags["headed"], flags["timeout"], flags["persistent"])
|
|
318
|
+
|
|
319
|
+
sock_path = get_socket_path(flags["session"])
|
|
320
|
+
|
|
321
|
+
# Send command with retry
|
|
322
|
+
last_err = ""
|
|
323
|
+
for attempt in range(5):
|
|
324
|
+
try:
|
|
325
|
+
response = send_command(sock_path, command)
|
|
326
|
+
print_response(response, flags["json"])
|
|
327
|
+
return
|
|
328
|
+
except Exception as e:
|
|
329
|
+
last_err = str(e)
|
|
330
|
+
if attempt < 4:
|
|
331
|
+
time.sleep(0.2 * (attempt + 1))
|
|
332
|
+
|
|
333
|
+
print(f"Error: Failed to connect to daemon after 5 attempts: {last_err}", file=sys.stderr)
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
USAGE = """\
|
|
338
|
+
Usage: camoufox-cli [flags] <command> [args]
|
|
339
|
+
|
|
340
|
+
Navigation:
|
|
341
|
+
open <url> Navigate to URL
|
|
342
|
+
back Go back
|
|
343
|
+
forward Go forward
|
|
344
|
+
reload Reload page
|
|
345
|
+
url Print current URL
|
|
346
|
+
title Print page title
|
|
347
|
+
close [--all] Close browser and daemon (--all: all sessions)
|
|
348
|
+
|
|
349
|
+
Snapshot:
|
|
350
|
+
snapshot [-i] [-s sel] Aria tree (-i interactive, -s scoped)
|
|
351
|
+
|
|
352
|
+
Interaction:
|
|
353
|
+
click @ref Click element
|
|
354
|
+
fill @ref "text" Clear + type into input
|
|
355
|
+
type @ref "text" Type without clearing
|
|
356
|
+
select @ref "option" Select dropdown option
|
|
357
|
+
check @ref Toggle checkbox
|
|
358
|
+
hover @ref Hover over element
|
|
359
|
+
press <key> Press key (e.g. Enter, Control+a)
|
|
360
|
+
|
|
361
|
+
Data:
|
|
362
|
+
text @ref|selector Get text content
|
|
363
|
+
eval "js expression" Execute JavaScript
|
|
364
|
+
screenshot [--full] [f] Screenshot to file or stdout
|
|
365
|
+
pdf <file> Save page as PDF
|
|
366
|
+
|
|
367
|
+
Scroll & Wait:
|
|
368
|
+
scroll <dir> [px] Scroll up/down (default 500px)
|
|
369
|
+
wait <ms|@ref|--url p> Wait for time/element/URL
|
|
370
|
+
|
|
371
|
+
Tabs:
|
|
372
|
+
tabs List open tabs
|
|
373
|
+
switch <index> Switch to tab
|
|
374
|
+
close-tab Close current tab
|
|
375
|
+
|
|
376
|
+
Session:
|
|
377
|
+
sessions List active sessions
|
|
378
|
+
cookies [import|export] Manage cookies
|
|
379
|
+
|
|
380
|
+
Flags:
|
|
381
|
+
--session <name> Session name (default: "default")
|
|
382
|
+
--headed Show browser window
|
|
383
|
+
--timeout <secs> Daemon idle timeout (default: 1800)
|
|
384
|
+
--json Output as JSON
|
|
385
|
+
--persistent <path> Use persistent browser profile"""
|
camoufox_cli/commands.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Command implementations for the daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from .browser import BrowserManager
|
|
9
|
+
from .protocol import ok_response, error_response
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def execute(manager: BrowserManager, command: dict) -> dict:
|
|
13
|
+
"""Dispatch and execute a command, return a response dict."""
|
|
14
|
+
cmd_id = command.get("id", "?")
|
|
15
|
+
action = command.get("action", "")
|
|
16
|
+
params = command.get("params", {})
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
handler = _HANDLERS.get(action)
|
|
20
|
+
if handler is None:
|
|
21
|
+
return error_response(cmd_id, f"Unknown action: {action}")
|
|
22
|
+
return handler(manager, cmd_id, params)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
return error_response(cmd_id, str(e))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Helpers
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _resolve_ref(manager: BrowserManager, ref_str: str):
|
|
32
|
+
"""Resolve a ref string to a (locator, entry) tuple, or raise."""
|
|
33
|
+
entry = manager.refs.resolve(ref_str)
|
|
34
|
+
if entry is None:
|
|
35
|
+
raise ValueError(f"Ref @{ref_str.lstrip('@')} not found. Run 'camoufox-cli snapshot' to refresh refs.")
|
|
36
|
+
page = manager.get_page()
|
|
37
|
+
locator = page.get_by_role(entry.role, name=entry.name)
|
|
38
|
+
if entry.nth > 0:
|
|
39
|
+
locator = locator.nth(entry.nth)
|
|
40
|
+
return locator
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Navigation
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def _cmd_open(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
48
|
+
url = params.get("url", "")
|
|
49
|
+
if not url:
|
|
50
|
+
return error_response(cmd_id, "Missing 'url' parameter")
|
|
51
|
+
|
|
52
|
+
if not manager.is_running:
|
|
53
|
+
manager.launch(headless=params.get("headless", True))
|
|
54
|
+
|
|
55
|
+
page = manager.get_page()
|
|
56
|
+
page.goto(url, wait_until="domcontentloaded")
|
|
57
|
+
return ok_response(cmd_id, {"url": page.url, "title": page.title()})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmd_back(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
61
|
+
manager.get_page().go_back(wait_until="domcontentloaded")
|
|
62
|
+
return ok_response(cmd_id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cmd_forward(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
66
|
+
manager.get_page().go_forward(wait_until="domcontentloaded")
|
|
67
|
+
return ok_response(cmd_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_reload(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
71
|
+
page = manager.get_page()
|
|
72
|
+
page.evaluate("location.reload()")
|
|
73
|
+
page.wait_for_load_state("domcontentloaded")
|
|
74
|
+
return ok_response(cmd_id)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cmd_url(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
78
|
+
return ok_response(cmd_id, {"url": manager.get_page().url})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_title(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
82
|
+
return ok_response(cmd_id, {"title": manager.get_page().title()})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cmd_close(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
86
|
+
manager.close()
|
|
87
|
+
return ok_response(cmd_id, {"closed": True})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Snapshot
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _cmd_snapshot(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
95
|
+
page = manager.get_page()
|
|
96
|
+
interactive = params.get("interactive", False)
|
|
97
|
+
selector = params.get("selector")
|
|
98
|
+
|
|
99
|
+
target = page.locator(selector) if selector else page.locator("body")
|
|
100
|
+
aria_text = target.aria_snapshot()
|
|
101
|
+
annotated = manager.refs.build_from_snapshot(aria_text, interactive_only=interactive)
|
|
102
|
+
return ok_response(cmd_id, {"snapshot": annotated})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Interaction
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _cmd_click(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
110
|
+
ref_str = params.get("ref", "")
|
|
111
|
+
if not ref_str:
|
|
112
|
+
return error_response(cmd_id, "Missing 'ref' parameter")
|
|
113
|
+
_resolve_ref(manager, ref_str).click()
|
|
114
|
+
return ok_response(cmd_id)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmd_fill(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
118
|
+
ref_str = params.get("ref", "")
|
|
119
|
+
text = params.get("text", "")
|
|
120
|
+
if not ref_str:
|
|
121
|
+
return error_response(cmd_id, "Missing 'ref' parameter")
|
|
122
|
+
_resolve_ref(manager, ref_str).fill(text)
|
|
123
|
+
return ok_response(cmd_id)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cmd_type(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
127
|
+
ref_str = params.get("ref", "")
|
|
128
|
+
text = params.get("text", "")
|
|
129
|
+
if not ref_str:
|
|
130
|
+
return error_response(cmd_id, "Missing 'ref' parameter")
|
|
131
|
+
_resolve_ref(manager, ref_str).press_sequentially(text)
|
|
132
|
+
return ok_response(cmd_id)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_select(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
136
|
+
ref_str = params.get("ref", "")
|
|
137
|
+
value = params.get("value", "")
|
|
138
|
+
if not ref_str:
|
|
139
|
+
return error_response(cmd_id, "Missing 'ref' parameter")
|
|
140
|
+
_resolve_ref(manager, ref_str).select_option(label=value)
|
|
141
|
+
return ok_response(cmd_id)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _cmd_check(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
145
|
+
ref_str = params.get("ref", "")
|
|
146
|
+
if not ref_str:
|
|
147
|
+
return error_response(cmd_id, "Missing 'ref' parameter")
|
|
148
|
+
locator = _resolve_ref(manager, ref_str)
|
|
149
|
+
if locator.is_checked():
|
|
150
|
+
locator.uncheck()
|
|
151
|
+
else:
|
|
152
|
+
locator.check()
|
|
153
|
+
return ok_response(cmd_id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cmd_hover(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
157
|
+
ref_str = params.get("ref", "")
|
|
158
|
+
if not ref_str:
|
|
159
|
+
return error_response(cmd_id, "Missing 'ref' parameter")
|
|
160
|
+
_resolve_ref(manager, ref_str).hover()
|
|
161
|
+
return ok_response(cmd_id)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _cmd_press(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
165
|
+
key = params.get("key", "")
|
|
166
|
+
if not key:
|
|
167
|
+
return error_response(cmd_id, "Missing 'key' parameter")
|
|
168
|
+
manager.get_page().keyboard.press(key)
|
|
169
|
+
return ok_response(cmd_id)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Data extraction
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def _cmd_text(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
177
|
+
target = params.get("target", "")
|
|
178
|
+
if not target:
|
|
179
|
+
return error_response(cmd_id, "Missing 'target' parameter")
|
|
180
|
+
|
|
181
|
+
if target.startswith("@"):
|
|
182
|
+
text = _resolve_ref(manager, target).text_content() or ""
|
|
183
|
+
else:
|
|
184
|
+
text = manager.get_page().locator(target).text_content() or ""
|
|
185
|
+
|
|
186
|
+
return ok_response(cmd_id, {"text": text})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _cmd_eval(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
190
|
+
expression = params.get("expression", "")
|
|
191
|
+
if not expression:
|
|
192
|
+
return error_response(cmd_id, "Missing 'expression' parameter")
|
|
193
|
+
result = manager.get_page().evaluate(expression)
|
|
194
|
+
return ok_response(cmd_id, {"result": result})
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _cmd_screenshot(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
198
|
+
page = manager.get_page()
|
|
199
|
+
path = params.get("path")
|
|
200
|
+
full_page = params.get("full_page", False)
|
|
201
|
+
|
|
202
|
+
if path:
|
|
203
|
+
page.screenshot(path=path, full_page=full_page)
|
|
204
|
+
return ok_response(cmd_id, {"path": path})
|
|
205
|
+
else:
|
|
206
|
+
buf = page.screenshot(full_page=full_page)
|
|
207
|
+
b64 = base64.b64encode(buf).decode("ascii")
|
|
208
|
+
return ok_response(cmd_id, {"base64": b64})
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _cmd_pdf(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
212
|
+
return error_response(
|
|
213
|
+
cmd_id,
|
|
214
|
+
"PDF export is not supported with Firefox/Camoufox. Use 'screenshot --full' instead.",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Scroll & Wait
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
def _cmd_scroll(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
223
|
+
direction = params.get("direction", "down")
|
|
224
|
+
amount = int(params.get("amount", 500))
|
|
225
|
+
|
|
226
|
+
if direction == "up":
|
|
227
|
+
amount = -amount
|
|
228
|
+
|
|
229
|
+
manager.get_page().evaluate(f"window.scrollBy(0, {amount})")
|
|
230
|
+
return ok_response(cmd_id)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _cmd_wait(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
234
|
+
page = manager.get_page()
|
|
235
|
+
|
|
236
|
+
if "ms" in params:
|
|
237
|
+
page.wait_for_timeout(int(params["ms"]))
|
|
238
|
+
elif "ref" in params:
|
|
239
|
+
_resolve_ref(manager, params["ref"]).wait_for()
|
|
240
|
+
elif "selector" in params:
|
|
241
|
+
page.wait_for_selector(params["selector"])
|
|
242
|
+
elif "url" in params:
|
|
243
|
+
page.wait_for_url(params["url"])
|
|
244
|
+
else:
|
|
245
|
+
return error_response(cmd_id, "wait requires ms, ref, selector, or url parameter")
|
|
246
|
+
|
|
247
|
+
return ok_response(cmd_id)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# Tab management
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def _cmd_tabs(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
255
|
+
return ok_response(cmd_id, {"tabs": manager.get_tabs()})
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _cmd_switch(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
259
|
+
index = params.get("index")
|
|
260
|
+
if index is None:
|
|
261
|
+
return error_response(cmd_id, "Missing 'index' parameter")
|
|
262
|
+
page = manager.switch_to_tab(int(index))
|
|
263
|
+
return ok_response(cmd_id, {"url": page.url, "title": page.title()})
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _cmd_close_tab(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
267
|
+
manager.close_current_tab()
|
|
268
|
+
page = manager.get_page()
|
|
269
|
+
return ok_response(cmd_id, {"url": page.url, "title": page.title()})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# Cookies
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def _cmd_cookies(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
|
|
277
|
+
ctx = manager.get_context()
|
|
278
|
+
op = params.get("op", "list")
|
|
279
|
+
|
|
280
|
+
if op == "list":
|
|
281
|
+
cookies = ctx.cookies()
|
|
282
|
+
return ok_response(cmd_id, {"cookies": cookies})
|
|
283
|
+
|
|
284
|
+
elif op == "export":
|
|
285
|
+
path = params.get("path", "")
|
|
286
|
+
if not path:
|
|
287
|
+
return error_response(cmd_id, "Missing 'path' parameter for export")
|
|
288
|
+
cookies = ctx.cookies()
|
|
289
|
+
with open(path, "w") as f:
|
|
290
|
+
json.dump(cookies, f, indent=2)
|
|
291
|
+
return ok_response(cmd_id, {"path": path, "count": len(cookies)})
|
|
292
|
+
|
|
293
|
+
elif op == "import":
|
|
294
|
+
path = params.get("path", "")
|
|
295
|
+
if not path:
|
|
296
|
+
return error_response(cmd_id, "Missing 'path' parameter for import")
|
|
297
|
+
with open(path) as f:
|
|
298
|
+
cookies = json.load(f)
|
|
299
|
+
ctx.add_cookies(cookies)
|
|
300
|
+
return ok_response(cmd_id, {"count": len(cookies)})
|
|
301
|
+
|
|
302
|
+
else:
|
|
303
|
+
return error_response(cmd_id, f"Unknown cookies op: {op}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Handler dispatch table
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
_HANDLERS = {
|
|
311
|
+
# Navigation
|
|
312
|
+
"open": _cmd_open,
|
|
313
|
+
"back": _cmd_back,
|
|
314
|
+
"forward": _cmd_forward,
|
|
315
|
+
"reload": _cmd_reload,
|
|
316
|
+
"url": _cmd_url,
|
|
317
|
+
"title": _cmd_title,
|
|
318
|
+
"close": _cmd_close,
|
|
319
|
+
# Snapshot
|
|
320
|
+
"snapshot": _cmd_snapshot,
|
|
321
|
+
# Interaction
|
|
322
|
+
"click": _cmd_click,
|
|
323
|
+
"fill": _cmd_fill,
|
|
324
|
+
"type": _cmd_type,
|
|
325
|
+
"select": _cmd_select,
|
|
326
|
+
"check": _cmd_check,
|
|
327
|
+
"hover": _cmd_hover,
|
|
328
|
+
"press": _cmd_press,
|
|
329
|
+
# Data extraction
|
|
330
|
+
"text": _cmd_text,
|
|
331
|
+
"eval": _cmd_eval,
|
|
332
|
+
"screenshot": _cmd_screenshot,
|
|
333
|
+
"pdf": _cmd_pdf,
|
|
334
|
+
# Scroll & Wait
|
|
335
|
+
"scroll": _cmd_scroll,
|
|
336
|
+
"wait": _cmd_wait,
|
|
337
|
+
# Tab management
|
|
338
|
+
"tabs": _cmd_tabs,
|
|
339
|
+
"switch": _cmd_switch,
|
|
340
|
+
"close-tab": _cmd_close_tab,
|
|
341
|
+
# Cookies
|
|
342
|
+
"cookies": _cmd_cookies,
|
|
343
|
+
}
|
camoufox_cli/protocol.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""JSON-line protocol for CLI <-> Daemon communication."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_command(line: str) -> dict:
|
|
7
|
+
"""Parse a JSON-line command from the CLI."""
|
|
8
|
+
return json.loads(line.strip())
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def serialize_response(response: dict) -> bytes:
|
|
12
|
+
"""Serialize a response dict to JSON-line bytes."""
|
|
13
|
+
return json.dumps(response, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ok_response(id: str, data: dict | None = None) -> dict:
|
|
17
|
+
resp = {"id": id, "success": True}
|
|
18
|
+
if data is not None:
|
|
19
|
+
resp["data"] = data
|
|
20
|
+
return resp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def error_response(id: str, error: str) -> dict:
|
|
24
|
+
return {"id": id, "success": False, "error": error}
|
camoufox_cli/refs.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Ref registry: maps @e1, @e2 to aria role+name for Playwright locators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class RefEntry:
|
|
11
|
+
ref: str # e.g. "e1"
|
|
12
|
+
role: str # e.g. "link"
|
|
13
|
+
name: str # e.g. "About"
|
|
14
|
+
nth: int = 0 # index for duplicates
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Roles considered "interactive" for snapshot -i
|
|
18
|
+
INTERACTIVE_ROLES = frozenset({
|
|
19
|
+
"link", "button", "combobox", "textbox", "textarea",
|
|
20
|
+
"checkbox", "radio", "switch", "slider",
|
|
21
|
+
"tab", "tabpanel", "menuitem", "option",
|
|
22
|
+
"select", "listbox", "searchbox",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
# Pattern to match aria snapshot lines like: - link "About"
|
|
26
|
+
# handles nested indentation and optional attributes
|
|
27
|
+
_ARIA_LINE_RE = re.compile(
|
|
28
|
+
r'^(\s*-\s+)' # leading indent + dash
|
|
29
|
+
r'(\w+)' # role
|
|
30
|
+
r'(?:\s+"([^"]*)")?' # optional quoted name
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RefRegistry:
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self._entries: dict[str, RefEntry] = {} # ref_str -> RefEntry
|
|
37
|
+
self._counter = 0
|
|
38
|
+
|
|
39
|
+
def build_from_snapshot(self, aria_text: str, interactive_only: bool = False) -> str:
|
|
40
|
+
"""Parse aria snapshot text, assign refs, return annotated text."""
|
|
41
|
+
self._entries.clear()
|
|
42
|
+
self._counter = 0
|
|
43
|
+
|
|
44
|
+
# Track role+name occurrences for nth disambiguation
|
|
45
|
+
seen: dict[tuple[str, str], int] = {}
|
|
46
|
+
lines = aria_text.split("\n")
|
|
47
|
+
result_lines = []
|
|
48
|
+
|
|
49
|
+
for line in lines:
|
|
50
|
+
m = _ARIA_LINE_RE.match(line)
|
|
51
|
+
if not m:
|
|
52
|
+
if not interactive_only:
|
|
53
|
+
result_lines.append(line)
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
role = m.group(2)
|
|
57
|
+
name = m.group(3) or ""
|
|
58
|
+
|
|
59
|
+
if interactive_only and role not in INTERACTIVE_ROLES:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
key = (role, name)
|
|
63
|
+
nth = seen.get(key, 0)
|
|
64
|
+
seen[key] = nth + 1
|
|
65
|
+
|
|
66
|
+
self._counter += 1
|
|
67
|
+
ref = f"e{self._counter}"
|
|
68
|
+
entry = RefEntry(ref=ref, role=role, name=name, nth=nth)
|
|
69
|
+
self._entries[ref] = entry
|
|
70
|
+
|
|
71
|
+
# Append [ref=eN] to the line
|
|
72
|
+
annotated = f"{line.rstrip()} [ref={ref}]"
|
|
73
|
+
result_lines.append(annotated)
|
|
74
|
+
|
|
75
|
+
return "\n".join(result_lines)
|
|
76
|
+
|
|
77
|
+
def resolve(self, ref_str: str) -> RefEntry | None:
|
|
78
|
+
"""Resolve a ref string like 'e1' or '@e1' to a RefEntry."""
|
|
79
|
+
ref = ref_str.lstrip("@")
|
|
80
|
+
return self._entries.get(ref)
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
return len(self._entries)
|
camoufox_cli/server.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Unix socket server for the camoufox-cli daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from .browser import BrowserManager
|
|
13
|
+
from .commands import execute
|
|
14
|
+
from .protocol import parse_command, serialize_response
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DaemonServer:
|
|
18
|
+
def __init__(self, session: str = "default", headless: bool = True, timeout: int = 1800, persistent: str | None = None):
|
|
19
|
+
self.session = session
|
|
20
|
+
self.headless = headless
|
|
21
|
+
self.timeout = timeout # idle timeout in seconds
|
|
22
|
+
self.socket_path = f"/tmp/camoufox-cli-{session}.sock"
|
|
23
|
+
self.pid_path = f"/tmp/camoufox-cli-{session}.pid"
|
|
24
|
+
self.manager = BrowserManager(persistent=persistent)
|
|
25
|
+
self._server_socket: socket.socket | None = None
|
|
26
|
+
self._last_activity = time.time()
|
|
27
|
+
self._running = False
|
|
28
|
+
|
|
29
|
+
def start(self) -> None:
|
|
30
|
+
self._cleanup_stale()
|
|
31
|
+
self._write_pid()
|
|
32
|
+
self._running = True
|
|
33
|
+
|
|
34
|
+
# Start idle timeout watchdog
|
|
35
|
+
watchdog = threading.Thread(target=self._idle_watchdog, daemon=True)
|
|
36
|
+
watchdog.start()
|
|
37
|
+
|
|
38
|
+
# Set up signal handlers
|
|
39
|
+
signal.signal(signal.SIGTERM, self._handle_signal)
|
|
40
|
+
signal.signal(signal.SIGINT, self._handle_signal)
|
|
41
|
+
|
|
42
|
+
self._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
43
|
+
try:
|
|
44
|
+
self._server_socket.bind(self.socket_path)
|
|
45
|
+
self._server_socket.listen(5)
|
|
46
|
+
self._server_socket.settimeout(1.0) # allow periodic checks
|
|
47
|
+
|
|
48
|
+
while self._running:
|
|
49
|
+
try:
|
|
50
|
+
conn, _ = self._server_socket.accept()
|
|
51
|
+
except socket.timeout:
|
|
52
|
+
continue
|
|
53
|
+
except OSError:
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
self._last_activity = time.time()
|
|
57
|
+
try:
|
|
58
|
+
self._handle_connection(conn)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"[camoufox-cli] Connection error: {e}", file=sys.stderr)
|
|
61
|
+
finally:
|
|
62
|
+
conn.close()
|
|
63
|
+
finally:
|
|
64
|
+
self._shutdown()
|
|
65
|
+
|
|
66
|
+
def _handle_connection(self, conn: socket.socket) -> None:
|
|
67
|
+
data = b""
|
|
68
|
+
while True:
|
|
69
|
+
chunk = conn.recv(4096)
|
|
70
|
+
if not chunk:
|
|
71
|
+
break
|
|
72
|
+
data += chunk
|
|
73
|
+
if b"\n" in data:
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
line = data.decode("utf-8").strip()
|
|
77
|
+
if not line:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
command = parse_command(line)
|
|
81
|
+
|
|
82
|
+
# Pass headless preference to open commands
|
|
83
|
+
if command.get("action") == "open":
|
|
84
|
+
command.setdefault("params", {}).setdefault("headless", self.headless)
|
|
85
|
+
|
|
86
|
+
response = execute(self.manager, command)
|
|
87
|
+
conn.sendall(serialize_response(response))
|
|
88
|
+
|
|
89
|
+
# If close command, shut down the daemon
|
|
90
|
+
if command.get("action") == "close":
|
|
91
|
+
self._running = False
|
|
92
|
+
|
|
93
|
+
def _idle_watchdog(self) -> None:
|
|
94
|
+
while self._running:
|
|
95
|
+
time.sleep(10)
|
|
96
|
+
if time.time() - self._last_activity > self.timeout:
|
|
97
|
+
print(f"[camoufox-cli] Idle timeout ({self.timeout}s), shutting down", file=sys.stderr)
|
|
98
|
+
self._running = False
|
|
99
|
+
# Nudge the accept() loop
|
|
100
|
+
try:
|
|
101
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
102
|
+
s.connect(self.socket_path)
|
|
103
|
+
s.close()
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
def _handle_signal(self, signum, frame):
|
|
109
|
+
self._running = False
|
|
110
|
+
|
|
111
|
+
def _shutdown(self) -> None:
|
|
112
|
+
self.manager.close()
|
|
113
|
+
if self._server_socket:
|
|
114
|
+
try:
|
|
115
|
+
self._server_socket.close()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
self._cleanup_files()
|
|
119
|
+
|
|
120
|
+
def _cleanup_stale(self) -> None:
|
|
121
|
+
"""Remove stale socket file if no daemon is running."""
|
|
122
|
+
if os.path.exists(self.socket_path):
|
|
123
|
+
# Check if another daemon is using it
|
|
124
|
+
if os.path.exists(self.pid_path):
|
|
125
|
+
try:
|
|
126
|
+
with open(self.pid_path) as f:
|
|
127
|
+
pid = int(f.read().strip())
|
|
128
|
+
os.kill(pid, 0)
|
|
129
|
+
# Process exists — abort
|
|
130
|
+
print(f"[camoufox-cli] Daemon already running (pid {pid})", file=sys.stderr)
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
except (ProcessLookupError, ValueError):
|
|
133
|
+
pass # stale pid, clean up
|
|
134
|
+
os.unlink(self.socket_path)
|
|
135
|
+
|
|
136
|
+
def _write_pid(self) -> None:
|
|
137
|
+
with open(self.pid_path, "w") as f:
|
|
138
|
+
f.write(str(os.getpid()))
|
|
139
|
+
|
|
140
|
+
def _cleanup_files(self) -> None:
|
|
141
|
+
for path in (self.socket_path, self.pid_path):
|
|
142
|
+
try:
|
|
143
|
+
os.unlink(path)
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
pass
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: camoufox-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Anti-detect browser CLI for AI agents, powered by Camoufox
|
|
5
|
+
Project-URL: Homepage, https://github.com/Bin-Huang/camoufox-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/Bin-Huang/camoufox-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/Bin-Huang/camoufox-cli/issues
|
|
8
|
+
Author: Bin Huang
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agent,anti-detect,automation,browser,camoufox,headless
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: camoufox[geoip]
|
|
19
|
+
Requires-Dist: playwright
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# camoufox-cli
|
|
23
|
+
|
|
24
|
+
Anti-detect browser CLI for AI agents, powered by [Camoufox](https://github.com/daijro/camoufox).
|
|
25
|
+
|
|
26
|
+
Camoufox has C++-level fingerprint spoofing (`navigator.webdriver=false`, randomized canvas/WebGL/audio, real plugins) but only exposes a Python API. This CLI wraps it into a simple command interface optimized for AI agent tool calls — snapshot the accessibility tree, interact by ref, repeat.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pipx install camoufox-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or with pip:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install camoufox-cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
camoufox-cli open https://example.com # Launch browser & navigate
|
|
44
|
+
camoufox-cli snapshot -i # Interactive elements only
|
|
45
|
+
# - link "More information..." [ref=e1]
|
|
46
|
+
camoufox-cli click @e1 # Click by ref
|
|
47
|
+
camoufox-cli close # Done
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
### Navigation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
camoufox-cli open <url> # Navigate to URL (starts daemon if needed)
|
|
56
|
+
camoufox-cli back # Go back
|
|
57
|
+
camoufox-cli forward # Go forward
|
|
58
|
+
camoufox-cli reload # Reload page
|
|
59
|
+
camoufox-cli url # Print current URL
|
|
60
|
+
camoufox-cli title # Print page title
|
|
61
|
+
camoufox-cli close # Close browser and stop daemon
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Snapshot
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
camoufox-cli snapshot # Full accessibility tree
|
|
68
|
+
camoufox-cli snapshot -i # Interactive elements only
|
|
69
|
+
camoufox-cli snapshot -s "css-selector" # Scoped to CSS selector
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Output format:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
- heading "Example Domain" [level=1] [ref=e1]
|
|
76
|
+
- paragraph [ref=e2]
|
|
77
|
+
- link "More information..." [ref=e3]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Interaction
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
camoufox-cli click @e1 # Click element
|
|
84
|
+
camoufox-cli fill @e3 "search query" # Clear + type into input
|
|
85
|
+
camoufox-cli type @e3 "append text" # Type without clearing
|
|
86
|
+
camoufox-cli select @e5 "option text" # Select dropdown option
|
|
87
|
+
camoufox-cli check @e6 # Toggle checkbox
|
|
88
|
+
camoufox-cli hover @e2 # Hover over element
|
|
89
|
+
camoufox-cli press Enter # Press keyboard key
|
|
90
|
+
camoufox-cli press "Control+a" # Key combination
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Data Extraction
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
camoufox-cli text @e1 # Get text content of element
|
|
97
|
+
camoufox-cli text body # Get all page text
|
|
98
|
+
camoufox-cli eval "document.title" # Execute JavaScript
|
|
99
|
+
camoufox-cli screenshot # Screenshot (base64 to stdout)
|
|
100
|
+
camoufox-cli screenshot page.png # Screenshot to file
|
|
101
|
+
camoufox-cli screenshot --full page.png # Full page screenshot
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Scroll & Wait
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
camoufox-cli scroll down # Scroll down 500px
|
|
108
|
+
camoufox-cli scroll up 1000 # Scroll up 1000px
|
|
109
|
+
camoufox-cli wait 2000 # Wait milliseconds
|
|
110
|
+
camoufox-cli wait @e1 # Wait for element to appear
|
|
111
|
+
camoufox-cli wait --url "*/dashboard" # Wait for URL pattern
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Tabs
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
camoufox-cli tabs # List open tabs
|
|
118
|
+
camoufox-cli switch 2 # Switch to tab by index
|
|
119
|
+
camoufox-cli close-tab # Close current tab
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Sessions
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
camoufox-cli sessions # List active sessions
|
|
126
|
+
camoufox-cli --session work open <url> # Use named session
|
|
127
|
+
camoufox-cli close --all # Close all sessions
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Cookies
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
camoufox-cli cookies # Dump cookies as JSON
|
|
134
|
+
camoufox-cli cookies import file.json # Import cookies
|
|
135
|
+
camoufox-cli cookies export file.json # Export cookies
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Flags
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
--session <name> Named session (default: "default")
|
|
142
|
+
--headed Show browser window (default: headless)
|
|
143
|
+
--timeout <seconds> Daemon idle timeout (default: 1800)
|
|
144
|
+
--json Output as JSON
|
|
145
|
+
--persistent <path> Use persistent browser profile directory
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Architecture
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
CLI (camoufox-cli) ──Unix socket──▶ Daemon (Python) ──Playwright──▶ Camoufox (Firefox)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The CLI sends JSON commands to a long-running daemon process via Unix socket. The daemon manages the Camoufox browser instance and maintains the ref registry between commands. The daemon auto-starts on the first command and auto-stops after 30 minutes of inactivity.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
camoufox_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
camoufox_cli/__main__.py,sha256=LW3Uey5g-nZ09cxETsLRs13KMxxWmTmhzEu0BInHeZA,1039
|
|
3
|
+
camoufox_cli/browser.py,sha256=Urol-couWtcg4BxtlsPuqg-fnYByPRkvW1uEAtcMLoM,3289
|
|
4
|
+
camoufox_cli/cli.py,sha256=Qu5BDTDTRsHDCEIC7qkU7ipMw0kfz1TkiTFIqkwS4ro,13777
|
|
5
|
+
camoufox_cli/commands.py,sha256=UxdAbGDu0KOYZIdCc511-3_Otl4PykSHhHH66b1eLpo,11556
|
|
6
|
+
camoufox_cli/protocol.py,sha256=vRpQPLxS4s5chA57bqdM9MgrP-wTlRGmiZm2V-QMtuI,658
|
|
7
|
+
camoufox_cli/refs.py,sha256=2qldrUQe3LWXvEAGvF40VjomTWui8EzfUg_GTaGeR_M,2509
|
|
8
|
+
camoufox_cli/server.py,sha256=p4rL-ha83RnRf8CnLJ7kjEB32ocMAxInSEgJ8G6kWak,4888
|
|
9
|
+
camoufox_cli-0.1.0.dist-info/METADATA,sha256=dLwSD9MgcddbClVMldRDsZhEN22r9L04h9R-PmKAAgo,5171
|
|
10
|
+
camoufox_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
camoufox_cli-0.1.0.dist-info/entry_points.txt,sha256=xB8yicErztODnNm6aPmBX00JC46jh4HvwtppdoG0op0,55
|
|
12
|
+
camoufox_cli-0.1.0.dist-info/licenses/LICENSE,sha256=oW4W62E_nL4Ybo4equL-rDJ-7R6zX-e4shGYeIWj86I,1066
|
|
13
|
+
camoufox_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Bin Huang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|