146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
#!/usr/bin/env python3
|
|
"""agent-browser CLI 封装"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import time
|
|
|
|
from config import AGENT_BROWSER, NODE_BIN_DIR, XDG_RUNTIME_DIR
|
|
|
|
|
|
class BrowserError(Exception):
|
|
pass
|
|
|
|
|
|
class AgentBrowser:
|
|
"""每个任务实例一个 namespace,避免 socket 冲突"""
|
|
|
|
def __init__(self, namespace='default'):
|
|
self.namespace = namespace
|
|
self.env = os.environ.copy()
|
|
self.env['PATH'] = f'{NODE_BIN_DIR}:{self.env.get("PATH", "")}'
|
|
self.env['XDG_RUNTIME_DIR'] = XDG_RUNTIME_DIR
|
|
os.makedirs(XDG_RUNTIME_DIR, exist_ok=True)
|
|
try:
|
|
os.chmod(XDG_RUNTIME_DIR, 0o700)
|
|
except OSError:
|
|
pass
|
|
|
|
def _run(self, args, timeout=60, check=False):
|
|
cmd = [AGENT_BROWSER, '--namespace', self.namespace] + args
|
|
try:
|
|
p = subprocess.run(cmd, capture_output=True, text=True,
|
|
timeout=timeout, env=self.env)
|
|
except subprocess.TimeoutExpired:
|
|
raise BrowserError(f'命令超时: {" ".join(args)}')
|
|
out = p.stdout.strip()
|
|
if check and p.returncode != 0:
|
|
err = (p.stderr or '').strip() or out
|
|
raise BrowserError(f'命令失败({p.returncode}): {err[:300]}')
|
|
if out.startswith('{'):
|
|
try:
|
|
return json.loads(out)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return out
|
|
|
|
# 瞬时网络故障(服务重启/端口切换等)会自动重试的错误特征
|
|
RETRYABLE_ERRS = (
|
|
'ERR_EMPTY_RESPONSE', 'ERR_CONNECTION_REFUSED',
|
|
'ERR_CONNECTION_RESET', 'ERR_CONNECTION_CLOSED',
|
|
'ERR_TIMED_OUT', 'ERR_NAME_NOT_RESOLVED', 'ERR_SOCKET_NOT_CONNECTED',
|
|
'ERR_ADDRESS_UNREACHABLE', 'ERR_NETWORK_CHANGED', 'ERR_INTERNET_DISCONNECTED',
|
|
)
|
|
|
|
def open(self, url, timeout=60, retries=2):
|
|
"""打开页面;对瞬时连接类错误自动重试(默认最多重试 2 次,间隔 2s/4s)"""
|
|
last_err = None
|
|
for attempt in range(retries + 1):
|
|
try:
|
|
return self._run(['open', url], timeout=timeout, check=True)
|
|
except BrowserError as e:
|
|
last_err = e
|
|
if attempt < retries and any(t in str(e) for t in self.RETRYABLE_ERRS):
|
|
time.sleep(2 * (attempt + 1))
|
|
continue
|
|
raise
|
|
raise last_err
|
|
|
|
def snapshot(self, interactive=True, compact=False, depth=None, timeout=60):
|
|
args = ['snapshot']
|
|
if interactive:
|
|
args.append('-i')
|
|
if compact:
|
|
args.append('-c')
|
|
if depth:
|
|
args += ['-d', str(depth)]
|
|
args.append('--json')
|
|
data = self._run(args, timeout=timeout, check=True)
|
|
if isinstance(data, dict) and data.get('success'):
|
|
return data.get('data') or {}
|
|
return data
|
|
|
|
def click(self, target, timeout=30):
|
|
return self._run(['click', target], timeout=timeout, check=True)
|
|
|
|
def fill(self, target, value, timeout=30):
|
|
return self._run(['fill', target, value], timeout=timeout, check=True)
|
|
|
|
def select(self, target, value, timeout=30):
|
|
return self._run(['select', target, value], timeout=timeout, check=True)
|
|
|
|
def press(self, key, timeout=30):
|
|
return self._run(['press', key], timeout=timeout, check=True)
|
|
|
|
def wait(self, *args, timeout=60):
|
|
return self._run(['wait'] + list(args), timeout=timeout, check=True)
|
|
|
|
def get(self, what, target=None, timeout=30):
|
|
args = ['get', what]
|
|
if target:
|
|
args.append(target)
|
|
args.append('--json')
|
|
return self._run(args, timeout=timeout, check=True)
|
|
|
|
def eval_js(self, expr, timeout=30):
|
|
return self._run(['eval', expr], timeout=timeout, check=True)
|
|
|
|
def screenshot(self, path, full=False, timeout=30):
|
|
args = ['screenshot', path]
|
|
if full:
|
|
args.append('--full')
|
|
return self._run(args, timeout=timeout, check=True)
|
|
|
|
def is_visible(self, target, timeout=30):
|
|
try:
|
|
r = self._run(['is', 'visible', target, '--json'], timeout=timeout)
|
|
return bool(r.get('data', {}).get('visible')) if isinstance(r, dict) else False
|
|
except BrowserError:
|
|
return False
|
|
|
|
def url(self, timeout=20):
|
|
try:
|
|
r = self.get('url')
|
|
if isinstance(r, dict):
|
|
d = r.get('data', {})
|
|
return d.get('url') or d.get('value') or ''
|
|
return ''
|
|
except BrowserError:
|
|
return ''
|
|
|
|
def title(self, timeout=20):
|
|
try:
|
|
r = self.get('title')
|
|
if isinstance(r, dict):
|
|
d = r.get('data', {})
|
|
return d.get('title') or d.get('value') or ''
|
|
return ''
|
|
except BrowserError:
|
|
return ''
|
|
|
|
def close(self):
|
|
try:
|
|
self._run(['close'], timeout=15)
|
|
except Exception:
|
|
pass
|