Compare commits
12 Commits
def06c6360
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 81b569c54d | |||
| fa788bf3fc | |||
| 17fab8d14c | |||
| c293f3d4ac | |||
| 6ab6666cac | |||
| 400569ad03 | |||
| 28fa04c407 | |||
| 5e7c140951 | |||
| 4dc918becd | |||
| 20dfda28e3 | |||
| 8b64d7e69e | |||
| 2ebdaec965 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ debug_*
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.har
|
||||
weidian_sso_login*.py
|
||||
|
||||
@@ -18,5 +18,7 @@ EXPOSE 9000
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
ENV FLASK_DEBUG=0
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
CMD ["python", "run.py"]
|
||||
|
||||
157
main.py
157
main.py
@@ -1,18 +1,27 @@
|
||||
"""
|
||||
独立抢购脚本(命令行模式)— 优化版
|
||||
用法: python main.py
|
||||
"""
|
||||
import asyncio
|
||||
import yaml
|
||||
import time
|
||||
from playwright.async_api import async_playwright
|
||||
from utils.stealth import stealth_async
|
||||
from utils.auth import Authenticator
|
||||
from utils.timer import PrecisionTimer
|
||||
|
||||
BUY_TEXTS = ["立即抢购", "立即购买", "马上抢", "立即秒杀"]
|
||||
MAX_RETRIES = 5
|
||||
CONCURRENT_TABS = 2
|
||||
|
||||
|
||||
async def snatch(config):
|
||||
auth = Authenticator(config.get("auth_file", "auth_state.json"))
|
||||
timer = PrecisionTimer()
|
||||
timer.sync_time()
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser, context = await auth.get_context(p, headless=config.get("headless", False))
|
||||
browser, context = await auth.get_context(
|
||||
p, headless=config.get("headless", False))
|
||||
page = await context.new_page()
|
||||
await stealth_async(page)
|
||||
|
||||
@@ -23,7 +32,7 @@ async def snatch(config):
|
||||
|
||||
# 1. 预热:先打开页面
|
||||
print(f"正在打开商品页面: {target_url}")
|
||||
await page.goto(target_url)
|
||||
await page.goto(target_url, wait_until='networkidle', timeout=20000)
|
||||
|
||||
# 如果未登录,处理登录
|
||||
if not auth.has_auth():
|
||||
@@ -31,83 +40,111 @@ async def snatch(config):
|
||||
await auth.login(target_url)
|
||||
await context.close()
|
||||
await browser.close()
|
||||
browser, context = await auth.get_context(p, headless=config.get("headless", False))
|
||||
browser, context = await auth.get_context(
|
||||
p, headless=config.get("headless", False))
|
||||
page = await context.new_page()
|
||||
await stealth_async(page)
|
||||
print("已重新加载登录状态,正在打开商品页面...")
|
||||
await page.goto(target_url)
|
||||
await page.goto(target_url, wait_until='networkidle',
|
||||
timeout=20000)
|
||||
|
||||
# 2. 等待抢购时间
|
||||
# 2. 等待抢购时间(提前 500ms 触发)
|
||||
snatch_time = config.get("snatch_time")
|
||||
if snatch_time:
|
||||
await timer.wait_until(snatch_time)
|
||||
await timer.wait_until_early(snatch_time, early_ms=500)
|
||||
|
||||
# 3. 抢购核心逻辑
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
# 3. 并发抢购
|
||||
pages = [page]
|
||||
for _ in range(CONCURRENT_TABS - 1):
|
||||
try:
|
||||
# 刷新页面,让预售按钮变为可点击
|
||||
if attempt > 0:
|
||||
await asyncio.sleep(0.3)
|
||||
await page.reload(wait_until='domcontentloaded', timeout=10000)
|
||||
await asyncio.sleep(0.5)
|
||||
p2 = await context.new_page()
|
||||
await stealth_async(p2)
|
||||
await p2.goto(target_url, wait_until='commit', timeout=10000)
|
||||
pages.append(p2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 点击购买按钮(兼容多种文案)
|
||||
buy_btn = None
|
||||
for text in ["立即抢购", "立即购买", "马上抢", "立即秒杀"]:
|
||||
loc = page.get_by_text(text, exact=False)
|
||||
if await loc.count() > 0:
|
||||
buy_btn = loc.first
|
||||
break
|
||||
tasks = [_do_purchase(pg, i) for i, pg in enumerate(pages)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
if not buy_btn:
|
||||
if attempt < max_retries - 1:
|
||||
print(f"第{attempt+1}次未找到购买按钮,重试...")
|
||||
continue
|
||||
print("错误: 未找到购买按钮")
|
||||
break
|
||||
|
||||
await buy_btn.click(timeout=3000)
|
||||
print("点击购买按钮")
|
||||
|
||||
# 处理 SKU 选择(如果弹出规格选择框)
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
confirm_btn = page.get_by_text("确定", exact=True)
|
||||
if await confirm_btn.count() > 0 and await confirm_btn.first.is_visible():
|
||||
# 自动选择第一个可用的 SKU 选项
|
||||
sku_items = page.locator('.sku-item:not(.disabled), .sku_item:not(.disabled), [class*="sku"] [class*="item"]:not([class*="disabled"])')
|
||||
if await sku_items.count() > 0:
|
||||
await sku_items.first.click()
|
||||
print("自动选择 SKU")
|
||||
await asyncio.sleep(0.3)
|
||||
await confirm_btn.first.click(timeout=3000)
|
||||
print("点击确定(SKU)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 提交订单
|
||||
submit_btn = page.get_by_text("提交订单")
|
||||
await submit_btn.wait_for(state="visible", timeout=8000)
|
||||
await submit_btn.click()
|
||||
print("点击提交订单!抢购请求已发送!")
|
||||
for r in results:
|
||||
print(f"结果: {r}")
|
||||
if isinstance(r, str) and ('已提交' in r or '已发送' in r):
|
||||
print("✅ 抢购成功!")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
print(f"第{attempt+1}次尝试失败: {e},重试...")
|
||||
else:
|
||||
print(f"抢购失败: {e}")
|
||||
|
||||
# 保持浏览器打开一段时间查看结果
|
||||
await asyncio.sleep(10)
|
||||
await browser.close()
|
||||
|
||||
|
||||
async def _do_purchase(page, tab_index=0):
|
||||
"""极速购买流程"""
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
await page.reload(wait_until='commit', timeout=8000)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# 点击购买按钮
|
||||
buy_btn = None
|
||||
for text in BUY_TEXTS:
|
||||
loc = page.get_by_text(text, exact=False)
|
||||
try:
|
||||
await loc.first.wait_for(state="visible", timeout=1500)
|
||||
buy_btn = loc.first
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not buy_btn:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
print(f"tab{tab_index} 第{attempt+1}次未找到按钮,重试...")
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
return f"tab{tab_index}: 未找到购买按钮"
|
||||
|
||||
await buy_btn.click(timeout=2000)
|
||||
print(f"tab{tab_index}: 点击购买按钮")
|
||||
|
||||
# 处理 SKU 弹窗
|
||||
try:
|
||||
confirm_btn = page.get_by_text("确定", exact=True)
|
||||
await confirm_btn.first.wait_for(state="visible",
|
||||
timeout=1500)
|
||||
sku_sel = ('.sku-item:not(.disabled), '
|
||||
'.sku_item:not(.disabled), '
|
||||
'[class*="sku"] [class*="item"]'
|
||||
':not([class*="disabled"])')
|
||||
sku_items = page.locator(sku_sel)
|
||||
if await sku_items.count() > 0:
|
||||
await sku_items.first.click()
|
||||
print(f"tab{tab_index}: 自动选择 SKU")
|
||||
await asyncio.sleep(0.1)
|
||||
await confirm_btn.first.click(timeout=2000)
|
||||
print(f"tab{tab_index}: 点击确定")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 提交订单
|
||||
submit_btn = page.get_by_text("提交订单")
|
||||
await submit_btn.wait_for(state="visible", timeout=6000)
|
||||
await submit_btn.click()
|
||||
return f"tab{tab_index}: 抢购请求已提交"
|
||||
|
||||
except Exception as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
print(f"tab{tab_index} 第{attempt+1}次失败: {e},重试...")
|
||||
await asyncio.sleep(0.05)
|
||||
else:
|
||||
return f"tab{tab_index}: 抢购失败: {e}"
|
||||
|
||||
return f"tab{tab_index}: 重试次数用尽"
|
||||
|
||||
|
||||
def load_config():
|
||||
with open("config.yaml", "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = load_config()
|
||||
asyncio.run(snatch(config))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
flask>=3.0
|
||||
flask-socketio>=5.3
|
||||
simple-websocket>=1.0
|
||||
playwright==1.52.0
|
||||
playwright-stealth>=1.0
|
||||
pyyaml>=6.0
|
||||
|
||||
7
run.py
7
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)
|
||||
|
||||
243
server/app.py
243
server/app.py
@@ -1,24 +1,214 @@
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
# 确保项目根目录在 sys.path 中
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from flask import Flask, redirect, url_for
|
||||
from server.database import init_db
|
||||
from server.routers import accounts, tasks, orders
|
||||
from flask import (Flask, redirect, url_for, request, session,
|
||||
render_template, jsonify)
|
||||
from flask_socketio import SocketIO
|
||||
from server.database import init_db, get_db
|
||||
|
||||
socketio = SocketIO(cors_allowed_origins="*")
|
||||
|
||||
# 防爆破:最大失败次数 & 锁定时间(秒)
|
||||
MAX_FAIL = 5
|
||||
LOCK_SECONDS = 300 # 5分钟
|
||||
|
||||
|
||||
def _hash_pwd(pwd):
|
||||
return hashlib.sha256(pwd.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_admin():
|
||||
db = get_db()
|
||||
row = db.execute('SELECT * FROM admin WHERE id = 1').fetchone()
|
||||
db.close()
|
||||
return row
|
||||
|
||||
|
||||
def _is_locked():
|
||||
admin = _get_admin()
|
||||
if not admin:
|
||||
return False
|
||||
if admin['fail_count'] >= MAX_FAIL and admin['locked_until']:
|
||||
try:
|
||||
from datetime import datetime
|
||||
locked = datetime.strptime(admin['locked_until'],
|
||||
'%Y-%m-%d %H:%M:%S')
|
||||
if datetime.now() < locked:
|
||||
return True
|
||||
# 锁定已过期,重置
|
||||
db = get_db()
|
||||
db.execute("UPDATE admin SET fail_count=0, locked_until='' "
|
||||
"WHERE id=1")
|
||||
db.commit()
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _record_fail():
|
||||
from datetime import datetime, timedelta
|
||||
db = get_db()
|
||||
admin = db.execute('SELECT fail_count FROM admin WHERE id=1').fetchone()
|
||||
if admin:
|
||||
new_count = (admin['fail_count'] or 0) + 1
|
||||
locked_until = ''
|
||||
if new_count >= MAX_FAIL:
|
||||
locked_until = (datetime.now() + timedelta(
|
||||
seconds=LOCK_SECONDS)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
db.execute("UPDATE admin SET fail_count=?, locked_until=?, "
|
||||
"updated_at=datetime('now','localtime') WHERE id=1",
|
||||
(new_count, locked_until))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _reset_fail():
|
||||
db = get_db()
|
||||
db.execute("UPDATE admin SET fail_count=0, locked_until='', "
|
||||
"updated_at=datetime('now','localtime') WHERE id=1")
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=os.path.join(os.path.dirname(__file__), '..', 'templates'),
|
||||
static_folder=os.path.join(os.path.dirname(__file__), '..', 'static'),
|
||||
template_folder=os.path.join(os.path.dirname(__file__), '..',
|
||||
'templates'),
|
||||
static_folder=os.path.join(os.path.dirname(__file__), '..',
|
||||
'static'),
|
||||
)
|
||||
app.secret_key = 'snatcher-secret-key-change-me'
|
||||
app.secret_key = os.environ.get(
|
||||
'SECRET_KEY', 'snatcher-' + _hash_pwd('change-me')[:16])
|
||||
|
||||
init_db()
|
||||
|
||||
# ── 登录 & 初始设置密码 ──
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
admin = _get_admin()
|
||||
# 首次使用:跳转设置密码
|
||||
if not admin:
|
||||
return redirect(url_for('setup'))
|
||||
|
||||
if request.method == 'POST':
|
||||
if _is_locked():
|
||||
remain = ''
|
||||
try:
|
||||
from datetime import datetime
|
||||
locked = datetime.strptime(
|
||||
_get_admin()['locked_until'], '%Y-%m-%d %H:%M:%S')
|
||||
remain = f'{int((locked - datetime.now()).total_seconds())}秒'
|
||||
except Exception:
|
||||
remain = f'{LOCK_SECONDS}秒'
|
||||
return render_template(
|
||||
'login.html',
|
||||
error=f'登录失败次数过多,请{remain}后再试')
|
||||
|
||||
pwd = request.form.get('password', '')
|
||||
if _hash_pwd(pwd) == admin['password_hash']:
|
||||
_reset_fail()
|
||||
session['authenticated'] = True
|
||||
session.permanent = True
|
||||
app.permanent_session_lifetime = __import__(
|
||||
'datetime').timedelta(days=7)
|
||||
return redirect(request.args.get('next', '/'))
|
||||
else:
|
||||
_record_fail()
|
||||
admin = _get_admin()
|
||||
remaining = MAX_FAIL - (admin['fail_count'] or 0)
|
||||
if remaining <= 0:
|
||||
return render_template(
|
||||
'login.html',
|
||||
error=f'账号已锁定{LOCK_SECONDS // 60}分钟')
|
||||
return render_template(
|
||||
'login.html',
|
||||
error=f'密码错误,还剩{remaining}次机会')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/setup', methods=['GET', 'POST'])
|
||||
def setup():
|
||||
admin = _get_admin()
|
||||
if admin:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
pwd = request.form.get('password', '')
|
||||
pwd2 = request.form.get('password2', '')
|
||||
if len(pwd) < 6:
|
||||
return render_template('setup.html',
|
||||
error='密码至少6位')
|
||||
if pwd != pwd2:
|
||||
return render_template('setup.html',
|
||||
error='两次密码不一致')
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO admin (id, password_hash) VALUES (1, ?)",
|
||||
(_hash_pwd(pwd),))
|
||||
db.commit()
|
||||
db.close()
|
||||
session['authenticated'] = True
|
||||
session.permanent = True
|
||||
app.permanent_session_lifetime = __import__(
|
||||
'datetime').timedelta(days=7)
|
||||
return redirect('/')
|
||||
|
||||
return render_template('setup.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect('/login')
|
||||
|
||||
@app.route('/change_password', methods=['GET', 'POST'])
|
||||
def change_password():
|
||||
if not session.get('authenticated'):
|
||||
return redirect(url_for('login'))
|
||||
if request.method == 'POST':
|
||||
old = request.form.get('old_password', '')
|
||||
new = request.form.get('new_password', '')
|
||||
new2 = request.form.get('new_password2', '')
|
||||
admin = _get_admin()
|
||||
if _hash_pwd(old) != admin['password_hash']:
|
||||
return render_template('change_password.html',
|
||||
error='原密码错误')
|
||||
if len(new) < 6:
|
||||
return render_template('change_password.html',
|
||||
error='新密码至少6位')
|
||||
if new != new2:
|
||||
return render_template('change_password.html',
|
||||
error='两次密码不一致')
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE admin SET password_hash=?, "
|
||||
"updated_at=datetime('now','localtime') WHERE id=1",
|
||||
(_hash_pwd(new),))
|
||||
db.commit()
|
||||
db.close()
|
||||
return render_template('change_password.html',
|
||||
success='密码修改成功')
|
||||
return render_template('change_password.html')
|
||||
|
||||
@app.before_request
|
||||
def require_auth():
|
||||
allowed = ('/login', '/setup')
|
||||
if request.path in allowed or request.path.startswith('/static'):
|
||||
return
|
||||
if request.path.startswith('/socket.io'):
|
||||
return
|
||||
admin = _get_admin()
|
||||
if not admin:
|
||||
return redirect(url_for('setup'))
|
||||
if not session.get('authenticated'):
|
||||
return redirect(url_for('login', next=request.path))
|
||||
|
||||
from server.routers import accounts, tasks, orders
|
||||
app.register_blueprint(accounts.bp)
|
||||
app.register_blueprint(tasks.bp)
|
||||
app.register_blueprint(orders.bp)
|
||||
@@ -27,9 +217,46 @@ 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():
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
s.handle_keyboard(data.get('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():
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
s.handle_key(data.get('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)
|
||||
|
||||
@@ -54,6 +54,15 @@ def init_db():
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
password_hash TEXT NOT NULL,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
locked_until TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -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 才拼 URL(cart_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,
|
||||
|
||||
@@ -146,7 +146,7 @@ async def login_with_password(account_id, phone, password):
|
||||
# 保存 storage_state + 精简 cookies
|
||||
auth_path = get_auth_path(account_id)
|
||||
await context.storage_state(path=auth_path)
|
||||
_save_cookies_file(account_id, context)
|
||||
await _save_cookies_file_async(context, account_id)
|
||||
|
||||
return True, "登录成功"
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""
|
||||
购物车预售商品抓取服务
|
||||
通过 Playwright 打开购物车页面,从 DOM 的 item_warp 提取商品信息
|
||||
通过 Playwright 打开购物车页面,从 DOM 提取商品信息,
|
||||
并通过点击商品图片获取跳转 URL 来提取真实 itemID。
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
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 提取购物车商品基本信息
|
||||
EXTRACT_JS = """() => {
|
||||
const R = [];
|
||||
const sws = document.querySelectorAll(
|
||||
@@ -22,10 +24,53 @@ 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: ''
|
||||
};
|
||||
|
||||
// 尝试多种方式提取 itemID
|
||||
// 1. Vue 组件数据
|
||||
try {
|
||||
const vue = iw.__vue__;
|
||||
if (vue) {
|
||||
const d = vue.$data || vue._data || vue;
|
||||
o.item_id = String(d.itemID || d.itemId || d.item_id
|
||||
|| d.goodsId || d.goods_id || '');
|
||||
// 也检查 props
|
||||
if (!o.item_id && vue.$props) {
|
||||
const pp = vue.$props;
|
||||
o.item_id = String(pp.itemID || pp.itemId
|
||||
|| pp.item_id || pp.goodsId || '');
|
||||
}
|
||||
// 检查 item 对象
|
||||
if (!o.item_id && d.item) {
|
||||
o.item_id = String(d.item.itemID || d.item.itemId
|
||||
|| d.item.item_id || '');
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// 2. data-* 属性
|
||||
if (!o.item_id) {
|
||||
o.item_id = iw.dataset.itemId || iw.dataset.itemid
|
||||
|| iw.dataset.goodsId || iw.dataset.id || '';
|
||||
}
|
||||
|
||||
// 3. 内部链接
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 图片 URL 中可能有商品信息(备用)
|
||||
const img = iw.querySelector('.item_img img');
|
||||
o.img_src = img ? (img.src || img.dataset.src || '') : '';
|
||||
|
||||
const te = iw.querySelector('.item_title');
|
||||
if (te) o.title = te.textContent.trim();
|
||||
const sk = iw.querySelector('.item_sku');
|
||||
@@ -62,7 +107,8 @@ EXTRACT_JS = """() => {
|
||||
|
||||
async def fetch_cart_presale_items(account_id):
|
||||
"""
|
||||
获取指定账号购物车中的预售商品列表
|
||||
获取指定账号购物车中的预售商品列表。
|
||||
对于没有 itemID 的商品,通过点击图片获取跳转 URL 来提取。
|
||||
返回: (success, items_or_msg)
|
||||
"""
|
||||
if not has_auth(account_id):
|
||||
@@ -93,9 +139,100 @@ async def fetch_cart_presale_items(account_id):
|
||||
await browser.close()
|
||||
return False, f"打开购物车失败: {e}"
|
||||
|
||||
# 提取基本信息
|
||||
raw_items = await page.evaluate(EXTRACT_JS)
|
||||
|
||||
# 筛选预售商品
|
||||
presale = [it for it in raw_items if it.get("is_presale")]
|
||||
|
||||
# 对没有 itemID 的预售商品,通过点击图片获取跳转 URL
|
||||
for item in presale:
|
||||
if item.get('item_id'):
|
||||
continue
|
||||
cid = item.get('cart_item_id', '')
|
||||
if not cid:
|
||||
continue
|
||||
|
||||
item_id = await _get_item_id_by_click(page, context, cid)
|
||||
if item_id:
|
||||
item['item_id'] = item_id
|
||||
item['url'] = f'https://weidian.com/item.html?itemID={item_id}'
|
||||
|
||||
await browser.close()
|
||||
|
||||
# 只返回预售商品
|
||||
presale = [it for it in raw_items if it.get("is_presale")]
|
||||
return True, presale
|
||||
|
||||
|
||||
async def _get_item_id_by_click(page, context, cart_item_id):
|
||||
"""
|
||||
通过点击购物车中商品的图片,拦截跳转 URL 来提取 itemID。
|
||||
点击后会打开新 tab,从新 tab 的 URL 中提取 itemID,然后关闭。
|
||||
"""
|
||||
try:
|
||||
# 定位商品图片
|
||||
img_locator = page.locator(
|
||||
f'.item_warp[id="{cart_item_id}"] .item_img')
|
||||
if await img_locator.count() == 0:
|
||||
return None
|
||||
|
||||
# 监听新页面打开事件
|
||||
async with context.expect_page(timeout=5000) as new_page_info:
|
||||
await img_locator.first.click()
|
||||
|
||||
new_page = await new_page_info.value
|
||||
# 等待 URL 加载
|
||||
await asyncio.sleep(1)
|
||||
url = new_page.url
|
||||
|
||||
# 从 URL 提取 itemID
|
||||
item_id = _extract_item_id_from_url(url)
|
||||
|
||||
# 关闭新 tab
|
||||
await new_page.close()
|
||||
|
||||
return item_id
|
||||
|
||||
except Exception:
|
||||
# 如果 expect_page 超时,可能是在当前页面跳转了
|
||||
# 检查当前页面 URL
|
||||
try:
|
||||
current_url = page.url
|
||||
item_id = _extract_item_id_from_url(current_url)
|
||||
if item_id:
|
||||
# 跳回购物车
|
||||
await page.goto(CART_URL, wait_until="networkidle",
|
||||
timeout=15000)
|
||||
await asyncio.sleep(2)
|
||||
return item_id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 确保回到购物车页面
|
||||
try:
|
||||
if 'new-cart' not in page.url:
|
||||
await page.goto(CART_URL, wait_until="networkidle",
|
||||
timeout=15000)
|
||||
await asyncio.sleep(2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_item_id_from_url(url):
|
||||
"""从 URL 中提取 itemID"""
|
||||
if not url:
|
||||
return None
|
||||
# https://weidian.com/item.html?itemID=123456
|
||||
m = re.search(r'itemID=(\d+)', url, re.IGNORECASE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# https://shop.weidian.com/item/123456
|
||||
m = re.search(r'/item/(\d+)', url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# https://weidian.com/...?id=123456
|
||||
m = re.search(r'[?&]id=(\d+)', url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
292
server/services/remote_browser.py
Normal file
292
server/services/remote_browser.py
Normal 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()
|
||||
@@ -1,3 +1,11 @@
|
||||
"""
|
||||
抢购核心服务 — 优化版 v2
|
||||
策略:两阶段重试
|
||||
阶段1: 商品详情页直接抢购(5次快速重试)
|
||||
阶段2: 若阶段1失败,切换到购物车入口下单(5次重试)
|
||||
从购物车勾选商品 → 结算 → 提交订单
|
||||
这样避免单一页面刷新过多触发风控
|
||||
"""
|
||||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
from utils.stealth import stealth_async
|
||||
@@ -6,6 +14,12 @@ from server.services.auth_service import get_browser_context, has_auth
|
||||
from server.database import get_db
|
||||
from datetime import datetime
|
||||
|
||||
CONCURRENT_TABS = 2
|
||||
PHASE1_RETRIES = 5 # 商品页重试次数
|
||||
PHASE2_RETRIES = 5 # 购物车重试次数
|
||||
BUY_TEXTS = ["立即抢购", "立即购买", "马上抢", "立即秒杀"]
|
||||
CART_URL = "https://weidian.com/new-cart/index.php"
|
||||
|
||||
|
||||
async def run_snatch(task_id):
|
||||
"""执行单个抢购任务"""
|
||||
@@ -19,51 +33,105 @@ async def run_snatch(task_id):
|
||||
_update_task(db, task_id, 'failed', '账号未登录')
|
||||
return
|
||||
|
||||
target_url = task['target_url']
|
||||
if not target_url or not target_url.strip():
|
||||
_update_task(db, task_id, 'failed',
|
||||
'商品链接为空,请检查购物车同步是否获取到了 itemID')
|
||||
return
|
||||
|
||||
_update_task(db, task_id, 'running', '正在准备...')
|
||||
|
||||
timer = PrecisionTimer()
|
||||
timer.sync_time()
|
||||
|
||||
cart_item_id = task['item_id'] or '' # 购物车商品ID,用于阶段2
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser, context = await get_browser_context(p, account_id, headless=True)
|
||||
browser, context = await get_browser_context(
|
||||
p, account_id, headless=True)
|
||||
|
||||
# ── 1. 预热:打开商品页面 ──
|
||||
_update_task(db, task_id, 'running', '预热:打开商品页面...')
|
||||
page = await context.new_page()
|
||||
await stealth_async(page)
|
||||
await page.goto(target_url, wait_until='networkidle',
|
||||
timeout=20000)
|
||||
|
||||
target_url = task['target_url']
|
||||
|
||||
# 1. 预热:先打开商品页面
|
||||
_update_task(db, task_id, 'running', '正在打开商品页面...')
|
||||
await page.goto(target_url, wait_until='networkidle', timeout=20000)
|
||||
|
||||
# 检查是否被重定向到登录页
|
||||
# 检查页面状态
|
||||
if 'login' in page.url.lower():
|
||||
_update_task(db, task_id, 'failed', '登录态已过期')
|
||||
await browser.close()
|
||||
return
|
||||
|
||||
# 2. 等待抢购时间
|
||||
body_text = await page.locator('body').text_content()
|
||||
if '商品不存在' in (body_text or '') or '已下架' in (body_text or ''):
|
||||
_update_task(db, task_id, 'failed', '商品不存在或已下架')
|
||||
await browser.close()
|
||||
return
|
||||
|
||||
# ── 2. 等待抢购时间 ──
|
||||
snatch_time = task['snatch_time']
|
||||
if snatch_time:
|
||||
_update_task(db, task_id, 'running', f'等待抢购时间: {snatch_time}')
|
||||
await timer.wait_until(snatch_time)
|
||||
_update_task(db, task_id, 'running',
|
||||
f'等待开售: {snatch_time}')
|
||||
await timer.wait_until_early(snatch_time, early_ms=500)
|
||||
|
||||
# 3. 抢购核心逻辑(与 main.py 一致)
|
||||
_update_task(db, task_id, 'running', '开始抢购...')
|
||||
result = await _do_purchase(page)
|
||||
# ══════════════════════════════════════
|
||||
# 阶段1: 商品详情页直接抢购
|
||||
# ══════════════════════════════════════
|
||||
_update_task(db, task_id, 'running', '阶段1: 商品页抢购...')
|
||||
|
||||
if '已提交' in result or '已发送' in result:
|
||||
pages = [page]
|
||||
for _ in range(CONCURRENT_TABS - 1):
|
||||
try:
|
||||
p2 = await context.new_page()
|
||||
await stealth_async(p2)
|
||||
await p2.goto(target_url, wait_until='commit',
|
||||
timeout=10000)
|
||||
pages.append(p2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tasks_coro = [_phase1_purchase(pg, i) for i, pg in
|
||||
enumerate(pages)]
|
||||
results = await asyncio.gather(*tasks_coro,
|
||||
return_exceptions=True)
|
||||
|
||||
result = _pick_success(results)
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 阶段2: 购物车入口下单(降级策略)
|
||||
# ══════════════════════════════════════
|
||||
if not _is_success(result):
|
||||
_update_task(db, task_id, 'running',
|
||||
f'阶段1失败({result}),切换购物车下单...')
|
||||
|
||||
# 关闭之前的 tab,开新 tab 去购物车
|
||||
for pg in pages:
|
||||
try:
|
||||
await pg.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cart_page = await context.new_page()
|
||||
await stealth_async(cart_page)
|
||||
result = await _phase2_cart_purchase(
|
||||
cart_page, cart_item_id)
|
||||
|
||||
# ── 记录结果 ──
|
||||
if _is_success(result):
|
||||
_update_task(db, task_id, 'completed', result)
|
||||
db.execute(
|
||||
'INSERT INTO orders (task_id, account_id, status, detail) VALUES (?, ?, ?, ?)',
|
||||
(task_id, account_id, 'submitted', result)
|
||||
)
|
||||
'INSERT INTO orders (task_id, account_id, status, detail)'
|
||||
' VALUES (?, ?, ?, ?)',
|
||||
(task_id, account_id, 'submitted', result))
|
||||
else:
|
||||
_update_task(db, task_id, 'failed', result)
|
||||
db.execute(
|
||||
'INSERT INTO orders (task_id, account_id, status, detail) VALUES (?, ?, ?, ?)',
|
||||
(task_id, account_id, 'failed', result)
|
||||
)
|
||||
'INSERT INTO orders (task_id, account_id, status, detail)'
|
||||
' VALUES (?, ?, ?, ?)',
|
||||
(task_id, account_id, 'failed', result))
|
||||
db.commit()
|
||||
|
||||
await asyncio.sleep(3)
|
||||
@@ -75,71 +143,190 @@ async def run_snatch(task_id):
|
||||
db.close()
|
||||
|
||||
|
||||
async def _do_purchase(page):
|
||||
"""
|
||||
执行购买流程:
|
||||
1. 刷新页面(预售商品需要刷新才能出现购买按钮)
|
||||
2. 点击"立即购买"/"立即抢购"
|
||||
3. 处理 SKU 选择 -> 点击"确定"
|
||||
4. 进入订单确认页 -> 点击"提交订单"
|
||||
支持多次重试
|
||||
"""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
# ─────────────────────────────────────────────
|
||||
# 阶段1: 商品详情页快速抢购
|
||||
# ─────────────────────────────────────────────
|
||||
async def _phase1_purchase(page, tab_index=0):
|
||||
"""商品详情页极速购买"""
|
||||
for attempt in range(PHASE1_RETRIES):
|
||||
try:
|
||||
# 刷新页面,让预售按钮变为可点击
|
||||
if attempt > 0:
|
||||
await asyncio.sleep(0.3)
|
||||
await page.reload(wait_until='domcontentloaded', timeout=10000)
|
||||
await asyncio.sleep(0.5)
|
||||
await page.reload(wait_until='commit', timeout=8000)
|
||||
# 等 DOM 关键元素出现,比纯 commit 多等一点
|
||||
await asyncio.sleep(0.5 if attempt == 0 else 0.3)
|
||||
|
||||
# 点击购买按钮(兼容多种文案)
|
||||
buy_btn = None
|
||||
for text in ["立即抢购", "立即购买", "马上抢", "立即秒杀"]:
|
||||
for text in BUY_TEXTS:
|
||||
loc = page.get_by_text(text, exact=False)
|
||||
if await loc.count() > 0:
|
||||
try:
|
||||
await loc.first.wait_for(state="visible", timeout=2000)
|
||||
buy_btn = loc.first
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not buy_btn:
|
||||
if attempt < max_retries - 1:
|
||||
if attempt < PHASE1_RETRIES - 1:
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
return "抢购操作失败: 未找到购买按钮"
|
||||
return f"P1-tab{tab_index}: 未找到购买按钮"
|
||||
|
||||
await buy_btn.click(timeout=3000)
|
||||
# force=True 绕过遮罩层/倒计时覆盖层的拦截
|
||||
await buy_btn.click(timeout=3000, force=True)
|
||||
|
||||
# 处理 SKU 选择(如果弹出规格选择框)
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
# 检查是否有 SKU 弹窗
|
||||
confirm_btn = page.get_by_text("确定", exact=True)
|
||||
if await confirm_btn.count() > 0 and await confirm_btn.first.is_visible():
|
||||
# 自动选择第一个可用的 SKU 选项
|
||||
sku_items = page.locator('.sku-item:not(.disabled), .sku_item:not(.disabled), [class*="sku"] [class*="item"]:not([class*="disabled"])')
|
||||
if await sku_items.count() > 0:
|
||||
await sku_items.first.click()
|
||||
await asyncio.sleep(0.3)
|
||||
await confirm_btn.first.click(timeout=3000)
|
||||
except Exception:
|
||||
pass
|
||||
# SKU 弹窗
|
||||
await _handle_sku(page)
|
||||
|
||||
# 等待进入订单确认页,点击"提交订单"
|
||||
# 提交订单
|
||||
submit_btn = page.get_by_text("提交订单")
|
||||
await submit_btn.wait_for(state="visible", timeout=8000)
|
||||
await submit_btn.click()
|
||||
return "抢购请求已提交"
|
||||
await submit_btn.click(force=True)
|
||||
return f"P1-tab{tab_index}: 抢购请求已提交"
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
if attempt < PHASE1_RETRIES - 1:
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
return f"抢购操作失败: {e}"
|
||||
return f"P1-tab{tab_index}: {e}"
|
||||
|
||||
return "抢购操作失败: 重试次数用尽"
|
||||
return f"P1-tab{tab_index}: 重试次数用尽"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 阶段2: 购物车入口下单
|
||||
# ─────────────────────────────────────────────
|
||||
async def _phase2_cart_purchase(page, cart_item_id):
|
||||
"""
|
||||
从购物车下单:
|
||||
1. 打开购物车页面
|
||||
2. 找到目标商品并勾选
|
||||
3. 点击结算
|
||||
4. 提交订单
|
||||
"""
|
||||
for attempt in range(PHASE2_RETRIES):
|
||||
try:
|
||||
# 打开购物车
|
||||
if attempt == 0:
|
||||
await page.goto(CART_URL, wait_until='networkidle',
|
||||
timeout=15000)
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
await page.reload(wait_until='domcontentloaded',
|
||||
timeout=10000)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 尝试勾选目标商品
|
||||
selected = False
|
||||
if cart_item_id:
|
||||
# 通过 cart_item_id 精确定位
|
||||
item_warp = page.locator(f'#\\3{cart_item_id[0]} {cart_item_id[1:]}' if len(cart_item_id) > 1 else f'#{cart_item_id}')
|
||||
# 更可靠的方式:用 data-v + id 属性
|
||||
item_warp = page.locator(f'.item_warp[id="{cart_item_id}"]')
|
||||
if await item_warp.count() > 0:
|
||||
# 点击商品前面的勾选框
|
||||
checkbox = item_warp.locator('.checkbox').first
|
||||
if await checkbox.count() > 0:
|
||||
await checkbox.click(force=True)
|
||||
selected = True
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
if not selected:
|
||||
# 没有精确定位到,尝试全选
|
||||
try:
|
||||
select_all = page.get_by_text("全选", exact=False)
|
||||
if await select_all.count() > 0:
|
||||
await select_all.first.click(force=True)
|
||||
selected = True
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not selected:
|
||||
# 还是没选中,点击第一个商品的 checkbox
|
||||
try:
|
||||
first_cb = page.locator(
|
||||
'.item_warp .checkbox').first
|
||||
if await first_cb.count() > 0:
|
||||
await first_cb.click(force=True)
|
||||
selected = True
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 点击结算按钮
|
||||
settle_btn = None
|
||||
for text in ["结算", "去结算", "立即结算"]:
|
||||
loc = page.get_by_text(text, exact=False)
|
||||
try:
|
||||
await loc.first.wait_for(state="visible", timeout=2000)
|
||||
settle_btn = loc.first
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not settle_btn:
|
||||
if attempt < PHASE2_RETRIES - 1:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
return "P2: 未找到结算按钮"
|
||||
|
||||
await settle_btn.click(timeout=3000, force=True)
|
||||
|
||||
# 等待跳转到订单确认页
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 提交订单
|
||||
submit_btn = page.get_by_text("提交订单")
|
||||
await submit_btn.wait_for(state="visible", timeout=8000)
|
||||
await submit_btn.click(force=True)
|
||||
return "P2-购物车: 抢购请求已提交"
|
||||
|
||||
except Exception as e:
|
||||
if attempt < PHASE2_RETRIES - 1:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
return f"P2-购物车: {e}"
|
||||
|
||||
return "P2-购物车: 重试次数用尽"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 公共工具
|
||||
# ─────────────────────────────────────────────
|
||||
async def _handle_sku(page):
|
||||
"""处理 SKU 选择弹窗"""
|
||||
try:
|
||||
confirm_btn = page.get_by_text("确定", exact=True)
|
||||
await confirm_btn.first.wait_for(state="visible", timeout=2000)
|
||||
sku_sel = ('.sku-item:not(.disabled), '
|
||||
'.sku_item:not(.disabled), '
|
||||
'[class*="sku"] [class*="item"]'
|
||||
':not([class*="disabled"])')
|
||||
sku_items = page.locator(sku_sel)
|
||||
if await sku_items.count() > 0:
|
||||
await sku_items.first.click(force=True)
|
||||
await asyncio.sleep(0.1)
|
||||
await confirm_btn.first.click(timeout=2000, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _is_success(result):
|
||||
return isinstance(result, str) and ('已提交' in result or '已发送' in result)
|
||||
|
||||
|
||||
def _pick_success(results):
|
||||
for r in results:
|
||||
if _is_success(r):
|
||||
return r
|
||||
for r in results:
|
||||
if isinstance(r, str):
|
||||
return r
|
||||
return f"全部失败: {results}"
|
||||
|
||||
|
||||
def _update_task(db, task_id, status, result):
|
||||
db.execute(
|
||||
"UPDATE tasks SET status = ?, result = ?, updated_at = ? WHERE id = ?",
|
||||
(status, result, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), task_id)
|
||||
)
|
||||
(status, result, datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
task_id))
|
||||
db.commit()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
<a class="nav-link" href="/tasks/"><i class="bi bi-list-task"></i> 任务</a>
|
||||
<a class="nav-link" href="/accounts/"><i class="bi bi-people"></i> 账号</a>
|
||||
<a class="nav-link" href="/orders/"><i class="bi bi-receipt"></i> 订单</a>
|
||||
<a class="nav-link text-warning-emphasis" href="/change_password" title="修改密码"><i class="bi bi-key"></i></a>
|
||||
<a class="nav-link text-warning-emphasis" href="/logout" title="退出"><i class="bi bi-box-arrow-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
33
templates/change_password.html
Normal file
33
templates/change_password.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="mb-3"><i class="bi bi-key"></i> 修改密码</h5>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2" style="border-radius:10px;font-size:.85rem">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if success %}
|
||||
<div class="alert alert-success py-2" style="border-radius:10px;font-size:.85rem">{{ success }}</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">原密码</label>
|
||||
<input type="password" class="form-control" name="old_password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">新密码(至少6位)</label>
|
||||
<input type="password" class="form-control" name="new_password" required minlength="6">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">确认新密码</label>
|
||||
<input type="password" class="form-control" name="new_password2" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">确认修改</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
44
templates/login.html
Normal file
44
templates/login.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - 微店抢购管理</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||
.card { width: 360px; border-radius: 18px; border: none; box-shadow: 0 8px 40px rgba(0,0,0,.2); }
|
||||
.card-header { text-align: center; padding: 2rem 2rem 1rem; background: none; border: none; }
|
||||
.card-header i { font-size: 2.5rem; color: #667eea; }
|
||||
.card-header h4 { margin-top: .8rem; font-weight: 600; color: #333; }
|
||||
.card-body { padding: 0 2rem 2rem; }
|
||||
.form-control { border-radius: 10px; padding: .6rem 1rem; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); border: none;
|
||||
border-radius: 10px; padding: .6rem; font-weight: 500; width: 100%; }
|
||||
.alert { border-radius: 10px; font-size: .85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-lightning-charge-fill"></i>
|
||||
<h4>微店抢购管理</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<input type="password" class="form-control" name="password"
|
||||
placeholder="请输入访问密码" autofocus required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
50
templates/setup.html
Normal file
50
templates/setup.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>初始设置 - 微店抢购管理</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||
.card { width: 380px; border-radius: 18px; border: none; box-shadow: 0 8px 40px rgba(0,0,0,.2); }
|
||||
.card-header { text-align: center; padding: 2rem 2rem 1rem; background: none; border: none; }
|
||||
.card-header i { font-size: 2.5rem; color: #667eea; }
|
||||
.card-header h4 { margin-top: .8rem; font-weight: 600; color: #333; }
|
||||
.card-header p { color: #888; font-size: .85rem; }
|
||||
.card-body { padding: 0 2rem 2rem; }
|
||||
.form-control { border-radius: 10px; padding: .6rem 1rem; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); border: none;
|
||||
border-radius: 10px; padding: .6rem; font-weight: 500; width: 100%; }
|
||||
.alert { border-radius: 10px; font-size: .85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-shield-lock-fill"></i>
|
||||
<h4>首次使用设置</h4>
|
||||
<p>请设置管理密码(至少6位)</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<input type="password" class="form-control" name="password"
|
||||
placeholder="设置密码" autofocus required minlength="6">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<input type="password" class="form-control" name="password2"
|
||||
placeholder="确认密码" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">确认设置</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,47 +3,67 @@ import asyncio
|
||||
import ntplib
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class PrecisionTimer:
|
||||
def __init__(self):
|
||||
self.offset = 0 # 服务器时间 - 本地时间
|
||||
self.offset = 0 # 服务器时间 - 本地时间
|
||||
|
||||
def sync_time(self):
|
||||
"""
|
||||
同步 NTP 时间,计算偏移量
|
||||
"""
|
||||
try:
|
||||
client = ntplib.NTPClient()
|
||||
response = client.request('pool.ntp.org', version=3)
|
||||
self.offset = response.tx_time - time.time()
|
||||
print(f"时间同步完成,偏移量: {self.offset:.3f}s")
|
||||
except Exception as e:
|
||||
print(f"NTP同步失败: {e},将使用系统时间")
|
||||
"""多次 NTP 同步取中位数,提高精度"""
|
||||
offsets = []
|
||||
servers = ['ntp.aliyun.com', 'ntp.tencent.com', 'pool.ntp.org']
|
||||
for server in servers:
|
||||
try:
|
||||
client = ntplib.NTPClient()
|
||||
resp = client.request(server, version=3)
|
||||
offsets.append(resp.tx_time - time.time())
|
||||
except Exception:
|
||||
continue
|
||||
if offsets:
|
||||
offsets.sort()
|
||||
self.offset = offsets[len(offsets) // 2]
|
||||
print(f"时间同步完成,偏移量: {self.offset:.3f}s (采样{len(offsets)}个)")
|
||||
else:
|
||||
print("NTP同步失败,将使用系统时间")
|
||||
|
||||
def get_server_time(self):
|
||||
return time.time() + self.offset
|
||||
|
||||
async def wait_until(self, target_time_str):
|
||||
"""
|
||||
等待直到目标时间 (格式: 2026-02-01 10:00:00)
|
||||
"""
|
||||
"""等待直到目标时间,最后阶段忙等保证精度"""
|
||||
target_dt = datetime.strptime(target_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
target_timestamp = target_dt.timestamp()
|
||||
|
||||
print(f"正在等待目标时间: {target_time_str}")
|
||||
|
||||
target_ts = target_dt.timestamp()
|
||||
|
||||
print(f"等待目标时间: {target_time_str}")
|
||||
|
||||
while True:
|
||||
current_time = self.get_server_time()
|
||||
remaining = target_timestamp - current_time
|
||||
|
||||
now = self.get_server_time()
|
||||
remaining = target_ts - now
|
||||
|
||||
if remaining <= 0:
|
||||
print("目标时间已到!触发抢购!")
|
||||
print("目标时间已到!")
|
||||
break
|
||||
|
||||
# 动态调整调整休眠时间以节省 CPU 并保持精度
|
||||
if remaining > 1:
|
||||
await asyncio.sleep(remaining - 0.5)
|
||||
elif remaining > 10:
|
||||
await asyncio.sleep(remaining - 10)
|
||||
elif remaining > 2:
|
||||
await asyncio.sleep(0.5)
|
||||
elif remaining > 0.1:
|
||||
await asyncio.sleep(0.01)
|
||||
# remaining <= 0.1: 忙等,不 sleep
|
||||
|
||||
async def wait_until_early(self, target_time_str, early_ms=500):
|
||||
"""提前 early_ms 毫秒触发,用于需要预操作的场景"""
|
||||
target_dt = datetime.strptime(target_time_str, "%Y-%m-%d %H:%M:%S")
|
||||
target_ts = target_dt.timestamp() - (early_ms / 1000.0)
|
||||
|
||||
while True:
|
||||
now = self.get_server_time()
|
||||
remaining = target_ts - now
|
||||
if remaining <= 0:
|
||||
break
|
||||
elif remaining > 10:
|
||||
await asyncio.sleep(remaining - 10)
|
||||
elif remaining > 2:
|
||||
await asyncio.sleep(0.5)
|
||||
elif remaining > 0.1:
|
||||
await asyncio.sleep(0.01)
|
||||
else:
|
||||
# 最后一刻进入忙等以获取最高精度
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user