293 lines
9.8 KiB
Python
293 lines
9.8 KiB
Python
|
|
"""
|
|||
|
|
远程浏览器服务 — 通过 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()
|