feat: Web管理系统 + Docker支持
- 多账号管理(异步登录、状态轮询) - 购物车预售商品同步(倒计时/定时开售) - 定时抢购(自动刷新、SKU选择、重试机制) - 账号隔离调度(同账号顺序、跨账号并行) - Web面板(任务分组、实时倒计时、批量操作) - Dockerfile + docker-compose
This commit is contained in:
1
server/__init__.py
Normal file
1
server/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# server package
|
||||
35
server/app.py
Normal file
35
server/app.py
Normal file
@@ -0,0 +1,35 @@
|
||||
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 server.database import init_db
|
||||
from server.routers import accounts, tasks, orders
|
||||
|
||||
|
||||
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'),
|
||||
)
|
||||
app.secret_key = 'snatcher-secret-key-change-me'
|
||||
|
||||
init_db()
|
||||
|
||||
app.register_blueprint(accounts.bp)
|
||||
app.register_blueprint(tasks.bp)
|
||||
app.register_blueprint(orders.bp)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return redirect(url_for('tasks.list_tasks'))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(host='0.0.0.0', port=9000, debug=True)
|
||||
59
server/database.py
Normal file
59
server/database.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'snatcher.db')
|
||||
|
||||
|
||||
def get_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_db()
|
||||
conn.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
auth_file TEXT NOT NULL,
|
||||
is_logged_in INTEGER DEFAULT 0,
|
||||
login_msg TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
target_url TEXT NOT NULL,
|
||||
item_name TEXT DEFAULT '',
|
||||
item_id TEXT DEFAULT '',
|
||||
sku_id TEXT DEFAULT '',
|
||||
price TEXT DEFAULT '',
|
||||
snatch_time TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
result TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'unknown',
|
||||
detail TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id)
|
||||
);
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
1
server/routers/__init__.py
Normal file
1
server/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# routers package
|
||||
139
server/routers/accounts.py
Normal file
139
server/routers/accounts.py
Normal file
@@ -0,0 +1,139 @@
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
from flask import Blueprint, request, jsonify, render_template
|
||||
from server.database import get_db
|
||||
from server.services.auth_service import get_auth_path, has_auth, login_with_password
|
||||
|
||||
bp = Blueprint('accounts', __name__, url_prefix='/accounts')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def list_accounts():
|
||||
db = get_db()
|
||||
accounts = db.execute('SELECT * FROM accounts ORDER BY id DESC').fetchall()
|
||||
db.close()
|
||||
return render_template('accounts.html', accounts=accounts)
|
||||
|
||||
|
||||
@bp.route('/add', methods=['POST'])
|
||||
def add_account():
|
||||
name = request.form.get('name', '').strip()
|
||||
phone = request.form.get('phone', '').strip()
|
||||
password = request.form.get('password', '').strip()
|
||||
|
||||
if not name or not phone or not password:
|
||||
return jsonify(success=False, msg='请填写完整信息'), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.execute(
|
||||
'INSERT INTO accounts (name, phone, password, auth_file, login_msg) VALUES (?, ?, ?, ?, ?)',
|
||||
(name, phone, password, '', '登录中...')
|
||||
)
|
||||
account_id = cursor.lastrowid
|
||||
auth_file = get_auth_path(account_id)
|
||||
db.execute('UPDATE accounts SET auth_file = ? WHERE id = ?', (auth_file, account_id))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
# 后台异步登录
|
||||
_start_bg_login(account_id, phone, password)
|
||||
|
||||
return jsonify(success=True, id=account_id)
|
||||
|
||||
|
||||
@bp.route('/delete/<int:account_id>', methods=['POST'])
|
||||
def delete_account(account_id):
|
||||
db = get_db()
|
||||
db.execute('DELETE FROM tasks WHERE account_id = ?', (account_id,))
|
||||
db.execute('DELETE FROM accounts WHERE id = ?', (account_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
path = get_auth_path(account_id)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
return jsonify(success=True)
|
||||
|
||||
|
||||
@bp.route('/login/<int:account_id>', methods=['POST'])
|
||||
def do_login(account_id):
|
||||
"""用 Playwright 模拟浏览器自动登录微店"""
|
||||
db = get_db()
|
||||
account = db.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone()
|
||||
db.close()
|
||||
|
||||
if not account:
|
||||
return jsonify(success=False, msg='账号不存在'), 404
|
||||
|
||||
phone = account['phone']
|
||||
password = account['password']
|
||||
|
||||
if not phone or not password:
|
||||
return jsonify(success=False, msg='该账号未设置手机号或密码'), 400
|
||||
|
||||
# 标记为登录中
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE accounts SET is_logged_in = 0, login_msg = '登录中...', updated_at = datetime('now','localtime') WHERE id = ?",
|
||||
(account_id,)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
# 后台异步登录
|
||||
_start_bg_login(account_id, phone, password)
|
||||
|
||||
return jsonify(success=True, msg='登录中...')
|
||||
|
||||
|
||||
@bp.route('/status/<int:account_id>')
|
||||
def get_status(account_id):
|
||||
"""轮询账号登录状态"""
|
||||
db = get_db()
|
||||
account = db.execute(
|
||||
'SELECT is_logged_in, login_msg FROM accounts WHERE id = ?',
|
||||
(account_id,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
if not account:
|
||||
return jsonify(is_logged_in=False, login_msg='账号不存在', done=True)
|
||||
|
||||
msg = account['login_msg'] or ''
|
||||
is_logged_in = bool(account['is_logged_in'])
|
||||
# 登录中... 表示还在进行
|
||||
done = msg != '登录中...'
|
||||
return jsonify(is_logged_in=is_logged_in, login_msg=msg, done=done)
|
||||
|
||||
|
||||
def _start_bg_login(account_id, phone, password):
|
||||
"""在后台线程中执行登录"""
|
||||
def _run():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
ok, msg = loop.run_until_complete(
|
||||
login_with_password(account_id, phone, password)
|
||||
)
|
||||
except Exception as e:
|
||||
ok, msg = False, str(e)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
db = get_db()
|
||||
if ok:
|
||||
db.execute(
|
||||
"UPDATE accounts SET is_logged_in = 1, login_msg = '登录成功', "
|
||||
"updated_at = datetime('now','localtime') WHERE id = ?",
|
||||
(account_id,)
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"UPDATE accounts SET is_logged_in = 0, login_msg = ?, "
|
||||
"updated_at = datetime('now','localtime') WHERE id = ?",
|
||||
(msg, account_id)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
18
server/routers/orders.py
Normal file
18
server/routers/orders.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from flask import Blueprint, render_template
|
||||
from server.database import get_db
|
||||
|
||||
bp = Blueprint('orders', __name__, url_prefix='/orders')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def list_orders():
|
||||
db = get_db()
|
||||
orders = db.execute('''
|
||||
SELECT o.*, a.name as account_name, t.target_url, t.snatch_time
|
||||
FROM orders o
|
||||
LEFT JOIN accounts a ON o.account_id = a.id
|
||||
LEFT JOIN tasks t ON o.task_id = t.id
|
||||
ORDER BY o.id DESC
|
||||
''').fetchall()
|
||||
db.close()
|
||||
return render_template('orders.html', orders=orders)
|
||||
276
server/routers/tasks.py
Normal file
276
server/routers/tasks.py
Normal file
@@ -0,0 +1,276 @@
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, render_template
|
||||
from server.database import get_db
|
||||
from server.services.scheduler import start_task, stop_task, get_running_task_ids, start_account_tasks
|
||||
|
||||
bp = Blueprint('tasks', __name__, url_prefix='/tasks')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
def list_tasks():
|
||||
db = get_db()
|
||||
tasks = db.execute('''
|
||||
SELECT t.*, a.name as account_name
|
||||
FROM tasks t LEFT JOIN accounts a ON t.account_id = a.id
|
||||
ORDER BY t.account_id, t.snatch_time ASC
|
||||
''').fetchall()
|
||||
accounts = db.execute('SELECT id, name, is_logged_in FROM accounts').fetchall()
|
||||
running_ids = get_running_task_ids()
|
||||
db.close()
|
||||
|
||||
# 按账号分组
|
||||
grouped = {}
|
||||
for t in tasks:
|
||||
aid = t['account_id']
|
||||
if aid not in grouped:
|
||||
grouped[aid] = {'name': t['account_name'] or f'账号#{aid}', 'tasks': []}
|
||||
grouped[aid]['tasks'].append(t)
|
||||
|
||||
return render_template('tasks.html', grouped=grouped, accounts=accounts, running_ids=running_ids)
|
||||
|
||||
|
||||
@bp.route('/add', methods=['POST'])
|
||||
def add_task():
|
||||
data = request.form
|
||||
account_id = data.get('account_id')
|
||||
target_url = data.get('target_url', '').strip()
|
||||
snatch_time = data.get('snatch_time', '').strip()
|
||||
item_name = data.get('item_name', '').strip()
|
||||
item_id = data.get('item_id', '').strip()
|
||||
sku_id = data.get('sku_id', '').strip()
|
||||
price = data.get('price', '').strip()
|
||||
|
||||
if not account_id or not target_url or not snatch_time:
|
||||
return jsonify(success=False, msg='请填写必要字段'), 400
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
'INSERT INTO tasks (account_id, target_url, item_name, item_id, sku_id, price, snatch_time) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(account_id, target_url, item_name, item_id, sku_id, price, snatch_time)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify(success=True)
|
||||
|
||||
|
||||
@bp.route('/start/<int:task_id>', methods=['POST'])
|
||||
def start(task_id):
|
||||
ok, msg = start_task(task_id)
|
||||
return jsonify(success=ok, msg=msg)
|
||||
|
||||
|
||||
@bp.route('/stop/<int:task_id>', methods=['POST'])
|
||||
def stop(task_id):
|
||||
ok, msg = stop_task(task_id)
|
||||
return jsonify(success=ok, msg=msg)
|
||||
|
||||
|
||||
@bp.route('/delete/<int:task_id>', methods=['POST'])
|
||||
def delete_task(task_id):
|
||||
db = get_db()
|
||||
db.execute('DELETE FROM tasks WHERE id = ?', (task_id,))
|
||||
db.commit()
|
||||
db.close()
|
||||
return jsonify(success=True)
|
||||
|
||||
|
||||
@bp.route('/sync_cart/<int:account_id>', methods=['POST'])
|
||||
def sync_cart(account_id):
|
||||
"""同步购物车预售商品,自动创建抢购任务"""
|
||||
from server.services.cart_service import fetch_cart_presale_items
|
||||
|
||||
db = get_db()
|
||||
account = db.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone()
|
||||
db.close()
|
||||
|
||||
if not account:
|
||||
return jsonify(success=False, msg='账号不存在'), 404
|
||||
if not account['is_logged_in']:
|
||||
return jsonify(success=False, msg='请先登录该账号'), 400
|
||||
|
||||
result = {'success': False, 'msg': '同步超时', 'count': 0}
|
||||
|
||||
def _run():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
ok, data = loop.run_until_complete(fetch_cart_presale_items(account_id))
|
||||
if ok:
|
||||
items = data
|
||||
db2 = get_db()
|
||||
added = 0
|
||||
for item in items:
|
||||
sale_time = item.get('sale_time') or ''
|
||||
countdown = item.get('countdown_text') or ''
|
||||
|
||||
# 统一转为绝对时间 YYYY-MM-DD HH:MM:SS
|
||||
snatch_time = ''
|
||||
if sale_time:
|
||||
# "2026.03.19 20:00" -> "2026-03-19 20:00:00"
|
||||
try:
|
||||
st = sale_time.replace('.', '-').replace('/', '-')
|
||||
dt = datetime.strptime(st, '%Y-%m-%d %H:%M')
|
||||
snatch_time = dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
snatch_time = sale_time
|
||||
elif countdown:
|
||||
# "22:47:46" -> now + countdown
|
||||
try:
|
||||
parts = countdown.split(':')
|
||||
h, m, s = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
dt = datetime.now() + timedelta(hours=h, minutes=m, seconds=s)
|
||||
snatch_time = dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
if not snatch_time:
|
||||
continue
|
||||
cart_item_id = item.get('cart_item_id', '')
|
||||
item_id = item.get('item_id', '') or cart_item_id
|
||||
title = item.get('title', '')
|
||||
# 用 cart_item_id 去重(因为可能没有 item_id)
|
||||
dedup_key = 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"',
|
||||
(account_id, dedup_key, title)
|
||||
).fetchone()
|
||||
if existing:
|
||||
continue
|
||||
url = item.get('url', '')
|
||||
if not url and item_id and item_id.isdigit():
|
||||
url = f'https://weidian.com/item.html?itemID={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,
|
||||
item.get('sku_name', ''), item.get('price', ''), snatch_time)
|
||||
)
|
||||
added += 1
|
||||
db2.commit()
|
||||
db2.close()
|
||||
result['success'] = True
|
||||
result['msg'] = f'同步完成,新增 {added} 个任务' if added else '购物车中没有新的预售商品'
|
||||
result['count'] = added
|
||||
else:
|
||||
result['msg'] = data
|
||||
except Exception as e:
|
||||
result['msg'] = str(e)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=30)
|
||||
|
||||
return jsonify(**result)
|
||||
|
||||
|
||||
@bp.route('/sync_all', methods=['POST'])
|
||||
def sync_all_carts():
|
||||
"""同步所有已登录账号的购物车"""
|
||||
db = get_db()
|
||||
accounts = db.execute('SELECT id, name, phone, password FROM accounts WHERE is_logged_in = 1').fetchall()
|
||||
db.close()
|
||||
|
||||
if not accounts:
|
||||
return jsonify(success=False, msg='没有已登录的账号')
|
||||
|
||||
from server.services.cart_service import fetch_cart_presale_items
|
||||
|
||||
total_added = 0
|
||||
errors = []
|
||||
|
||||
def _run():
|
||||
nonlocal total_added
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
for acc in accounts:
|
||||
aid = acc['id']
|
||||
try:
|
||||
ok, data = loop.run_until_complete(fetch_cart_presale_items(aid))
|
||||
if not ok:
|
||||
errors.append(f"{acc['name']}: {data}")
|
||||
continue
|
||||
db2 = get_db()
|
||||
for item in data:
|
||||
sale_time = item.get('sale_time') or ''
|
||||
countdown = item.get('countdown_text') or ''
|
||||
snatch_time = ''
|
||||
if sale_time:
|
||||
try:
|
||||
st = sale_time.replace('.', '-').replace('/', '-')
|
||||
dt = datetime.strptime(st, '%Y-%m-%d %H:%M')
|
||||
snatch_time = dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
snatch_time = sale_time
|
||||
elif countdown:
|
||||
try:
|
||||
parts = countdown.split(':')
|
||||
h, m, s = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
dt = datetime.now() + timedelta(hours=h, minutes=m, seconds=s)
|
||||
snatch_time = dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if not snatch_time:
|
||||
continue
|
||||
cart_item_id = item.get('cart_item_id', '')
|
||||
item_id = item.get('item_id', '') or cart_item_id
|
||||
title = item.get('title', '')
|
||||
dedup_key = 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"',
|
||||
(aid, dedup_key, title)
|
||||
).fetchone()
|
||||
if existing:
|
||||
continue
|
||||
url = item.get('url', '')
|
||||
if not url and item_id and item_id.isdigit():
|
||||
url = f'https://weidian.com/item.html?itemID={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,
|
||||
item.get('sku_name', ''), item.get('price', ''), snatch_time)
|
||||
)
|
||||
total_added += 1
|
||||
db2.commit()
|
||||
db2.close()
|
||||
except Exception as e:
|
||||
errors.append(f"{acc['name']}: {e}")
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=60)
|
||||
|
||||
msg = f'同步完成,新增 {total_added} 个任务'
|
||||
if errors:
|
||||
msg += f'({len(errors)} 个账号出错)'
|
||||
return jsonify(success=True, msg=msg, count=total_added)
|
||||
|
||||
|
||||
@bp.route('/start_all', methods=['POST'])
|
||||
def start_all_pending():
|
||||
"""按账号隔离启动所有 pending 任务"""
|
||||
db = get_db()
|
||||
account_ids = db.execute(
|
||||
"SELECT DISTINCT account_id FROM tasks WHERE status = 'pending'"
|
||||
).fetchall()
|
||||
db.close()
|
||||
started = 0
|
||||
for row in account_ids:
|
||||
ok, _ = start_account_tasks(row['account_id'])
|
||||
if ok:
|
||||
started += 1
|
||||
return jsonify(success=True, msg=f'已启动 {started} 个账号的任务')
|
||||
|
||||
|
||||
@bp.route('/start_account/<int:account_id>', methods=['POST'])
|
||||
def start_account(account_id):
|
||||
"""启动指定账号的所有 pending 任务"""
|
||||
ok, msg = start_account_tasks(account_id)
|
||||
return jsonify(success=ok, msg=msg)
|
||||
1
server/services/__init__.py
Normal file
1
server/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services package
|
||||
182
server/services/auth_service.py
Normal file
182
server/services/auth_service.py
Normal file
@@ -0,0 +1,182 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from playwright.async_api import async_playwright
|
||||
from utils.stealth import stealth_async
|
||||
|
||||
AUTH_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'auth')
|
||||
LOGIN_URL = "https://sso.weidian.com/login/index.php"
|
||||
|
||||
|
||||
def get_auth_path(account_id):
|
||||
os.makedirs(AUTH_DIR, exist_ok=True)
|
||||
return os.path.join(AUTH_DIR, f'auth_state_{account_id}.json')
|
||||
|
||||
|
||||
def has_auth(account_id):
|
||||
path = get_auth_path(account_id)
|
||||
return os.path.exists(path) and os.path.getsize(path) > 10
|
||||
|
||||
|
||||
async def login_with_password(account_id, phone, password):
|
||||
"""
|
||||
用 Playwright 模拟浏览器登录微店,通过监听 API 响应提取 cookie。
|
||||
流程:
|
||||
1. 打开登录页
|
||||
2. 点击 #login_init_by_login 进入登录表单
|
||||
3. 点击"账号密码登录" tab
|
||||
4. 填写手机号、密码
|
||||
5. 点击 #login_pwd_submit
|
||||
6. 监听 /user/login 响应,从中提取 cookie 并保存
|
||||
"""
|
||||
login_result = {'success': False, 'msg': '登录超时', 'cookies': []}
|
||||
|
||||
p = await async_playwright().start()
|
||||
browser = await p.chromium.launch(
|
||||
headless=True, args=['--disable-gpu', '--no-sandbox']
|
||||
)
|
||||
device = p.devices['iPhone 13']
|
||||
context = await browser.new_context(**device)
|
||||
page = await context.new_page()
|
||||
await stealth_async(page)
|
||||
|
||||
# 监听登录接口响应
|
||||
async def on_response(response):
|
||||
if 'user/login' in response.url and response.status == 200:
|
||||
try:
|
||||
data = await response.json()
|
||||
status = data.get('status', {})
|
||||
if status.get('status_code') == 0:
|
||||
login_result['success'] = True
|
||||
login_result['msg'] = '登录成功'
|
||||
login_result['cookies'] = data.get('result', {}).get('cookie', [])
|
||||
else:
|
||||
login_result['msg'] = f"登录失败: {status.get('status_reason', '未知错误')}"
|
||||
except Exception as e:
|
||||
login_result['msg'] = f"解析登录响应失败: {e}"
|
||||
|
||||
page.on("response", on_response)
|
||||
|
||||
try:
|
||||
await page.goto(LOGIN_URL, wait_until='networkidle', timeout=15000)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 点击"登录"进入表单
|
||||
await page.locator('#login_init_by_login').click(timeout=5000)
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
# 点击"账号密码登录" tab
|
||||
try:
|
||||
await page.locator('h4.login_content_h4 span', has_text="账号密码登录").click(timeout=3000)
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 填写手机号(逐字输入,触发 JS 事件)
|
||||
phone_input = page.locator('input[placeholder*="手机号"]').first
|
||||
await phone_input.click()
|
||||
await phone_input.fill("")
|
||||
await page.keyboard.type(phone, delay=50)
|
||||
|
||||
# 填写密码
|
||||
pwd_input = page.locator('input[placeholder*="登录密码"], input[type="password"]').first
|
||||
await pwd_input.click()
|
||||
await pwd_input.fill("")
|
||||
await page.keyboard.type(password, delay=50)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 点击登录
|
||||
await page.locator('#login_pwd_submit').click(timeout=5000)
|
||||
|
||||
# 等待 API 响应
|
||||
await asyncio.sleep(5)
|
||||
|
||||
if login_result['success'] and login_result['cookies']:
|
||||
# 从 API 响应中提取 cookie,写入 context
|
||||
for c in login_result['cookies']:
|
||||
await context.add_cookies([{
|
||||
"name": c.get("name", ""),
|
||||
"value": c.get("value", ""),
|
||||
"domain": c.get("domain", ".weidian.com"),
|
||||
"path": c.get("path", "/"),
|
||||
"httpOnly": c.get("httpOnly", False),
|
||||
"secure": c.get("secure", False),
|
||||
"sameSite": "Lax",
|
||||
}])
|
||||
|
||||
# 保存完整的 storage_state
|
||||
auth_path = get_auth_path(account_id)
|
||||
await context.storage_state(path=auth_path)
|
||||
return True, "登录成功"
|
||||
|
||||
return False, login_result['msg']
|
||||
|
||||
except Exception as e:
|
||||
return False, f"登录过程出错: {e}"
|
||||
finally:
|
||||
await browser.close()
|
||||
await p.stop()
|
||||
|
||||
|
||||
async def login_with_api(account_id, phone, password):
|
||||
"""
|
||||
通过微店 SSO API 直接登录(备选方案,速度快但更容易触发风控)。
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
login_api = "https://sso.weidian.com/user/login"
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||||
"Referer": "https://sso.weidian.com/login/index.php",
|
||||
"Origin": "https://sso.weidian.com",
|
||||
}
|
||||
payload = {"phone": phone, "password": password, "loginMode": "password"}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(login_api, data=payload, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
status_code = data.get("status", {}).get("status_code", -1)
|
||||
status_reason = data.get("status", {}).get("status_reason", "未知错误")
|
||||
|
||||
if status_code == 0:
|
||||
api_cookies = data.get("result", {}).get("cookie", [])
|
||||
pw_cookies = []
|
||||
for c in api_cookies:
|
||||
pw_cookies.append({
|
||||
"name": c.get("name", ""),
|
||||
"value": c.get("value", ""),
|
||||
"domain": c.get("domain", ".weidian.com"),
|
||||
"path": c.get("path", "/"),
|
||||
"expires": -1,
|
||||
"httpOnly": c.get("httpOnly", False),
|
||||
"secure": c.get("secure", False),
|
||||
"sameSite": "Lax",
|
||||
})
|
||||
state = {"cookies": pw_cookies, "origins": []}
|
||||
auth_path = get_auth_path(account_id)
|
||||
with open(auth_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
return True, "API登录成功"
|
||||
else:
|
||||
return False, f"API登录失败: {status_reason}"
|
||||
except Exception as e:
|
||||
return False, f"API登录出错: {e}"
|
||||
|
||||
|
||||
async def get_browser_context(playwright_instance, account_id, headless=True):
|
||||
"""创建带有已保存登录状态的浏览器上下文"""
|
||||
browser = await playwright_instance.chromium.launch(
|
||||
headless=headless, args=['--disable-gpu', '--no-sandbox']
|
||||
)
|
||||
device = playwright_instance.devices['iPhone 13']
|
||||
auth_path = get_auth_path(account_id)
|
||||
|
||||
if has_auth(account_id):
|
||||
context = await browser.new_context(**device, storage_state=auth_path)
|
||||
else:
|
||||
context = await browser.new_context(**device)
|
||||
|
||||
return browser, context
|
||||
101
server/services/cart_service.py
Normal file
101
server/services/cart_service.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
购物车预售商品抓取服务
|
||||
通过 Playwright 打开购物车页面,从 DOM 的 item_warp 提取商品信息
|
||||
"""
|
||||
import asyncio
|
||||
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 保持一致
|
||||
EXTRACT_JS = """() => {
|
||||
const R = [];
|
||||
const sws = document.querySelectorAll(
|
||||
'div.shop_info.cart_content div.shop_warp'
|
||||
);
|
||||
for (const sw of sws) {
|
||||
const sn = (sw.querySelector('.shop_name') || {}).textContent || '';
|
||||
const iws = sw.querySelectorAll('.item_warp');
|
||||
for (const iw of iws) {
|
||||
const o = {
|
||||
shop_name: sn.trim(),
|
||||
cart_item_id: iw.id,
|
||||
title: '', sku_name: '', price: '',
|
||||
is_presale: false, countdown_text: '',
|
||||
sale_time: '', presale_type: ''
|
||||
};
|
||||
const te = iw.querySelector('.item_title');
|
||||
if (te) o.title = te.textContent.trim();
|
||||
const sk = iw.querySelector('.item_sku');
|
||||
if (sk) o.sku_name = sk.textContent.trim();
|
||||
const pr = iw.querySelector('.item_prices');
|
||||
if (pr) o.price = pr.textContent.replace(/[^\\d.]/g, '');
|
||||
const de = iw.querySelector('.item_desc');
|
||||
if (de) {
|
||||
const dt = de.querySelector('.title');
|
||||
const dd = de.querySelector('.desc');
|
||||
const wm = de.querySelector('.warn_msg');
|
||||
if (dt && /\\u5b9a\\u65f6\\s*\\u5f00\\u552e/.test(dt.textContent)) {
|
||||
o.is_presale = true;
|
||||
const d = dd ? dd.textContent.trim() : '';
|
||||
const w = wm ? wm.textContent.trim() : '';
|
||||
if (d.includes('\\u8ddd\\u79bb\\u5f00\\u552e\\u8fd8\\u5269')) {
|
||||
o.presale_type = 'countdown';
|
||||
o.countdown_text = w;
|
||||
} else if (d.includes('\\u5f00\\u552e\\u65f6\\u95f4')) {
|
||||
o.presale_type = 'scheduled';
|
||||
o.sale_time = w;
|
||||
} else {
|
||||
o.presale_type = 'unknown';
|
||||
o.countdown_text = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
R.push(o);
|
||||
}
|
||||
}
|
||||
return R;
|
||||
}"""
|
||||
|
||||
|
||||
async def fetch_cart_presale_items(account_id):
|
||||
"""
|
||||
获取指定账号购物车中的预售商品列表
|
||||
返回: (success, items_or_msg)
|
||||
"""
|
||||
if not has_auth(account_id):
|
||||
return False, "账号未登录"
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser, context = await get_browser_context(
|
||||
p, account_id, headless=True
|
||||
)
|
||||
page = await context.new_page()
|
||||
await stealth_async(page)
|
||||
|
||||
try:
|
||||
await page.goto(
|
||||
CART_URL, wait_until="networkidle", timeout=20000
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if "login" in page.url.lower():
|
||||
await browser.close()
|
||||
return False, "登录态已过期,请重新登录"
|
||||
|
||||
if "error" in page.url.lower():
|
||||
await browser.close()
|
||||
return False, "购物车页面加载失败"
|
||||
|
||||
except Exception as e:
|
||||
await browser.close()
|
||||
return False, f"打开购物车失败: {e}"
|
||||
|
||||
raw_items = await page.evaluate(EXTRACT_JS)
|
||||
await browser.close()
|
||||
|
||||
# 只返回预售商品
|
||||
presale = [it for it in raw_items if it.get("is_presale")]
|
||||
return True, presale
|
||||
110
server/services/scheduler.py
Normal file
110
server/services/scheduler.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
任务调度器 - 按账号隔离执行
|
||||
同一账号的任务在同一个线程/浏览器中顺序执行,不同账号并行。
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from server.database import get_db
|
||||
from server.services.snatcher import run_snatch
|
||||
|
||||
# {task_id: thread} 跟踪运行中的任务
|
||||
_running_tasks = {}
|
||||
# {account_id: thread} 跟踪每个账号的执行线程
|
||||
_account_threads = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def start_task(task_id):
|
||||
"""启动单个任务"""
|
||||
with _lock:
|
||||
if task_id in _running_tasks and _running_tasks[task_id].is_alive():
|
||||
return False, "任务已在运行中"
|
||||
|
||||
def _run():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(run_snatch(task_id))
|
||||
finally:
|
||||
loop.close()
|
||||
with _lock:
|
||||
_running_tasks.pop(task_id, None)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
with _lock:
|
||||
_running_tasks[task_id] = t
|
||||
return True, "任务已启动"
|
||||
|
||||
|
||||
def start_account_tasks(account_id):
|
||||
"""
|
||||
启动指定账号的所有 pending 任务。
|
||||
同一账号的任务在同一线程中顺序执行(共享浏览器上下文)。
|
||||
"""
|
||||
with _lock:
|
||||
if account_id in _account_threads and _account_threads[account_id].is_alive():
|
||||
return False, "该账号已有任务在执行中"
|
||||
|
||||
db = get_db()
|
||||
tasks = db.execute(
|
||||
"SELECT id FROM tasks WHERE account_id = ? AND status = 'pending' ORDER BY snatch_time ASC",
|
||||
(account_id,)
|
||||
).fetchall()
|
||||
db.close()
|
||||
|
||||
if not tasks:
|
||||
return False, "该账号没有待执行的任务"
|
||||
|
||||
task_ids = [row['id'] for row in tasks]
|
||||
|
||||
def _run():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
for tid in task_ids:
|
||||
# 检查任务是否被取消
|
||||
db2 = get_db()
|
||||
t = db2.execute('SELECT status FROM tasks WHERE id = ?', (tid,)).fetchone()
|
||||
db2.close()
|
||||
if not t or t['status'] == 'cancelled':
|
||||
continue
|
||||
with _lock:
|
||||
_running_tasks[tid] = threading.current_thread()
|
||||
try:
|
||||
loop.run_until_complete(run_snatch(tid))
|
||||
finally:
|
||||
with _lock:
|
||||
_running_tasks.pop(tid, None)
|
||||
finally:
|
||||
loop.close()
|
||||
with _lock:
|
||||
_account_threads.pop(account_id, None)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
with _lock:
|
||||
_account_threads[account_id] = t
|
||||
for tid in task_ids:
|
||||
_running_tasks[tid] = t
|
||||
return True, f"已启动 {len(task_ids)} 个任务"
|
||||
|
||||
|
||||
def stop_task(task_id):
|
||||
"""停止任务(标记状态)"""
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE tasks SET status = 'cancelled', updated_at = ? WHERE id = ?",
|
||||
(datetime.now().strftime('%Y-%m-%d %H:%M:%S'), task_id)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
with _lock:
|
||||
_running_tasks.pop(task_id, None)
|
||||
return True, "任务已取消"
|
||||
|
||||
|
||||
def get_running_task_ids():
|
||||
with _lock:
|
||||
return [tid for tid, t in _running_tasks.items() if t.is_alive()]
|
||||
145
server/services/snatcher.py
Normal file
145
server/services/snatcher.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import asyncio
|
||||
from playwright.async_api import async_playwright
|
||||
from utils.stealth import stealth_async
|
||||
from utils.timer import PrecisionTimer
|
||||
from server.services.auth_service import get_browser_context, has_auth
|
||||
from server.database import get_db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
async def run_snatch(task_id):
|
||||
"""执行单个抢购任务"""
|
||||
db = get_db()
|
||||
task = db.execute('SELECT * FROM tasks WHERE id = ?', (task_id,)).fetchone()
|
||||
if not task:
|
||||
return
|
||||
|
||||
account_id = task['account_id']
|
||||
if not has_auth(account_id):
|
||||
_update_task(db, task_id, 'failed', '账号未登录')
|
||||
return
|
||||
|
||||
_update_task(db, task_id, 'running', '正在准备...')
|
||||
|
||||
timer = PrecisionTimer()
|
||||
timer.sync_time()
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser, context = await get_browser_context(p, account_id, headless=True)
|
||||
page = await context.new_page()
|
||||
await stealth_async(page)
|
||||
|
||||
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. 等待抢购时间
|
||||
snatch_time = task['snatch_time']
|
||||
if snatch_time:
|
||||
_update_task(db, task_id, 'running', f'等待抢购时间: {snatch_time}')
|
||||
await timer.wait_until(snatch_time)
|
||||
|
||||
# 3. 抢购核心逻辑(与 main.py 一致)
|
||||
_update_task(db, task_id, 'running', '开始抢购...')
|
||||
result = await _do_purchase(page)
|
||||
|
||||
if '已提交' in result or '已发送' in 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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
await asyncio.sleep(3)
|
||||
await browser.close()
|
||||
|
||||
except Exception as e:
|
||||
_update_task(db, task_id, 'failed', str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _do_purchase(page):
|
||||
"""
|
||||
执行购买流程:
|
||||
1. 刷新页面(预售商品需要刷新才能出现购买按钮)
|
||||
2. 点击"立即购买"/"立即抢购"
|
||||
3. 处理 SKU 选择 -> 点击"确定"
|
||||
4. 进入订单确认页 -> 点击"提交订单"
|
||||
支持多次重试
|
||||
"""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# 刷新页面,让预售按钮变为可点击
|
||||
if attempt > 0:
|
||||
await asyncio.sleep(0.3)
|
||||
await page.reload(wait_until='domcontentloaded', timeout=10000)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 点击购买按钮(兼容多种文案)
|
||||
buy_btn = None
|
||||
for text in ["立即抢购", "立即购买", "马上抢", "立即秒杀"]:
|
||||
loc = page.get_by_text(text, exact=False)
|
||||
if await loc.count() > 0:
|
||||
buy_btn = loc.first
|
||||
break
|
||||
|
||||
if not buy_btn:
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
return "抢购操作失败: 未找到购买按钮"
|
||||
|
||||
await buy_btn.click(timeout=3000)
|
||||
|
||||
# 处理 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
|
||||
|
||||
# 等待进入订单确认页,点击"提交订单"
|
||||
submit_btn = page.get_by_text("提交订单")
|
||||
await submit_btn.wait_for(state="visible", timeout=8000)
|
||||
await submit_btn.click()
|
||||
return "抢购请求已提交"
|
||||
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
return f"抢购操作失败: {e}"
|
||||
|
||||
return "抢购操作失败: 重试次数用尽"
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
db.commit()
|
||||
Reference in New Issue
Block a user