Compare commits

...

2 Commits

Author SHA1 Message Date
8b64d7e69e feat: 远程浏览器功能 - Web面板内嵌操作滑块验证
- 新增 remote_browser.py: CDP screencast截图流 + 鼠标/键盘事件转发
- Flask-SocketIO 实时通信
- 短信登录时弹出远程浏览器窗口,用户直接在Web页面拖滑块
- 自动检测登录成功并保存auth状态
2026-04-01 13:56:27 +08:00
2ebdaec965 fix: cart_item_id不是itemID,修复商品链接错误导致抢购失败
- cart_service: 拦截购物车API提取真实itemID映射
- cart_service: 从Vue组件/data属性/window全局变量多路提取itemID
- tasks: 区分item_id和cart_item_id,只有真实itemID才拼URL
- snatcher: 增加商品不存在/已下架检测,增加空URL检测
2026-04-01 13:41:10 +08:00
10 changed files with 647 additions and 52 deletions

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ debug_*
*.db
*.db-shm
*.db-wal
*.har
weidian_sso_login*.py

View File

@@ -1,4 +1,5 @@
flask>=3.0
flask-socketio>=5.3
playwright==1.52.0
playwright-stealth>=1.0
pyyaml>=6.0

7
run.py
View File

@@ -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)

View File

@@ -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)

View File

