browser-agent-server 1.0.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.
- browser_agent/__init__.py +128 -0
- browser_agent/chrome.py +591 -0
- browser_agent/cli.py +414 -0
- browser_agent/data/browser-agent.service +26 -0
- browser_agent/server.py +250 -0
- browser_agent/vision_agent.py +165 -0
- browser_agent/x11_input.py +273 -0
- browser_agent_server-1.0.0.dist-info/METADATA +98 -0
- browser_agent_server-1.0.0.dist-info/RECORD +12 -0
- browser_agent_server-1.0.0.dist-info/WHEEL +4 -0
- browser_agent_server-1.0.0.dist-info/entry_points.txt +2 -0
- browser_agent_server-1.0.0.dist-info/licenses/LICENSE +21 -0
browser_agent/cli.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Command line interface: ``browser-agent …``.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
serve Run the localhost HTTP daemon (what systemd starts)
|
|
5
|
+
status Show daemon & X11 status
|
|
6
|
+
stop Hibernate Chrome and Xvfb immediately
|
|
7
|
+
fetch Open a URL in real Chrome and print text/html/JSON
|
|
8
|
+
click / move / type Hardware-level X11 input
|
|
9
|
+
agent Vision-guided computer-use loop
|
|
10
|
+
vnc Start/stop localhost VNC for live viewing over SSH
|
|
11
|
+
setup Provision a Linux host (apt packages, Google Chrome, systemd unit)
|
|
12
|
+
doctor Self-check this host and the running daemon
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import base64
|
|
18
|
+
import getpass
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from importlib import resources
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .chrome import find_chrome_binary, is_warp_proxy_ready
|
|
31
|
+
from .server import API_VERSION, DEFAULT_HOST, DEFAULT_IDLE_TIMEOUT, DEFAULT_PORT, serve
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("browser_agent.cli")
|
|
34
|
+
|
|
35
|
+
APT_PACKAGES = ("xvfb", "openbox", "xdotool", "scrot", "x11vnc")
|
|
36
|
+
STATE_HOME = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "browser-agent"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# HTTP helpers for client-side subcommands
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _api_request(
|
|
44
|
+
method: str,
|
|
45
|
+
path: str,
|
|
46
|
+
payload: dict | None = None,
|
|
47
|
+
port: int = DEFAULT_PORT,
|
|
48
|
+
timeout: float = 90.0,
|
|
49
|
+
) -> dict | None:
|
|
50
|
+
url = f"http://{DEFAULT_HOST}:{port}{path}"
|
|
51
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
52
|
+
headers = {"Content-Type": "application/json"} if data is not None else {}
|
|
53
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
54
|
+
try:
|
|
55
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
56
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
57
|
+
except urllib.error.HTTPError as e:
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(e.read().decode("utf-8"))
|
|
60
|
+
except Exception: # noqa: BLE001
|
|
61
|
+
return {"ok": False, "error": str(e)}
|
|
62
|
+
except OSError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# setup
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _load_unit_template() -> str:
|
|
71
|
+
return (
|
|
72
|
+
resources.files("browser_agent.data")
|
|
73
|
+
.joinpath("browser-agent.service")
|
|
74
|
+
.read_text(encoding="utf-8")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run(cmd: list[str], sudo: list[str], dry_run: bool = False) -> bool:
|
|
79
|
+
full = sudo + cmd
|
|
80
|
+
print(f" $ {' '.join(full)}")
|
|
81
|
+
if dry_run:
|
|
82
|
+
return True
|
|
83
|
+
res = subprocess.run(full, check=False)
|
|
84
|
+
return res.returncode == 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _install_chrome_apt(sudo: list[str], dry_run: bool) -> bool:
|
|
88
|
+
"""Add Google's apt repo and install google-chrome-stable."""
|
|
89
|
+
steps = [
|
|
90
|
+
"install -m 0755 -d /usr/share/keyrings",
|
|
91
|
+
"sh -c 'curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg'",
|
|
92
|
+
"sh -c 'echo deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main > /etc/apt/sources.list.d/google-chrome.list'",
|
|
93
|
+
"apt-get update",
|
|
94
|
+
"apt-get install -y google-chrome-stable",
|
|
95
|
+
]
|
|
96
|
+
ok = True
|
|
97
|
+
for s in steps:
|
|
98
|
+
ok = _run(["sh", "-c", s] if s.startswith(("install", "sh")) else s.split(), sudo, dry_run) and ok
|
|
99
|
+
return ok
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
103
|
+
dry = args.dry_run
|
|
104
|
+
euid = os.geteuid() if hasattr(os, "geteuid") else None
|
|
105
|
+
sudo: list[str] = [] if euid == 0 else (["sudo"] if shutil.which("sudo") else [])
|
|
106
|
+
if euid != 0 and not sudo:
|
|
107
|
+
print("setup needs root (or sudo). Re-run as root.", file=sys.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
|
|
110
|
+
print("== browser-agent setup ==")
|
|
111
|
+
|
|
112
|
+
if not args.no_apt:
|
|
113
|
+
print(f"-> apt packages: {' '.join(APT_PACKAGES)}")
|
|
114
|
+
_run(["apt-get", "update"], sudo, dry)
|
|
115
|
+
_run(["apt-get", "install", "-y", *APT_PACKAGES], sudo, dry)
|
|
116
|
+
else:
|
|
117
|
+
print("-> skipping apt (--no-apt)")
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
chrome = find_chrome_binary()
|
|
121
|
+
print(f"-> chrome found: {chrome}")
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
chrome = ""
|
|
124
|
+
if args.with_chrome:
|
|
125
|
+
print("-> installing google-chrome-stable from Google's apt repo")
|
|
126
|
+
_install_chrome_apt(sudo, dry)
|
|
127
|
+
else:
|
|
128
|
+
print("-> WARNING: no Chrome/Chromium binary found.", file=sys.stderr)
|
|
129
|
+
print(" Re-run with --with-chrome to install google-chrome-stable automatically,", file=sys.stderr)
|
|
130
|
+
print(" or install it yourself: https://www.google.com/chrome/", file=sys.stderr)
|
|
131
|
+
|
|
132
|
+
if args.no_unit:
|
|
133
|
+
print("-> skipping systemd unit (--no-unit)")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
exec_start = shutil.which("browser-agent") or f"{sys.executable} -m browser_agent.server"
|
|
137
|
+
env_file = args.env_file
|
|
138
|
+
unit = (
|
|
139
|
+
_load_unit_template()
|
|
140
|
+
.replace("{{EXEC_START}}", exec_start)
|
|
141
|
+
.replace("{{ENV_FILE}}", env_file)
|
|
142
|
+
.replace("{{USER}}", args.user or getpass.getuser())
|
|
143
|
+
.replace("{{PORT}}", str(args.port))
|
|
144
|
+
.replace("{{IDLE}}", str(args.idle_timeout))
|
|
145
|
+
.replace("{{MEMORY_HIGH}}", args.memory_high)
|
|
146
|
+
.replace("{{MEMORY_MAX}}", args.memory_max)
|
|
147
|
+
)
|
|
148
|
+
unit_path = Path("/etc/systemd/system/browser-agent.service")
|
|
149
|
+
print(f"-> writing {unit_path}")
|
|
150
|
+
print(" ExecStart:", exec_start)
|
|
151
|
+
if not dry:
|
|
152
|
+
proc = subprocess.run(
|
|
153
|
+
[*sudo, "tee", str(unit_path)], input=unit.encode(), stdout=subprocess.DEVNULL, check=False
|
|
154
|
+
)
|
|
155
|
+
if proc.returncode != 0:
|
|
156
|
+
print("failed to write the unit file", file=sys.stderr)
|
|
157
|
+
return 1
|
|
158
|
+
|
|
159
|
+
env_path = Path(env_file)
|
|
160
|
+
if not env_path.exists() and str(env_path).startswith("/etc/"):
|
|
161
|
+
seed = "# browser-agent environment (chmod 600 — put API keys here)\n# GEMINI_API_KEYS=key1,key2\n"
|
|
162
|
+
subprocess.run([*sudo, "sh", "-c", f"printf '%s' '{seed}' > {env_path} && chmod 600 {env_path}"], check=False)
|
|
163
|
+
|
|
164
|
+
_run(["systemctl", "daemon-reload"], sudo)
|
|
165
|
+
if not args.no_start:
|
|
166
|
+
_run(["systemctl", "enable", "--now", "browser-agent.service"], sudo)
|
|
167
|
+
|
|
168
|
+
print()
|
|
169
|
+
print("Done. Next steps:")
|
|
170
|
+
print(f" 1. Put Gemini key(s) into {env_file} if you plan to use `browser-agent agent`")
|
|
171
|
+
print(" 2. Run: browser-agent doctor")
|
|
172
|
+
print(" 3. Optional live view: browser-agent vnc start (+ ssh -L 5900:127.0.0.1:5900 …)")
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# doctor
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _check(name: str, ok: bool, detail: str = "", required: bool = True) -> dict:
|
|
181
|
+
return {"name": name, "ok": bool(ok), "required": required, "detail": detail}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
185
|
+
checks: list[dict] = []
|
|
186
|
+
|
|
187
|
+
vi = sys.version_info
|
|
188
|
+
checks.append(_check("python>=3.12", vi >= (3, 12), f"running {vi.major}.{vi.minor}.{vi.micro}"))
|
|
189
|
+
|
|
190
|
+
for b in ("Xvfb", "openbox", "xdotool", "scrot"):
|
|
191
|
+
p = shutil.which(b)
|
|
192
|
+
checks.append(_check(f"binary:{b}", bool(p), p or "not found on PATH"))
|
|
193
|
+
p = shutil.which("x11vnc")
|
|
194
|
+
checks.append(_check("binary:x11vnc", bool(p), p or "optional — needed only for `vnc`", required=False))
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
chrome = find_chrome_binary()
|
|
198
|
+
checks.append(_check("chrome-binary", True, chrome))
|
|
199
|
+
except FileNotFoundError as e:
|
|
200
|
+
checks.append(_check("chrome-binary", False, str(e).split(".")[0]))
|
|
201
|
+
|
|
202
|
+
lock = Path("/tmp/.X99-lock")
|
|
203
|
+
checks.append(_check("display :99 free", not lock.exists(), "stale /tmp/.X99-lock present" if lock.exists() else "no lock", False))
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
STATE_HOME.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
checks.append(_check("state dir writable", os.access(STATE_HOME, os.W_OK), str(STATE_HOME)))
|
|
208
|
+
except OSError as e:
|
|
209
|
+
checks.append(_check("state dir writable", False, f"{STATE_HOME}: {e}"))
|
|
210
|
+
|
|
211
|
+
total_gb = 0.0
|
|
212
|
+
try:
|
|
213
|
+
for line in Path("/proc/meminfo").read_text().splitlines():
|
|
214
|
+
if line.startswith("MemTotal"):
|
|
215
|
+
total_gb = int(line.split()[1]) / 1024 / 1024
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
checks.append(_check(
|
|
219
|
+
"ram", total_gb >= 1.4 or total_gb == 0,
|
|
220
|
+
f"{total_gb:.1f} GB total" + (" — tight; keep MemoryMax low and avoid parallel tabs" if 0 < total_gb < 1.4 else ""),
|
|
221
|
+
required=False,
|
|
222
|
+
))
|
|
223
|
+
|
|
224
|
+
checks.append(_check("warp proxy (127.0.0.1:40000)", is_warp_proxy_ready(), "optional — fallback when the datacenter IP is blocked", False))
|
|
225
|
+
|
|
226
|
+
keys = [v for v in ("GEMINI_API_KEY", "GEMINI_API_KEYS", "LLM_API_KEY") if os.environ.get(v)]
|
|
227
|
+
keys += [f"GEMINI_API_KEY_{i}" for i in range(1, 11) if os.environ.get(f"GEMINI_API_KEY_{i}")]
|
|
228
|
+
checks.append(_check("gemini key(s) for `agent`", bool(keys), ", ".join(keys) if keys else "optional — vision agent needs one", False))
|
|
229
|
+
|
|
230
|
+
unit_active = subprocess.run(["systemctl", "is-active", "browser-agent.service"], capture_output=True, text=True, check=False).stdout.strip() if shutil.which("systemctl") else "n/a"
|
|
231
|
+
checks.append(_check("systemd unit", unit_active == "active", f"is-active: {unit_active}", False))
|
|
232
|
+
|
|
233
|
+
res = _api_request("GET", "/status", port=args.port, timeout=3.0)
|
|
234
|
+
if res is None:
|
|
235
|
+
checks.append(_check("daemon http api", False, f"no answer on 127.0.0.1:{args.port} (start: systemctl start browser-agent)", False))
|
|
236
|
+
else:
|
|
237
|
+
api_ok = res.get("ok") and res.get("api_version") == API_VERSION
|
|
238
|
+
detail = f"api_version={res.get('api_version')} version={res.get('version')} chrome={res.get('chrome_running')} x11={res.get('x11_running')}"
|
|
239
|
+
if res.get("api_version") != API_VERSION:
|
|
240
|
+
detail += f" (this CLI speaks api_version={API_VERSION} — upgrade/downgrade to match)"
|
|
241
|
+
checks.append(_check("daemon http api", api_ok, detail))
|
|
242
|
+
|
|
243
|
+
if args.json:
|
|
244
|
+
failed = [c for c in checks if c["required"] and not c["ok"]]
|
|
245
|
+
print(json.dumps({"ok": not failed, "checks": checks}, indent=2))
|
|
246
|
+
else:
|
|
247
|
+
print("== browser-agent doctor ==")
|
|
248
|
+
for c in checks:
|
|
249
|
+
mark = "✓" if c["ok"] else ("✗" if c["required"] else "!")
|
|
250
|
+
opt = "" if c["required"] else " (optional)"
|
|
251
|
+
print(f" {mark} {c['name']}{opt}" + (f" — {c['detail']}" if c["detail"] else ""))
|
|
252
|
+
failed = [c for c in checks if c["required"] and not c["ok"]]
|
|
253
|
+
print()
|
|
254
|
+
print("OK" if not failed else f"FAIL: {len(failed)} required check(s) failed: " + ", ".join(c["name"] for c in failed))
|
|
255
|
+
return 0 if not any(c["required"] and not c["ok"] for c in checks) else 1
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# argument parsing & dispatch
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
263
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
264
|
+
common.add_argument("--port", type=int, default=DEFAULT_PORT, help="Daemon HTTP port (default: 8765)")
|
|
265
|
+
|
|
266
|
+
parser = argparse.ArgumentParser(prog="browser-agent", description="Real Chrome + X11 Mouse Agent", parents=[common])
|
|
267
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
268
|
+
|
|
269
|
+
p_serve = sub.add_parser("serve", parents=[common], help="Run the localhost HTTP daemon (127.0.0.1:8765)")
|
|
270
|
+
p_serve.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
|
|
271
|
+
|
|
272
|
+
sub.add_parser("status", parents=[common], help="Show Browser Agent daemon & X11 status")
|
|
273
|
+
sub.add_parser("stop", parents=[common], help="Hibernate Chrome and Xvfb immediately to free RAM")
|
|
274
|
+
|
|
275
|
+
p_fetch = sub.add_parser("fetch", parents=[common], help="Open URL in real Chrome, solve Cloudflare, extract page")
|
|
276
|
+
p_fetch.add_argument("url")
|
|
277
|
+
p_fetch.add_argument("--wait", type=float, default=3.5, help="Wait seconds after navigation")
|
|
278
|
+
p_fetch.add_argument("--text", action="store_true", help="Print extracted article text only")
|
|
279
|
+
p_fetch.add_argument("--html", action="store_true", help="Print outer HTML only")
|
|
280
|
+
p_fetch.add_argument("--screenshot", type=str, default="", help="Save PNG screenshot to path")
|
|
281
|
+
p_fetch.add_argument("--proxy", action="store_true", help="Route through local WARP SOCKS5 (127.0.0.1:40000)")
|
|
282
|
+
|
|
283
|
+
p_click = sub.add_parser("click", parents=[common], help="Move real X11 mouse along a Bezier curve and click (x, y)")
|
|
284
|
+
p_click.add_argument("x", type=int)
|
|
285
|
+
p_click.add_argument("y", type=int)
|
|
286
|
+
p_click.add_argument("--double", action="store_true")
|
|
287
|
+
|
|
288
|
+
p_move = sub.add_parser("move", parents=[common], help="Move real X11 mouse along a Bezier curve to (x, y)")
|
|
289
|
+
p_move.add_argument("x", type=int)
|
|
290
|
+
p_move.add_argument("y", type=int)
|
|
291
|
+
|
|
292
|
+
p_type = sub.add_parser("type", parents=[common], help="Type text via the X11 keyboard")
|
|
293
|
+
p_type.add_argument("text")
|
|
294
|
+
p_type.add_argument("--submit", action="store_true")
|
|
295
|
+
|
|
296
|
+
p_agent = sub.add_parser("agent", parents=[common], help="Run the vision-guided computer-use agent on a URL")
|
|
297
|
+
p_agent.add_argument("url")
|
|
298
|
+
p_agent.add_argument("goal")
|
|
299
|
+
p_agent.add_argument("--max-steps", type=int, default=8)
|
|
300
|
+
p_agent.add_argument("--proxy", action="store_true")
|
|
301
|
+
|
|
302
|
+
p_vnc = sub.add_parser("vnc", parents=[common], help="Start or stop the localhost VNC server (127.0.0.1:5900)")
|
|
303
|
+
p_vnc.add_argument("mode", choices=["start", "stop"], default="start", nargs="?")
|
|
304
|
+
|
|
305
|
+
p_setup = sub.add_parser("setup", parents=[common], help="Provision a Linux host (apt, Chrome, systemd unit)")
|
|
306
|
+
p_setup.add_argument("--no-apt", action="store_true", help="Do not install apt packages")
|
|
307
|
+
p_setup.add_argument("--with-chrome", action="store_true", help="Install google-chrome-stable from Google's apt repo")
|
|
308
|
+
p_setup.add_argument("--no-unit", action="store_true", help="Do not write the systemd unit")
|
|
309
|
+
p_setup.add_argument("--no-start", action="store_true", help="Write the unit but do not enable/start it")
|
|
310
|
+
p_setup.add_argument("--user", default="", help="User for the systemd unit (default: current user)")
|
|
311
|
+
p_setup.add_argument("--env-file", default="/etc/browser-agent.env", help="EnvironmentFile for the unit")
|
|
312
|
+
p_setup.add_argument("--memory-high", default="450M")
|
|
313
|
+
p_setup.add_argument("--memory-max", default="600M")
|
|
314
|
+
p_setup.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
|
|
315
|
+
p_setup.add_argument("--dry-run", action="store_true", help="Print what would happen, change nothing")
|
|
316
|
+
|
|
317
|
+
p_doctor = sub.add_parser("doctor", parents=[common], help="Self-check this host and the running daemon")
|
|
318
|
+
p_doctor.add_argument("--json", action="store_true", help="Machine-readable output")
|
|
319
|
+
|
|
320
|
+
return parser
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main(argv: list[str] | None = None) -> int:
|
|
324
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s", datefmt="%H:%M:%S")
|
|
325
|
+
args = build_parser().parse_args(argv)
|
|
326
|
+
|
|
327
|
+
if args.cmd == "serve":
|
|
328
|
+
return serve(port=args.port, idle_timeout=args.idle_timeout)
|
|
329
|
+
|
|
330
|
+
if args.cmd == "status":
|
|
331
|
+
res = _api_request("GET", "/status", port=args.port, timeout=5.0)
|
|
332
|
+
if res is None:
|
|
333
|
+
print(json.dumps({"ok": False, "daemon_running": False}, indent=2))
|
|
334
|
+
return 1
|
|
335
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
if args.cmd == "stop":
|
|
339
|
+
res = _api_request("POST", "/action", {"action": "stop"}, port=args.port, timeout=10.0)
|
|
340
|
+
print(json.dumps(res or {"ok": True, "note": "daemon not running"}, indent=2))
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
if args.cmd == "fetch":
|
|
344
|
+
payload = {
|
|
345
|
+
"url": args.url,
|
|
346
|
+
"wait_sec": args.wait,
|
|
347
|
+
"solve_cloudflare": True,
|
|
348
|
+
"use_proxy": True if args.proxy else None,
|
|
349
|
+
"include_screenshot": bool(args.screenshot),
|
|
350
|
+
}
|
|
351
|
+
res = _api_request("POST", "/fetch", payload, port=args.port, timeout=90.0)
|
|
352
|
+
if res is None:
|
|
353
|
+
# Fallback: run a one-shot coordinator in-process when the daemon is down
|
|
354
|
+
from .server import BrowserCoordinator
|
|
355
|
+
|
|
356
|
+
coord = BrowserCoordinator()
|
|
357
|
+
try:
|
|
358
|
+
res = coord.fetch(payload)
|
|
359
|
+
finally:
|
|
360
|
+
coord.shutdown()
|
|
361
|
+
if args.screenshot and res.get("screenshot_b64"):
|
|
362
|
+
Path(args.screenshot).write_bytes(base64.b64decode(res["screenshot_b64"]))
|
|
363
|
+
res["screenshot_saved"] = args.screenshot
|
|
364
|
+
res.pop("screenshot_b64", None)
|
|
365
|
+
if args.text:
|
|
366
|
+
print(res.get("text") or "")
|
|
367
|
+
elif args.html:
|
|
368
|
+
print(res.get("html") or "")
|
|
369
|
+
else:
|
|
370
|
+
out = {k: v for k, v in res.items() if k != "html"}
|
|
371
|
+
out["html_len"] = len(res.get("html") or "")
|
|
372
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
373
|
+
return 0 if res.get("ok") else 1
|
|
374
|
+
|
|
375
|
+
if args.cmd in ("click", "move", "type", "vnc"):
|
|
376
|
+
if args.cmd == "click":
|
|
377
|
+
act_payload = {"action": "click", "x": args.x, "y": args.y, "double": args.double}
|
|
378
|
+
elif args.cmd == "move":
|
|
379
|
+
act_payload = {"action": "move", "x": args.x, "y": args.y}
|
|
380
|
+
elif args.cmd == "type":
|
|
381
|
+
act_payload = {"action": "type", "text": args.text, "submit": args.submit}
|
|
382
|
+
else:
|
|
383
|
+
act_payload = {"action": f"vnc_{args.mode}"}
|
|
384
|
+
res = _api_request("POST", "/action", act_payload, port=args.port, timeout=30.0)
|
|
385
|
+
if res is None:
|
|
386
|
+
print("Error: browser-agent daemon is not running (start with: systemctl start browser-agent)", file=sys.stderr)
|
|
387
|
+
return 1
|
|
388
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
389
|
+
return 0 if res.get("ok") else 1
|
|
390
|
+
|
|
391
|
+
if args.cmd == "agent":
|
|
392
|
+
payload = {"url": args.url, "goal": args.goal, "max_steps": args.max_steps, "use_proxy": True if args.proxy else None}
|
|
393
|
+
res = _api_request("POST", "/agent", payload, port=args.port, timeout=180.0)
|
|
394
|
+
if res is None:
|
|
395
|
+
from .server import BrowserCoordinator
|
|
396
|
+
|
|
397
|
+
coord = BrowserCoordinator()
|
|
398
|
+
try:
|
|
399
|
+
res = coord.agent(payload)
|
|
400
|
+
finally:
|
|
401
|
+
coord.shutdown()
|
|
402
|
+
print(json.dumps(res, indent=2, ensure_ascii=False))
|
|
403
|
+
return 0 if res.get("ok") else 1
|
|
404
|
+
|
|
405
|
+
if args.cmd == "setup":
|
|
406
|
+
return cmd_setup(args)
|
|
407
|
+
if args.cmd == "doctor":
|
|
408
|
+
return cmd_doctor(args)
|
|
409
|
+
|
|
410
|
+
return 0
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
sys.exit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=browser-agent: Real Chrome + X11 Mouse daemon (localhost:{{PORT}})
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
User={{USER}}
|
|
9
|
+
EnvironmentFile=-{{ENV_FILE}}
|
|
10
|
+
Environment=BROWSER_AGENT_PORT={{PORT}}
|
|
11
|
+
Environment=BROWSER_AGENT_IDLE_SEC={{IDLE}}
|
|
12
|
+
# Memory discipline: the daemon must never starve co-tenant services on a
|
|
13
|
+
# small VPS. Chrome auto-hibernates after {{IDLE}}s of idle time (~12 MB
|
|
14
|
+
# resting state). Tune for your box with `browser-agent setup --memory-*`.
|
|
15
|
+
MemoryHigh={{MEMORY_HIGH}}
|
|
16
|
+
MemoryMax={{MEMORY_MAX}}
|
|
17
|
+
OOMScoreAdjust=500
|
|
18
|
+
ExecStart={{EXEC_START}} serve --port {{PORT}} --idle-timeout {{IDLE}}
|
|
19
|
+
Restart=on-failure
|
|
20
|
+
RestartSec=3
|
|
21
|
+
StandardOutput=journal
|
|
22
|
+
StandardError=journal
|
|
23
|
+
SyslogIdentifier=browser-agent
|
|
24
|
+
|
|
25
|
+
[Install]
|
|
26
|
+
WantedBy=multi-user.target
|