diff --git a/requirements.txt b/requirements.txt index 7b72d45..fcfca27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ flask>=3.0 +flask-socketio>=5.3 playwright==1.52.0 playwright-stealth>=1.0 pyyaml>=6.0 diff --git a/run.py b/run.py index 0b274ff..4d9f34a 100644 --- a/run.py +++ b/run.py @@ -1,13 +1,14 @@ """启动入口:python run.py""" -from server.app import create_app +from server.app import create_app, socketio app = create_app() if __name__ == '__main__': + import os print("=" * 50) print(" 微店抢购管理系统已启动") print(" 访问 http://localhost:9000") print("=" * 50) - import os debug = os.environ.get('FLASK_DEBUG', '1') == '1' - app.run(host='0.0.0.0', port=9000, debug=debug) + socketio.run(app, host='0.0.0.0', port=9000, debug=debug, + allow_unsafe_werkzeug=True) diff --git a/server/app.py b/server/app.py index 28710f3..4686f54 100644 --- a/server/app.py +++ b/server/app.py @@ -1,13 +1,15 @@ import os import sys -# 确保项目根目录在 sys.path 中 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from flask import Flask, redirect, url_for +from flask_socketio import SocketIO from server.database import init_db from server.routers import accounts, tasks, orders +socketio = SocketIO(cors_allowed_origins="*") + def create_app(): app = Flask( @@ -27,9 +29,50 @@ def create_app(): def index(): return redirect(url_for('tasks.list_tasks')) + socketio.init_app(app) + _register_socketio_events() + return app +def _register_socketio_events(): + import asyncio + from server.services.remote_browser import get_session + + @socketio.on('rb_mouse', namespace='/rb') + def handle_mouse(data): + s = get_session(data.get('session_id', '')) + if s and s.loop and s.loop.is_running(): + asyncio.run_coroutine_threadsafe( + s.handle_mouse( + data.get('action', 'click'), + data.get('x', 0), data.get('y', 0)), + s.loop) + + @socketio.on('rb_keyboard', namespace='/rb') + def handle_keyboard(data): + s = get_session(data.get('session_id', '')) + if s and s.loop and s.loop.is_running(): + text = data.get('text', '') + if text: + asyncio.run_coroutine_threadsafe( + s.handle_keyboard(text), s.loop) + + @socketio.on('rb_key', namespace='/rb') + def handle_key(data): + s = get_session(data.get('session_id', '')) + if s and s.loop and s.loop.is_running(): + key = data.get('key', '') + if key: + asyncio.run_coroutine_threadsafe( + s.handle_key(key), s.loop) + + @socketio.on('rb_close', namespace='/rb') + def handle_close(data): + from server.services.remote_browser import close_session + close_session(data.get('session_id', '')) + + if __name__ == '__main__': app = create_app() - app.run(host='0.0.0.0', port=9000, debug=True) + socketio.run(app, host='0.0.0.0', port=9000, debug=True) diff --git a/server/routers/accounts.py b/server/routers/accounts.py index dace7bf..6246655 100644 --- a/server/routers/accounts.py +++ b/server/routers/accounts.py @@ -89,10 +89,7 @@ def do_login(account_id): @bp.route('/login_sms/', methods=['POST']) def do_sms_login(account_id): - """ - 短信验证码登录(需要人机交互) - 会弹出浏览器窗口,需要人拖滑块 + 输入验证码。 - """ + """短信验证码登录 — 远程浏览器模式,在 Web 面板中操作""" db = get_db() account = db.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone() db.close() @@ -114,10 +111,13 @@ def do_sms_login(account_id): db.commit() db.close() - # 后台启动短信登录(会弹出浏览器窗口) - _start_bg_sms_login(account_id, phone) + # 启动远程浏览器会话 + from server.app import socketio + from server.services.remote_browser import start_session + session_id = start_session(account_id, phone, socketio) - return jsonify(success=True, msg='已启动短信登录,请在弹出的浏览器中完成滑块验证,并在终端输入验证码') + return jsonify(success=True, session_id=session_id, + msg='远程浏览器已启动,请在弹出窗口中操作') @bp.route('/status/') diff --git a/server/services/remote_browser.py b/server/services/remote_browser.py new file mode 100644 index 0000000..d6aff6e --- /dev/null +++ b/server/services/remote_browser.py @@ -0,0 +1,292 @@ +""" +远程浏览器服务 — 通过 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() diff --git a/templates/accounts.html b/templates/accounts.html index 24a3788..b538f85 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -21,9 +21,10 @@ {% if a.is_logged_in %} 已登录 - {% elif a.login_msg == '登录中...' %} + {% elif a.login_msg == '登录中...' or a.login_msg == '等待人机交互...' %} - 登录中... + + {{ a.login_msg }} {% else %} 未登录 @@ -50,6 +51,8 @@ + + + + + {% endblock %} + {% block scripts %} +