Files
weidian/server/services/remote_browser.py

293 lines
9.8 KiB
Python
Raw Normal View History

"""
远程浏览器服务 通过 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()