@@ -89,10 +89,7 @@ def do_login(account_id):
@bp.route('/login_sms/<int:account_id>', 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/<int:account_id>')

View File

@@ -128,10 +128,10 @@ def sync_cart(account_id):
if not snatch_time:
continue
cart_item_id = item.get('cart_item_id', '')
item_id = item.get('item_id', '') or cart_item_id
item_id = item.get('item_id', '')
title = item.get('title', '')
# 用 cart_item_id 去重(因为可能没有 item_id
dedup_key = item_id or title
# 去重:优先用 item_id其次 cart_item_id最后 title
dedup_key = item_id or cart_item_id or title
if dedup_key:
existing = db2.execute(
'SELECT id FROM tasks WHERE account_id = ? AND (item_id = ? OR item_name = ?) AND status = "pending"',
@@ -140,8 +140,11 @@ def sync_cart(account_id):
if existing:
continue
url = item.get('url', '')
# 只有真正的 itemID 才拼 URLcart_item_id 不是 itemID
if not url and item_id and item_id.isdigit():
url = f'https://weidian.com/item.html?itemID={item_id}'
if not item_id:
item_id = cart_item_id
db2.execute(
'INSERT INTO tasks (account_id, target_url, item_name, item_id, sku_id, price, snatch_time) VALUES (?, ?, ?, ?, ?, ?, ?)',
(account_id, url, title, item_id,
@@ -217,9 +220,9 @@ def sync_all_carts():
if not snatch_time:
continue
cart_item_id = item.get('cart_item_id', '')
item_id = item.get('item_id', '') or cart_item_id
item_id = item.get('item_id', '')
title = item.get('title', '')
dedup_key = item_id or title
dedup_key = item_id or cart_item_id or title
if dedup_key:
existing = db2.execute(
'SELECT id FROM tasks WHERE account_id = ? AND (item_id = ? OR item_name = ?) AND status = "pending"',
@@ -230,6 +233,8 @@ def sync_all_carts():
url = item.get('url', '')
if not url and item_id and item_id.isdigit():
url = f'https://weidian.com/item.html?itemID={item_id}'
if not item_id:
item_id = cart_item_id
db2.execute(
'INSERT INTO tasks (account_id, target_url, item_name, item_id, sku_id, price, snatch_time) VALUES (?, ?, ?, ?, ?, ?, ?)',
(aid, url, title, item_id,

View File

@@ -1,15 +1,16 @@
"""
购物车预售商品抓取服务
通过 Playwright 打开购物车页面,从 DOM 的 item_warp 提取商品信息
通过 Playwright 打开购物车页面,拦截 API + DOM 提取商品信息
"""
import asyncio
import json
from playwright.async_api import async_playwright
from utils.stealth import stealth_async
from server.services.auth_service import get_browser_context, has_auth
CART_URL = "https://weidian.com/new-cart/index.php"
# 提取购物车商品的 JS与 test_cart.py 保持一致
# 从 DOM 提取购物车商品(含尝试从 Vue 组件获取 itemID
EXTRACT_JS = """() => {
const R = [];
const sws = document.querySelectorAll(
@@ -22,10 +23,35 @@ EXTRACT_JS = """() => {
const o = {
shop_name: sn.trim(),
cart_item_id: iw.id,
item_id: '',
title: '', sku_name: '', price: '',
is_presale: false, countdown_text: '',
sale_time: '', presale_type: ''
};
// 尝试从 Vue 组件数据中提取 itemID
try {
const vue = iw.__vue__ || (iw.__vue_app__ && iw.__vue_app__._instance);
if (vue) {
const d = vue.$data || vue.data || vue;
o.item_id = String(d.itemID || d.itemId || d.item_id || '');
}
} catch(e) {}
// 尝试从 data-* 属性提取
if (!o.item_id) {
o.item_id = iw.dataset.itemId || iw.dataset.itemid || '';
}
// 尝试从内部链接提取
if (!o.item_id) {
const a = iw.querySelector('a[href*="itemID"]');
if (a) {
const m = a.href.match(/itemID=(\\d+)/);
if (m) o.item_id = m[1];
}
}
const te = iw.querySelector('.item_title');
if (te) o.title = te.textContent.trim();
const sk = iw.querySelector('.item_sku');
@@ -62,12 +88,16 @@ EXTRACT_JS = """() => {
async def fetch_cart_presale_items(account_id):
"""
获取指定账号购物车中的预售商品列表
获取指定账号购物车中的预售商品列表
双重提取:拦截购物车 API 获取 itemID 映射 + DOM 提取预售信息。
返回: (success, items_or_msg)
"""
if not has_auth(account_id):
return False, "账号未登录"
# 用于存储 API 返回的 cart_item_id -> itemID 映射
api_item_map = {}
async with async_playwright() as p:
browser, context = await get_browser_context(
p, account_id, headless=True
@@ -75,6 +105,20 @@ async def fetch_cart_presale_items(account_id):
page = await context.new_page()
await stealth_async(page)
# 拦截购物车相关 API提取 itemID
async def on_response(response):
url = response.url
# 购物车 API 通常包含 cart 相关路径
if any(k in url for k in ['cart/list', 'cart/query', 'cartList', 'getCart',
'cart-server', 'newcart']):
try:
data = await response.json()
_extract_item_ids(data, api_item_map)
except Exception:
pass
page.on("response", on_response)
try:
await page.goto(
CART_URL, wait_until="networkidle", timeout=20000
@@ -93,9 +137,74 @@ async def fetch_cart_presale_items(account_id):
await browser.close()
return False, f"打开购物车失败: {e}"
# 也尝试从页面内嵌的 JS 变量/window 对象提取
try:
extra_map = await page.evaluate("""() => {
const m = {};
// 尝试从 window.__INITIAL_STATE__ 或类似全局变量提取
const sources = [
window.__INITIAL_STATE__,
window.__NUXT__,
window.__APP_DATA__,
window.cartData,
window.__data__,
];
function walk(obj, depth) {
if (!obj || depth > 5) return;
if (Array.isArray(obj)) {
for (const item of obj) walk(item, depth + 1);
} else if (typeof obj === 'object') {
const cid = String(obj.cartItemId || obj.cart_item_id || obj.cartId || '');
const iid = String(obj.itemID || obj.itemId || obj.item_id || obj.goodsId || '');
if (cid && iid && iid !== cid) m[cid] = iid;
// 也存 itemUrl
if (cid && obj.itemUrl) m[cid + '_url'] = obj.itemUrl;
for (const v of Object.values(obj)) walk(v, depth + 1);
}
}
for (const s of sources) { if (s) walk(s, 0); }
return m;
}""")
if extra_map:
api_item_map.update(extra_map)
except Exception:
pass
raw_items = await page.evaluate(EXTRACT_JS)
await browser.close()
# 合并 API 数据到 DOM 提取结果
for item in raw_items:
cid = item.get('cart_item_id', '')
if not item.get('item_id') and cid in api_item_map:
item['item_id'] = api_item_map[cid]
# 检查是否有 URL
url_key = cid + '_url'
if url_key in api_item_map:
item['url'] = api_item_map[url_key]
# 只返回预售商品
presale = [it for it in raw_items if it.get("is_presale")]
return True, presale
def _extract_item_ids(data, result_map):
"""递归遍历 API 响应 JSON提取 cart_item_id -> itemID 映射"""
if isinstance(data, list):
for item in data:
_extract_item_ids(item, result_map)
elif isinstance(data, dict):
# 常见字段名
cid = str(data.get('cartItemId', data.get('cart_item_id',
data.get('cartId', ''))))
iid = str(data.get('itemID', data.get('itemId',
data.get('item_id', data.get('goodsId', '')))))
if cid and iid and cid != iid and iid != 'None':
result_map[cid] = iid
# 也提取 URL
item_url = data.get('itemUrl', data.get('item_url', ''))
if cid and item_url:
result_map[cid + '_url'] = item_url
for v in data.values():
if isinstance(v, (dict, list)):
_extract_item_ids(v, result_map)

View File

@@ -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()

View File

@@ -42,6 +42,18 @@ async def run_snatch(task_id):
await browser.close()
return
# 检查商品是否存在
page_text = await page.locator('body').text_content()
if '商品不存在' in (page_text or '') or '已下架' in (page_text or ''):
_update_task(db, task_id, 'failed', f'商品不存在或已下架 (URL: {target_url})')
await browser.close()
return
if not target_url or target_url.strip() == '':
_update_task(db, task_id, 'failed', '商品链接为空,请检查购物车同步是否获取到了 itemID')
await browser.close()
return
# 2. 等待抢购时间
snatch_time = task['snatch_time']
if snatch_time:

View File

@@ -21,9 +21,10 @@
<td>
{% if a.is_logged_in %}
<span class="badge bg-success">已登录</span>
{% elif a.login_msg == '登录中...' %}
{% elif a.login_msg == '登录中...' or a.login_msg == '等待人机交互...' %}
<span class="badge bg-warning">
<span class="spinner-border spinner-border-sm" style="width:.7rem;height:.7rem"></span> 登录中...
<span class="spinner-border spinner-border-sm" style="width:.7rem;height:.7rem"></span>
{{ a.login_msg }}
</span>
{% else %}
<span class="badge bg-secondary" title="{{ a.login_msg or '' }}">未登录</span>
@@ -50,6 +51,8 @@
</table>
</div>
</div>
<!-- 添加账号弹窗 -->
<div class="modal fade" id="addModal" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">添加微店账号</h5></div>
@@ -75,60 +78,190 @@
</div>
</div></div>
</div>
<!-- 远程浏览器弹窗 -->
<div class="modal fade" id="rbModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered" style="max-width:430px">
<div class="modal-content" style="border-radius:16px;overflow:hidden">
<div class="modal-header py-2" style="background:#f0f4ff;border:none">
<span class="fw-bold" style="font-size:.9rem">
<i class="bi bi-phone"></i> 远程浏览器 — 短信登录
</span>
<button type="button" class="btn-close" onclick="closeRB()"></button>
</div>
<div class="modal-body p-0 text-center" style="background:#000">
<div id="rbStatus" style="color:#fff;font-size:.8rem;padding:6px 12px;background:#333">
正在启动浏览器...
</div>
<canvas id="rbCanvas" width="390" height="844"
style="width:390px;height:844px;cursor:pointer;display:block;margin:0 auto"></canvas>
</div>
<div class="modal-footer py-2 justify-content-center" style="background:#f0f4ff;border:none">
<span style="font-size:.75rem;color:#888">
直接在上方画面中点击、拖动操作浏览器
</span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script>
var rbSocket = null, rbSessionId = null, rbDragging = false;
var rbCanvas = document.getElementById('rbCanvas');
var rbCtx = rbCanvas.getContext('2d');
function addAccount() {
var form = document.getElementById('addForm');
var data = new FormData(form);
var btn = event.target;
btn.disabled = true;
btn.textContent = '添加中...';
btn.disabled = true; btn.textContent = '添加中...';
fetch('/accounts/add', { method: 'POST', body: data })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
form.reset();
location.reload();
} else {
alert(d.msg);
btn.disabled = false;
btn.textContent = '添加';
}
})
.catch(function() { btn.disabled = false; btn.textContent = '添加'; });
form.reset(); location.reload();
} else { alert(d.msg); btn.disabled = false; btn.textContent = '添加'; }
}).catch(function() { btn.disabled = false; btn.textContent = '添加'; });
}
function doLogin(id, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 登录中...';
var badge = btn.closest('tr').querySelector('.badge');
if (badge) { badge.className = 'badge bg-warning'; badge.textContent = '登录中...'; }
fetch('/accounts/login/' + id, { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function() { pollStatus(id, btn); })
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-key"></i> 密码登录'; });
}
function doSmsLogin(id, btn) {
if (!confirm('短信登录需要您在弹出的浏览器中拖动滑块,并在服务器终端输入验证码。确定继续?')) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 等待交互...';
var badge = btn.closest('tr').querySelector('.badge');
if (badge) { badge.className = 'badge bg-info'; badge.textContent = '等待人机交互...'; }
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 启动中...';
fetch('/accounts/login_sms/' + id, { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
alert(d.msg);
pollStatus(id, btn);
if (d.success && d.session_id) {
openRB(d.session_id, id, btn);
} else {
alert(d.msg);
alert(d.msg || '启动失败');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
}
})
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录'; });
}).catch(function() {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
});
}
function openRB(sessionId, accountId, btn) {
rbSessionId = sessionId;
document.getElementById('rbStatus').textContent = '正在启动浏览器...';
rbCtx.fillStyle = '#111';
rbCtx.fillRect(0, 0, 390, 844);
rbCtx.fillStyle = '#666';
rbCtx.font = '14px sans-serif';
rbCtx.textAlign = 'center';
rbCtx.fillText('正在加载...', 195, 422);
var modal = new bootstrap.Modal(document.getElementById('rbModal'));
modal.show();
// 连接 WebSocket
if (rbSocket) { rbSocket.disconnect(); }
rbSocket = io('/rb', { transports: ['websocket', 'polling'] });
rbSocket.on('connect', function() {
console.log('RB WebSocket connected');
});
rbSocket.on('rb_frame', function(data) {
if (data.session_id !== rbSessionId) return;
var img = new Image();
img.onload = function() {
rbCtx.drawImage(img, 0, 0, 390, 844);
};
img.src = 'data:image/jpeg;base64,' + data.data;
});
rbSocket.on('rb_status', function(data) {
if (data.session_id !== rbSessionId) return;
document.getElementById('rbStatus').textContent = data.msg || '';
if (data.status === 'success') {
document.getElementById('rbStatus').style.background = '#198754';
document.getElementById('rbStatus').textContent = '✅ 登录成功3秒后关闭...';
setTimeout(function() { closeRB(); location.reload(); }, 3000);
} else if (data.status === 'failed') {
document.getElementById('rbStatus').style.background = '#dc3545';
}
});
// Canvas 鼠标/触摸事件
rbCanvas.onmousedown = function(e) {
rbDragging = true;
var r = rbCanvas.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
rbSocket.emit('rb_mouse', { session_id: rbSessionId, action: 'down', x: x, y: y });
};
rbCanvas.onmousemove = function(e) {
if (!rbDragging) return;
var r = rbCanvas.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
rbSocket.emit('rb_mouse', { session_id: rbSessionId, action: 'move', x: x, y: y });
};
rbCanvas.onmouseup = function(e) {
if (!rbDragging) return;
rbDragging = false;
var r = rbCanvas.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
rbSocket.emit('rb_mouse', { session_id: rbSessionId, action: 'up', x: x, y: y });
};
rbCanvas.onclick = function(e) {
if (rbDragging) return;
var r = rbCanvas.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
rbSocket.emit('rb_mouse', { session_id: rbSessionId, action: 'click', x: x, y: y });
};
// 键盘事件(当 modal 打开时捕获)
document.addEventListener('keydown', rbKeyHandler);
// 存储 btn 引用用于关闭时恢复
window._rbBtn = btn;
window._rbAccountId = accountId;
}
function rbKeyHandler(e) {
if (!rbSocket || !rbSessionId) return;
// 不拦截 Escape
if (e.key === 'Escape') return;
e.preventDefault();
if (e.key.length === 1) {
rbSocket.emit('rb_keyboard', { session_id: rbSessionId, text: e.key });
} else {
rbSocket.emit('rb_key', { session_id: rbSessionId, key: e.key });
}
}
function closeRB() {
document.removeEventListener('keydown', rbKeyHandler);
if (rbSocket && rbSessionId) {
rbSocket.emit('rb_close', { session_id: rbSessionId });
rbSocket.disconnect();
rbSocket = null;
}
rbSessionId = null;
try {
bootstrap.Modal.getInstance(document.getElementById('rbModal')).hide();
} catch(e) {}
if (window._rbBtn) {
window._rbBtn.disabled = false;
window._rbBtn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
}
}
function pollStatus(id, btn) {
var interval = setInterval(function() {
fetch('/accounts/status/' + id)
@@ -140,24 +273,21 @@ function pollStatus(id, btn) {
if (row) {
var badge = row.querySelector('.badge');
if (d.is_logged_in) {
badge.className = 'badge bg-success';
badge.textContent = '已登录';
badge.className = 'badge bg-success'; badge.textContent = '已登录';
} else {
badge.className = 'badge bg-danger';
badge.textContent = '失败';
badge.className = 'badge bg-danger'; badge.textContent = '失败';
badge.title = d.login_msg;
}
}
if (btn) {
btn.disabled = false;
btn.innerHTML = btn.textContent.includes('短信') ?
'<i class="bi bi-chat-dots"></i> 短信登录' :
'<i class="bi bi-key"></i> 密码登录';
btn.innerHTML = '<i class="bi bi-key"></i> 密码登录';
}
}
});
}, 2000);
}
function deleteAccount(id) {
if (!confirm('确定删除此账号?相关任务也会被删除。')) return;
fetch('/accounts/delete/' + id, { method: 'POST' })