300 lines
10 KiB
Python
300 lines
10 KiB
Python
|
|
"""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()
|