""" 远程浏览器服务 — 通过 WebSocket 将 headless 浏览器画面串流到前端, 前端用户可以直接在 Web 页面上操作浏览器(拖滑块、输入验证码等)。 """ import asyncio import base64 import json import threading import time from datetime import datetime from playwright.async_api import async_playwright from utils.stealth import stealth_async from server.services.auth_service import ( SSO_LOGIN_URL, get_auth_path, _save_cookies_file_async ) from server.database import get_db # 全局会话存储: {session_id: RemoteBrowserSession} _sessions = {} class RemoteBrowserSession: def __init__(self, session_id, account_id, phone): self.session_id = session_id self.account_id = account_id self.phone = phone self.page = None self.browser = None self.context = None self.pw = None self.cdp = None self.loop = None self.thread = None self.status = 'init' # init, running, success, failed, closed self.message = '' self.login_success = False self._socketio = None self._frame_ack_id = 0 def start(self, socketio): self._socketio = socketio self.status = 'running' self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() def _run(self): self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) try: self.loop.run_until_complete(self._async_run()) except Exception as e: self.status = 'failed' self.message = str(e) finally: self.loop.close() async def _async_run(self): self.pw = await async_playwright().start() self.browser = await self.pw.chromium.launch( headless=True, args=['--no-sandbox', '--disable-gpu', '--disable-blink-features=AutomationControlled'] ) self.context = await self.browser.new_context( user_agent=( "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) " "Version/18.0 Mobile/15E148 Safari/604.1" ), viewport={"width": 390, "height": 844}, device_scale_factor=2, is_mobile=True, has_touch=True, ) await self.context.add_init_script( "Object.defineProperty(navigator,'webdriver',{get:()=>undefined});" ) self.page = await self.context.new_page() await stealth_async(self.page) # 监听登录相关响应 self.page.on("response", self._on_response) # 开启 CDP screencast self.cdp = await self.page.context.new_cdp_session(self.page) self.cdp.on("Page.screencastFrame", self._on_frame) await self.cdp.send("Page.startScreencast", { "format": "jpeg", "quality": 50, "maxWidth": 390, "maxHeight": 844 }) # 打开登录页 self.message = '正在打开登录页...' await self.page.goto(SSO_LOGIN_URL, wait_until="load", timeout=20000) await asyncio.sleep(2) # 点击"登录" try: btn = self.page.locator("#login_init_by_login") if await btn.count() > 0 and await btn.is_visible(): await btn.click() await asyncio.sleep(1.5) except Exception: pass # 填手机号 self.message = '正在填写手机号...' try: tele = self.page.locator('input[placeholder*="手机号"]').first await tele.click() await tele.fill("") await self.page.keyboard.type(self.phone, delay=80) await asyncio.sleep(0.5) except Exception: pass # 点击获取验证码 self.message = '请完成滑块验证' try: code_btn = self.page.locator( "#login_quickCode_right, text=获取验证码").first await code_btn.click() except Exception: pass # 等待登录完成或超时 for _ in range(300): # 5分钟超时 await asyncio.sleep(1) if self.login_success: break if self.status == 'closed': return if self.login_success: auth_path = get_auth_path(self.account_id) await self.context.storage_state(path=auth_path) await _save_cookies_file_async(self.context, self.account_id) self.status = 'success' self.message = '登录成功' self._update_db(True, '登录成功') self._emit('rb_status', {'status': 'success', 'msg': '登录成功'}) elif self.status != 'closed': self.status = 'failed' self.message = self.message or '登录超时' self._update_db(False, self.message) self._emit('rb_status', {'status': 'failed', 'msg': self.message}) await self._cleanup() def _on_frame(self, params): """CDP screencast 帧回调""" session_id = params.get("sessionId", 0) data = params.get("data", "") if data and self._socketio: self._emit('rb_frame', { 'session_id': self.session_id, 'data': data # base64 jpeg }) # 必须 ack 才能收到下一帧 if self.cdp and self.loop and self.loop.is_running(): asyncio.run_coroutine_threadsafe( self.cdp.send("Page.screencastFrameAck", {"sessionId": session_id}), self.loop ) async def _on_response(self, response): url = response.url try: if "cap_union_new_verify" in url: data = await response.json() if data.get("errorCode") == "0": self.message = '滑块验证通过,请输入验证码' self._emit('rb_status', { 'status': 'running', 'msg': self.message}) elif "get.vcode" in url: data = await response.json() code = str(data.get("status", {}).get("code", "")) if code == "0": self.message = '短信已发送,请输入验证码并点击登录' self._emit('rb_status', { 'status': 'running', 'msg': self.message}) elif "user/loginbyvcode" in url or "user/login" in url: data = await response.json() sc = str(data.get("status", {}).get("status_code", data.get("status", {}).get("code", ""))) if sc == "0": self.login_success = True elif "synclogin" in url: # SSO 同步完成也算登录成功 cookies = await self.context.cookies() cookie_map = {c["name"]: c["value"] for c in cookies} if cookie_map.get("wduss") or cookie_map.get("uid"): self.login_success = True except Exception: pass def _emit(self, event, data): if self._socketio: data['session_id'] = self.session_id self._socketio.emit(event, data, namespace='/rb') def _update_db(self, success, msg): try: db = get_db() if success: db.execute( "UPDATE accounts SET is_logged_in=1, login_msg=?, " "updated_at=datetime('now','localtime') WHERE id=?", (msg, self.account_id)) else: db.execute( "UPDATE accounts SET is_logged_in=0, login_msg=?, " "updated_at=datetime('now','localtime') WHERE id=?", (msg, self.account_id)) db.commit() db.close() except Exception: pass async def handle_mouse(self, action, x, y, **kwargs): if not self.page: return try: if action == 'click': await self.page.mouse.click(x, y) elif action == 'down': await self.page.mouse.move(x, y) await self.page.mouse.down() elif action == 'move': await self.page.mouse.move(x, y) elif action == 'up': await self.page.mouse.move(x, y) await self.page.mouse.up() except Exception: pass async def handle_keyboard(self, text): if not self.page: return try: await self.page.keyboard.type(text, delay=50) except Exception: pass async def handle_key(self, key): if not self.page: return try: await self.page.keyboard.press(key) except Exception: pass async def _cleanup(self): try: if self.cdp: await self.cdp.send("Page.stopScreencast") except Exception: pass try: if self.browser: await self.browser.close() except Exception: pass try: if self.pw: await self.pw.stop() except Exception: pass def close(self): self.status = 'closed' if self.loop and self.loop.is_running(): asyncio.run_coroutine_threadsafe(self._cleanup(), self.loop) def start_session(account_id, phone, socketio): sid = f"rb_{account_id}_{int(time.time())}" session = RemoteBrowserSession(sid, account_id, phone) _sessions[sid] = session session.start(socketio) return sid def get_session(session_id): return _sessions.get(session_id) def close_session(session_id): s = _sessions.pop(session_id, None) if s: s.close()