feat: Web管理系统 + Docker支持

- 多账号管理(异步登录、状态轮询)
- 购物车预售商品同步(倒计时/定时开售)
- 定时抢购(自动刷新、SKU选择、重试机制)
- 账号隔离调度(同账号顺序、跨账号并行)
- Web面板(任务分组、实时倒计时、批量操作)
- Dockerfile + docker-compose
This commit is contained in:
2026-03-18 13:38:17 +08:00
parent 7aea2ca2a8
commit 822a4636c0
28 changed files with 1966 additions and 66 deletions

View File

@@ -0,0 +1 @@
# services package

View 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

View 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

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