"""Playwright 浏览器子进程 Worker。 后端通过 stdin/stdout 与本进程通信,协议为每行一条 JSON: 请求: {"id": 1, "action": "open_url", "params": {"url": "..."}} 响应: {"id": 1, "ok": true, ...} 或 {"id": 1, "error": "..."} 为什么做成子进程: 后端是 PyInstaller 打包的 Python,playwright 的 C 扩展在里面会崩; tools/python/ 是原生 Python,playwright/.venv 的包都能正常运行。 """ import json import os import re import sys import base64 import threading from pathlib import Path def _find_tools_dir() -> Path: """查找 tools 目录,优先级: 1. 环境变量 OPENMESH_TOOLS_DIR 2. 从 Python 解释器路径向上找(tools/python/python.exe → tools/) 3. 从脚本路径向上找 4. 当前目录的 tools/ """ # 1. 环境变量 env_path = os.environ.get("OPENMESH_TOOLS_DIR", "") if env_path and Path(env_path).exists(): return Path(env_path) # 2. 从 Python 解释器向上找(tools/python/python.exe → tools/) try: exe_dir = Path(sys.executable).resolve().parent if exe_dir.name == "python" and (exe_dir.parent / "tools").exists(): # exe 在 dist/openmesh/tools/python/ return exe_dir.parent if exe_dir.name == "Scripts" and (exe_dir.parent.parent / "tools").exists(): # exe 在 .venv/Scripts/ return exe_dir.parent.parent / "tools" except Exception: pass # 3. 从脚本路径向上找 here = Path(__file__).resolve().parent for p in [here, here.parent, here.parent.parent, here.parent.parent.parent]: candidate = p / "tools" if candidate.exists(): return candidate # 4. 兜底 return Path("tools") # 找到 tools 目录,设置 playwright 浏览器路径 _tools_dir = _find_tools_dir() os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(_tools_dir / "playwright")) # 把 .venv 的 site-packages 加到路径 _venv_site = _tools_dir.parent / ".venv" / "Lib" / "site-packages" if _venv_site.exists(): sys.path.insert(0, str(_venv_site)) from playwright.sync_api import sync_playwright # noqa: E402 # ===== 页面快照 JS(跟原来 browser_automation.py 里的一样)===== _SNAPSHOT_JS = """ () => { const visible = (el) => { const style = window.getComputedStyle(el); const rect = el.getBoundingClientRect(); return style && style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0; }; const labelFor = (el) => { if (el.labels && el.labels.length) return Array.from(el.labels).map(l => l.innerText.trim()).filter(Boolean).join(' '); const id = el.getAttribute('id'); if (id) { const label = document.querySelector(`label[for="${CSS.escape(id)}"]`); if (label) return label.innerText.trim(); } return ''; }; const describe = (el, i) => ({ index: i, tag: el.tagName.toLowerCase(), type: el.getAttribute('type') || '', id: el.getAttribute('id') || '', name: el.getAttribute('name') || '', role: el.getAttribute('role') || '', aria: el.getAttribute('aria-label') || '', label: labelFor(el), placeholder: el.getAttribute('placeholder') || '', text: (el.innerText || el.value || '').trim().slice(0, 200), href: el.getAttribute('href') || '', selectorHint: el.getAttribute('id') ? `#${CSS.escape(el.getAttribute('id'))}` : (el.getAttribute('name') ? `[name="${el.getAttribute('name')}"]` : '') }); const controls = Array.from(document.querySelectorAll('a,button,input,textarea,select,[role="button"],[contenteditable="true"]')) .filter(visible) .slice(0, 120) .map(describe); return { title: document.title, url: location.href, text: document.body ? document.body.innerText : '', controls }; } """ class BrowserWorker: def __init__(self): self._pw = None self._browser = None self._context = None self._page = None self._lock = threading.Lock() def _ensure_page(self): if self._page is not None: return self._page if self._pw is None: self._pw = sync_playwright().start() if self._browser is None: self._browser = self._pw.chromium.launch( headless=True, executable_path=str(_tools_dir / "playwright" / "chromium-1234" / "chrome-win64" / "chrome.exe"), ) if self._context is None: self._context = self._browser.new_context( viewport={"width": 1280, "height": 900} ) if self._page is None: self._page = self._context.new_page() return self._page def _snapshot(self, page, max_chars=20000): data = page.evaluate(_SNAPSHOT_JS) text = re.sub(r"\n{3,}", "\n\n", str(data.get("text") or "")) cap = max(1, min(int(max_chars or 20000), 100000)) return { "title": data.get("title"), "url": data.get("url"), "text": text[:cap], "truncated": len(text) > cap, "controls": (data.get("controls") or [])[:30], } def _target_locator(self, page, target): target = str(target).strip() if target.startswith("text="): return page.get_by_text(target[5:], exact=False).first if target.startswith("role="): role_name = target[5:] role, _, name = role_name.partition(":") return page.get_by_role(role.strip(), name=name.strip() or None).first try: return page.locator(target).first except Exception: return page.get_by_text(target, exact=False).first # ===== 动作处理 ===== def handle(self, action, params): with self._lock: try: fn = getattr(self, f"_action_{action}", None) if fn is None: return {"error": f"unknown action: {action}"} return fn(params or {}) except Exception as e: return {"error": f"{type(e).__name__}: {e}"} def _action_state(self, params): if self._page is None: return {"open": False, "status": "closed", "url": "", "title": "", "controls": []} snap = self._snapshot(self._page, params.get("max_chars", 2000)) return { "open": True, "status": "open", "url": self._page.url, "title": self._page.title(), "controls": snap.get("controls", []), } def _action_open_url(self, params): url = params.get("url", "") if not url.lower().startswith(("http://", "https://")): return {"error": "url must start with http:// or https://"} page = self._ensure_page() page.goto(url, wait_until=params.get("wait_until", "domcontentloaded"), timeout=30000) return {"ok": True, "url": page.url} def _action_read_page(self, params): page = self._ensure_page() return self._snapshot(page, params.get("max_chars", 20000)) def _action_click(self, params): page = self._ensure_page() self._target_locator(page, params.get("target", "")).click(timeout=10000) return {"ok": True, "url": page.url} def _action_type(self, params): page = self._ensure_page() loc = self._target_locator(page, params.get("target", "")) if params.get("clear", True): loc.fill(params.get("text", ""), timeout=10000) else: loc.type(params.get("text", ""), timeout=10000) return {"ok": True, "url": page.url} def _action_select(self, params): page = self._ensure_page() self._target_locator(page, params.get("target", "")).select_option( params.get("value", ""), timeout=10000 ) return {"ok": True, "url": page.url} def _action_upload_file(self, params): page = self._ensure_page() path = params.get("path", "") if not path or not os.path.exists(path): return {"error": f"file not found: {path}"} self._target_locator(page, params.get("target", "")).set_input_files(path, timeout=10000) return {"ok": True, "path": path} def _action_wait(self, params): page = self._ensure_page() target = params.get("target", "") if target: self._target_locator(page, target).wait_for( timeout=max(1, int(params.get("milliseconds") or 1000)) ) else: page.wait_for_timeout(max(1, min(int(params.get("milliseconds") or 1000), 30000))) return {"ok": True, "url": page.url} def _action_screenshot(self, params): page = self._ensure_page() path = params.get("path", "") if path: out_path = Path(path).resolve() out_path.parent.mkdir(parents=True, exist_ok=True) page.screenshot(path=str(out_path), full_page=True) return {"ok": True, "path": str(out_path), "url": page.url} else: # 返回 base64 png = page.screenshot(full_page=False) data_url = "data:image/png;base64," + base64.b64encode(png).decode("ascii") return {"ok": True, "screenshot_data_url": data_url, "url": page.url} def _action_close(self, params): try: if self._context: self._context.close() if self._browser: self._browser.close() if self._pw: self._pw.stop() finally: self._page = None self._context = None self._browser = None self._pw = None return {"ok": True} def main(): worker = BrowserWorker() # 发送 ready 信号 print(json.dumps({"id": 0, "ok": True, "status": "ready"}), flush=True) for line in sys.stdin: line = line.strip() if not line: continue try: req = json.loads(line) except json.JSONDecodeError: print(json.dumps({"id": 0, "error": "invalid JSON"}), flush=True) continue req_id = req.get("id", 0) action = req.get("action", "") params = req.get("params", {}) result = worker.handle(action, params) result["id"] = req_id print(json.dumps(result, ensure_ascii=False), flush=True) # 退出命令 if action == "close": break if __name__ == "__main__": main()