#!/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