e833b923c8
接入 Playwright Worker、会话 WebSocket 与全局浏览器面板(固定于 App Bar 下); 含验证码 stealth、设置项默认音与 URL 安全校验;附带 worker 部署与设计文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
347 lines
12 KiB
Python
347 lines
12 KiB
Python
"""Playwright Chromium session manager with CDP screencast."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable, Awaitable
|
|
|
|
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
|
|
|
from browser_stealth import (
|
|
CHROMIUM_ARGS,
|
|
CONTEXT_OPTIONS,
|
|
IGNORE_DEFAULT_ARGS,
|
|
STEALTH_INIT_SCRIPT,
|
|
)
|
|
from browser_url_safe import validate_navigation_url
|
|
from config import settings
|
|
|
|
|
|
def _headless() -> bool:
|
|
return settings.WORKER_HEADLESS.lower() in ("1", "true", "yes")
|
|
|
|
logger = logging.getLogger("browser_worker.session")
|
|
|
|
SendFn = Callable[[dict[str, Any]], Awaitable[None]]
|
|
|
|
|
|
@dataclass
|
|
class BrowserSession:
|
|
session_id: str
|
|
viewport_w: int
|
|
viewport_h: int
|
|
send: SendFn
|
|
ready: bool = False
|
|
context: BrowserContext | None = None
|
|
page: Page | None = None
|
|
cdp: Any = None
|
|
last_activity: float = field(default_factory=time.monotonic)
|
|
reserved_at: float = field(default_factory=time.monotonic)
|
|
_screencast_task: asyncio.Task | None = None
|
|
_title_task: asyncio.Task | None = None
|
|
|
|
async def touch(self) -> None:
|
|
self.last_activity = time.monotonic()
|
|
|
|
async def close(self) -> None:
|
|
for task in (self._screencast_task, self._title_task):
|
|
if task and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._screencast_task = None
|
|
self._title_task = None
|
|
if self.cdp:
|
|
try:
|
|
await self.cdp.send("Page.stopScreencast")
|
|
except Exception:
|
|
pass
|
|
self.cdp = None
|
|
if self.context:
|
|
try:
|
|
await self.context.close()
|
|
except Exception as exc:
|
|
logger.warning("context close failed session=%s: %s", self.session_id, exc)
|
|
self.context = None
|
|
self.page = None
|
|
self.ready = False
|
|
|
|
|
|
class SessionManager:
|
|
def __init__(self) -> None:
|
|
self._browser: Browser | None = None
|
|
self._playwright = None
|
|
self._sessions: dict[str, BrowserSession] = {}
|
|
self._lock = asyncio.Lock()
|
|
self._idle_task: asyncio.Task | None = None
|
|
|
|
async def start(self) -> None:
|
|
self._playwright = await async_playwright().start()
|
|
self._browser = await self._playwright.chromium.launch(
|
|
headless=_headless(),
|
|
args=CHROMIUM_ARGS,
|
|
ignore_default_args=IGNORE_DEFAULT_ARGS,
|
|
)
|
|
self._idle_task = asyncio.create_task(self._idle_loop(), name="browser_idle")
|
|
|
|
async def stop(self) -> None:
|
|
if self._idle_task:
|
|
self._idle_task.cancel()
|
|
try:
|
|
await self._idle_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
for sid in list(self._sessions):
|
|
await self.destroy_session(sid)
|
|
if self._browser:
|
|
await self._browser.close()
|
|
self._browser = None
|
|
if self._playwright:
|
|
await self._playwright.stop()
|
|
self._playwright = None
|
|
|
|
async def _idle_loop(self) -> None:
|
|
while True:
|
|
await asyncio.sleep(60)
|
|
now = time.monotonic()
|
|
stale: list[str] = []
|
|
for sid, sess in self._sessions.items():
|
|
if sess.ready:
|
|
if now - sess.last_activity > settings.WORKER_IDLE_TIMEOUT_SEC:
|
|
stale.append(sid)
|
|
elif now - sess.reserved_at > settings.WORKER_RESERVE_TIMEOUT_SEC:
|
|
stale.append(sid)
|
|
for sid in stale:
|
|
logger.info("idle timeout session=%s", sid)
|
|
await self.destroy_session(sid)
|
|
|
|
def session_count(self) -> int:
|
|
return len(self._sessions)
|
|
|
|
async def reserve_session(
|
|
self,
|
|
session_id: str,
|
|
viewport_w: int,
|
|
viewport_h: int,
|
|
) -> None:
|
|
async with self._lock:
|
|
if session_id in self._sessions:
|
|
raise ValueError("session_exists")
|
|
if len(self._sessions) >= settings.WORKER_MAX_SESSIONS:
|
|
raise ValueError("max_sessions")
|
|
self._sessions[session_id] = BrowserSession(
|
|
session_id=session_id,
|
|
viewport_w=viewport_w,
|
|
viewport_h=viewport_h,
|
|
send=self._noop_send,
|
|
)
|
|
|
|
async def attach_stream(self, session_id: str, send: SendFn) -> BrowserSession:
|
|
async with self._lock:
|
|
session = self._sessions.get(session_id)
|
|
if not session:
|
|
raise ValueError("session_not_found")
|
|
if session.ready:
|
|
session.send = send
|
|
return session
|
|
if not self._browser:
|
|
raise RuntimeError("browser_not_ready")
|
|
session.send = send
|
|
|
|
context = await self._browser.new_context(
|
|
viewport={"width": session.viewport_w, "height": session.viewport_h},
|
|
ignore_https_errors=False,
|
|
**CONTEXT_OPTIONS,
|
|
)
|
|
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
|
page = await context.new_page()
|
|
session.context = context
|
|
session.page = page
|
|
cdp = await context.new_cdp_session(page)
|
|
session.cdp = cdp
|
|
await cdp.send("Page.startScreencast", {
|
|
"format": "jpeg",
|
|
"quality": max(60, min(settings.WORKER_SCREENCAST_QUALITY, 100)),
|
|
"everyNthFrame": 1,
|
|
})
|
|
session._screencast_task = asyncio.create_task(
|
|
self._screencast_loop(session, cdp),
|
|
name=f"screencast_{session_id}",
|
|
)
|
|
session._title_task = asyncio.create_task(
|
|
self._title_loop(session),
|
|
name=f"title_{session_id}",
|
|
)
|
|
session.ready = True
|
|
await session.send({
|
|
"type": "BROWSER_INIT",
|
|
"session_id": session_id,
|
|
"viewport_w": session.viewport_w,
|
|
"viewport_h": session.viewport_h,
|
|
})
|
|
return session
|
|
|
|
async def _noop_send(self, _msg: dict[str, Any]) -> None:
|
|
return
|
|
|
|
async def destroy_session(self, session_id: str) -> bool:
|
|
async with self._lock:
|
|
session = self._sessions.pop(session_id, None)
|
|
if not session:
|
|
return False
|
|
await session.close()
|
|
return True
|
|
|
|
def get_session(self, session_id: str) -> BrowserSession | None:
|
|
return self._sessions.get(session_id)
|
|
|
|
async def _screencast_loop(self, session: BrowserSession, cdp: Any) -> None:
|
|
"""CDPSession has no wait_for_event — use on() + asyncio.Queue."""
|
|
frame_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
|
|
def _on_screencast_frame(params: dict[str, Any]) -> None:
|
|
try:
|
|
frame_queue.put_nowait(params)
|
|
except asyncio.QueueFull:
|
|
pass
|
|
|
|
cdp.on("Page.screencastFrame", _on_screencast_frame)
|
|
try:
|
|
while session.session_id in self._sessions and session.ready:
|
|
try:
|
|
msg = await asyncio.wait_for(frame_queue.get(), timeout=30.0)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
await session.touch()
|
|
await session.send({
|
|
"type": "SCREENCAST_FRAME",
|
|
"data_b64": msg.get("data", ""),
|
|
"metadata": msg.get("metadata") or {},
|
|
})
|
|
await cdp.send("Page.screencastFrameAck", {"sessionId": msg["sessionId"]})
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.warning("screencast ended session=%s: %s", session.session_id, exc)
|
|
|
|
async def _title_loop(self, session: BrowserSession) -> None:
|
|
page = session.page
|
|
if not page:
|
|
return
|
|
last_url = ""
|
|
last_title = ""
|
|
try:
|
|
while session.session_id in self._sessions and session.ready:
|
|
await asyncio.sleep(1)
|
|
await session.touch()
|
|
try:
|
|
url = page.url
|
|
title = await page.title()
|
|
except Exception:
|
|
continue
|
|
if url != last_url or title != last_title:
|
|
last_url = url
|
|
last_title = title
|
|
await session.send({
|
|
"type": "PAGE_INFO",
|
|
"url": url,
|
|
"title": title,
|
|
})
|
|
except asyncio.CancelledError:
|
|
raise
|
|
|
|
async def handle_message(self, session_id: str, msg: dict[str, Any]) -> None:
|
|
session = self.get_session(session_id)
|
|
if not session or not session.page or not session.ready:
|
|
return
|
|
await session.touch()
|
|
page = session.page
|
|
mtype = msg.get("type")
|
|
|
|
if mtype == "PING":
|
|
await session.send({"type": "PONG"})
|
|
return
|
|
|
|
if mtype == "NAVIGATE":
|
|
url = msg.get("url", "")
|
|
normalized, err = validate_navigation_url(str(url))
|
|
if err:
|
|
await session.send({"type": "ERROR", "message": err, "code": "URL_BLOCKED"})
|
|
return
|
|
try:
|
|
await page.goto(normalized, wait_until="domcontentloaded", timeout=30_000)
|
|
await session.send({
|
|
"type": "PAGE_INFO",
|
|
"url": page.url,
|
|
"title": await page.title(),
|
|
})
|
|
except Exception as exc:
|
|
await session.send({
|
|
"type": "ERROR",
|
|
"message": str(exc)[:500],
|
|
"code": "NAVIGATE_FAILED",
|
|
})
|
|
return
|
|
|
|
if mtype == "RELOAD":
|
|
await page.reload(wait_until="domcontentloaded", timeout=30_000)
|
|
return
|
|
if mtype == "GO_BACK":
|
|
await page.go_back(wait_until="domcontentloaded", timeout=30_000)
|
|
return
|
|
if mtype == "GO_FORWARD":
|
|
await page.go_forward(wait_until="domcontentloaded", timeout=30_000)
|
|
return
|
|
|
|
if mtype == "VIEWPORT":
|
|
w = int(msg.get("width") or session.viewport_w)
|
|
h = int(msg.get("height") or session.viewport_h)
|
|
w = max(320, min(w, 1920))
|
|
h = max(240, min(h, 1080))
|
|
session.viewport_w = w
|
|
session.viewport_h = h
|
|
await page.set_viewport_size({"width": w, "height": h})
|
|
return
|
|
|
|
if mtype == "MOUSE":
|
|
event = msg.get("event")
|
|
x = float(msg.get("x", 0))
|
|
y = float(msg.get("y", 0))
|
|
button = msg.get("button", "left")
|
|
steps = int(msg.get("steps") or 1)
|
|
steps = max(1, min(steps, 25))
|
|
if event == "move":
|
|
await page.mouse.move(x, y, steps=steps)
|
|
elif event == "down":
|
|
await page.mouse.move(x, y)
|
|
await page.mouse.down(button=button)
|
|
elif event == "up":
|
|
await page.mouse.move(x, y)
|
|
await page.mouse.up(button=button)
|
|
elif event == "click":
|
|
await page.mouse.click(x, y, button=button, delay=50)
|
|
elif event == "wheel":
|
|
delta_y = float(msg.get("delta_y", 0))
|
|
await page.mouse.wheel(0, delta_y)
|
|
return
|
|
|
|
if mtype == "KEY":
|
|
event = msg.get("event")
|
|
key = msg.get("key", "")
|
|
text = msg.get("text", "")
|
|
if event == "down" and key:
|
|
await page.keyboard.down(key)
|
|
elif event == "up" and key:
|
|
await page.keyboard.up(key)
|
|
elif event == "type" and text:
|
|
await page.keyboard.type(text)
|
|
return
|
|
|
|
|
|
manager = SessionManager()
|