feat: Web管理系统 + Docker支持
- 多账号管理(异步登录、状态轮询) - 购物车预售商品同步(倒计时/定时开售) - 定时抢购(自动刷新、SKU选择、重试机制) - 账号隔离调度(同账号顺序、跨账号并行) - Web面板(任务分组、实时倒计时、批量操作) - Dockerfile + docker-compose
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.vscode/
|
||||||
|
data/
|
||||||
|
auth_state.json
|
||||||
|
test_auth_state.json
|
||||||
|
debug_*
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 Playwright 系统依赖
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||||
|
libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \
|
||||||
|
libpango-1.0-0 libcairo2 libasound2 libxshmfence1 libx11-xcb1 \
|
||||||
|
fonts-noto-cjk && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||||
|
playwright install chromium
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
CMD ["python", "run.py"]
|
||||||
77
README.md
77
README.md
@@ -1,43 +1,58 @@
|
|||||||
# Weidian Snatch (微店抢购脚本)
|
# 微店抢购管理系统
|
||||||
|
|
||||||
这是一个基于 [Playwright](https://playwright.dev/) 的微店自动抢购工具,支持精确计时、自动登录、隐身模式等功能。
|
基于 Playwright + Flask 的微店自动抢购工具,提供 Web 管理界面,支持多账号、购物车同步、定时抢购。
|
||||||
|
|
||||||
## 功能特性
|
## 功能
|
||||||
|
|
||||||
- **自动登录**:支持保存和加载登录状态 (`auth_state.json`)。
|
- **多账号管理** — 添加账号后台自动登录,状态实时轮询
|
||||||
- **精确计时**:内置 `PrecisionTimer` 进行时间同步和倒计时等待。
|
- **购物车同步** — 自动识别预售商品(倒计时 / 定时开售),一键创建抢购任务
|
||||||
- **隐身模式**:使用 stealth 脚本隐藏自动化特征,降低防爬虫检测风险。
|
- **定时抢购** — 精确计时,到点自动刷新 → 点击购买 → 选 SKU → 提交订单,支持重试
|
||||||
- **自动抢购**:自动执行点击购买、确认规格(SKU)、提交订单的流程。
|
- **账号隔离** — 同账号任务顺序执行,不同账号并行,互不干扰
|
||||||
|
- **Web 面板** — 任务按账号分组,实时倒计时,一键启动 / 停止
|
||||||
|
|
||||||
## 文件结构
|
## 快速开始
|
||||||
|
|
||||||
- `main.py`: 主程序入口,包含抢购的核心逻辑。
|
### 本地运行
|
||||||
- `config.yaml`: 配置文件,设置商品链接、抢购时间、浏览器模式等。
|
|
||||||
- `resolve_url.py`: URL 解析工具(如果有)。
|
|
||||||
- `utils/`:
|
|
||||||
- `auth.py`: 处理用户认证和 Session 管理。
|
|
||||||
- `stealth.py`: 反爬虫隐身处理。
|
|
||||||
- `timer.py`: 时间同步与控制。
|
|
||||||
|
|
||||||
## 使用方法
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
playwright install chromium
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
1. **安装依赖**
|
访问 http://localhost:9000
|
||||||
请确保已安装 Python,并安装所需的库:
|
|
||||||
```bash
|
|
||||||
pip install playwright pyyaml
|
|
||||||
playwright install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **配置 config.yaml**
|
### Docker 运行
|
||||||
修改 `config.yaml` 文件,填入目标商品 URL (`target_url`) 和抢购时间 (`snatch_time`)。
|
|
||||||
|
|
||||||
3. **运行脚本**
|
```bash
|
||||||
```bash
|
docker compose up -d
|
||||||
python main.py
|
```
|
||||||
```
|
|
||||||
如果是首次运行且无登录状态,脚本会提示登录。请手动登录后,脚本会自动保存状态供下次使用。
|
数据持久化在 `./data` 目录(SQLite 数据库 + 登录态文件)。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── run.py # 启动入口
|
||||||
|
├── main.py # 独立抢购脚本(命令行模式)
|
||||||
|
├── config.yaml # 命令行模式配置
|
||||||
|
├── server/
|
||||||
|
│ ├── app.py # Flask 应用
|
||||||
|
│ ├── database.py # SQLite 数据库
|
||||||
|
│ ├── routers/ # 路由(accounts / tasks / orders)
|
||||||
|
│ └── services/
|
||||||
|
│ ├── snatcher.py # 抢购核心逻辑
|
||||||
|
│ ├── cart_service.py # 购物车预售商品抓取
|
||||||
|
│ ├── scheduler.py # 任务调度(按账号隔离)
|
||||||
|
│ └── auth_service.py # 登录态管理
|
||||||
|
├── templates/ # Jinja2 页面模板
|
||||||
|
├── utils/ # 工具(stealth / timer / auth)
|
||||||
|
├── Dockerfile
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 请确保网络畅通,以保证时间同步和抢购请求的及时发送。
|
- 抢购成功率受网络延迟、库存、平台风控等因素影响,本工具仅辅助操作
|
||||||
- 抢购成功率受多种因素影响(网络延迟、库存数量、平台风控等),本脚本仅辅助操作,不保证 100% 成功。
|
- SKU 选择器的 CSS 类名基于当前微店 H5 页面结构,如页面改版可能需要调整
|
||||||
|
- Docker 容器内以 headless 模式运行 Chromium
|
||||||
|
|||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
snatcher:
|
||||||
|
build: .
|
||||||
|
container_name: weidian-snatcher
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
86
main.py
86
main.py
@@ -25,19 +25,15 @@ async def snatch(config):
|
|||||||
print(f"正在打开商品页面: {target_url}")
|
print(f"正在打开商品页面: {target_url}")
|
||||||
await page.goto(target_url)
|
await page.goto(target_url)
|
||||||
|
|
||||||
# 如果未登录,可能需要在这里处理扫码
|
# 如果未登录,处理登录
|
||||||
if not auth.has_auth():
|
if not auth.has_auth():
|
||||||
print("未发现登录状态,请手动操作并将状态保存...")
|
print("未发现登录状态,请手动操作并将状态保存...")
|
||||||
# 注意:login 会启动一个新的浏览器窗口
|
|
||||||
await auth.login(target_url)
|
await auth.login(target_url)
|
||||||
|
|
||||||
# 登录完成后,我们需要关闭当前空的 context 并重新加载带有 cookie 的 context
|
|
||||||
await context.close()
|
await context.close()
|
||||||
await browser.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()
|
page = await context.new_page()
|
||||||
await stealth_async(page)
|
await stealth_async(page)
|
||||||
|
|
||||||
print("已重新加载登录状态,正在打开商品页面...")
|
print("已重新加载登录状态,正在打开商品页面...")
|
||||||
await page.goto(target_url)
|
await page.goto(target_url)
|
||||||
|
|
||||||
@@ -46,42 +42,68 @@ async def snatch(config):
|
|||||||
if snatch_time:
|
if snatch_time:
|
||||||
await timer.wait_until(snatch_time)
|
await timer.wait_until(snatch_time)
|
||||||
|
|
||||||
# 3. 抢购核心逻辑 (H5 流程)
|
# 3. 抢购核心逻辑
|
||||||
# 注意:这里的选择器需要根据微店 H5 实际页面结构进行微调
|
max_retries = 3
|
||||||
# 这里演示一般的微店抢购流程:点击购买 -> 选择规格 -> 确定 -> 提交订单
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
# 刷新页面以获取最新状态(可选,视具体页面倒计时逻辑而定)
|
# 刷新页面,让预售按钮变为可点击
|
||||||
# await page.reload()
|
if attempt > 0:
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await page.reload(wait_until='domcontentloaded', timeout=10000)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
# 点击“立即购买”按钮
|
# 点击购买按钮(兼容多种文案)
|
||||||
# 微店 H5 常见的购买按钮类名类似于 .buy-btn, .footer-buy
|
buy_btn = None
|
||||||
# 我们尝试使用 text 匹配
|
for text in ["立即抢购", "立即购买", "马上抢", "立即秒杀"]:
|
||||||
buy_button = page.get_by_text("立即购买")
|
loc = page.get_by_text(text, exact=False)
|
||||||
await buy_button.click()
|
if await loc.count() > 0:
|
||||||
print("点击立即购买")
|
buy_btn = loc.first
|
||||||
|
break
|
||||||
|
|
||||||
# 处理 SKU 选择(如果弹出 SKU 选择框)
|
if not buy_btn:
|
||||||
# 这里简单起见,如果弹出了规格选择,点击第一个选项并确定
|
if attempt < max_retries - 1:
|
||||||
# 实际需要根据 config 中的 sku_id 进行精准点击
|
print(f"第{attempt+1}次未找到购买按钮,重试...")
|
||||||
confirm_btn = page.get_by_text("确定")
|
continue
|
||||||
if await confirm_btn.is_visible():
|
print("错误: 未找到购买按钮")
|
||||||
await confirm_btn.click()
|
break
|
||||||
print("点击确定(SKU)")
|
|
||||||
|
|
||||||
# 进入确认订单页面后,点击“提交订单”
|
await buy_btn.click(timeout=3000)
|
||||||
# 提交订单按钮通常在底部,文字为“提交订单”
|
print("点击购买按钮")
|
||||||
submit_btn = page.get_by_text("提交订单")
|
|
||||||
await submit_btn.wait_for(state="visible", timeout=5000)
|
|
||||||
await submit_btn.click()
|
|
||||||
print("点击提交订单!抢购请求已发送!")
|
|
||||||
|
|
||||||
except Exception as e:
|
# 处理 SKU 选择(如果弹出规格选择框)
|
||||||
print(f"抢购过程中发生错误: {e}")
|
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("点击提交订单!抢购请求已发送!")
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
print(f"第{attempt+1}次尝试失败: {e},重试...")
|
||||||
|
else:
|
||||||
|
print(f"抢购失败: {e}")
|
||||||
|
|
||||||
# 保持浏览器打开一段时间查看结果
|
# 保持浏览器打开一段时间查看结果
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
await browser.close()
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
with open("config.yaml", "r", encoding="utf-8") as f:
|
with open("config.yaml", "r", encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
flask>=3.0
|
||||||
|
playwright>=1.40
|
||||||
|
playwright-stealth>=1.0
|
||||||
|
pyyaml>=6.0
|
||||||
|
ntplib>=0.4
|
||||||
13
run.py
Normal file
13
run.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""启动入口:python run.py"""
|
||||||
|
from server.app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
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)
|
||||||
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()
|
||||||
143
templates/accounts.html
Normal file
143
templates/accounts.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">账号管理</h4>
|
||||||
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addModal">
|
||||||
|
<i class="bi bi-plus-lg"></i> 添加账号
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead><tr>
|
||||||
|
<th>ID</th><th>名称</th><th>手机号</th><th>登录状态</th><th>更新时间</th><th>操作</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in accounts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.id }}</td>
|
||||||
|
<td>{{ a.name }}</td>
|
||||||
|
<td>{{ a.phone[:3] }}****{{ a.phone[-4:] if a.phone|length > 4 else '' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.is_logged_in %}
|
||||||
|
<span class="badge bg-success">已登录</span>
|
||||||
|
{% elif a.login_msg == '登录中...' %}
|
||||||
|
<span class="badge bg-warning">
|
||||||
|
<span class="spinner-border spinner-border-sm" style="width:.7rem;height:.7rem"></span> 登录中...
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary" title="{{ a.login_msg or '' }}">未登录</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ a.updated_at }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" onclick="doLogin({{ a.id }}, this)">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> 登录
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteAccount({{ a.id }})">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not accounts %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-4">暂无账号,点击右上角添加</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</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>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="addForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">备注名称</label>
|
||||||
|
<input type="text" class="form-control" name="name" required placeholder="如:主号、小号1">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">手机号</label>
|
||||||
|
<input type="tel" class="form-control" name="phone" required placeholder="微店登录手机号">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">密码</label>
|
||||||
|
<input type="password" class="form-control" name="password" required placeholder="微店登录密码">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||||
|
<button class="btn btn-primary" onclick="addAccount()">添加</button>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function addAccount() {
|
||||||
|
var form = document.getElementById('addForm');
|
||||||
|
var data = new FormData(form);
|
||||||
|
var btn = event.target;
|
||||||
|
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 = '添加'; });
|
||||||
|
}
|
||||||
|
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.textContent = '重试'; });
|
||||||
|
}
|
||||||
|
function pollStatus(id, btn) {
|
||||||
|
var interval = setInterval(function() {
|
||||||
|
fetch('/accounts/status/' + id)
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.done) {
|
||||||
|
clearInterval(interval);
|
||||||
|
var row = btn ? btn.closest('tr') : null;
|
||||||
|
if (row) {
|
||||||
|
var badge = row.querySelector('.badge');
|
||||||
|
if (d.is_logged_in) {
|
||||||
|
badge.className = 'badge bg-success';
|
||||||
|
badge.textContent = '已登录';
|
||||||
|
} else {
|
||||||
|
badge.className = 'badge bg-danger';
|
||||||
|
badge.textContent = '失败';
|
||||||
|
badge.title = d.login_msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-box-arrow-in-right"></i> 登录';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
function deleteAccount(id) {
|
||||||
|
if (!confirm('确定删除此账号?相关任务也会被删除。')) return;
|
||||||
|
fetch('/accounts/delete/' + id, { method: 'POST' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) { if (d.success) location.reload(); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
47
templates/base.html
Normal file
47
templates/base.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!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: #f0f2f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||||
|
.navbar { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; }
|
||||||
|
.navbar-brand { font-weight: 600; }
|
||||||
|
.nav-link { font-size: .9rem; opacity: .85; transition: opacity .2s; }
|
||||||
|
.nav-link:hover { opacity: 1; }
|
||||||
|
.card { border: none; box-shadow: 0 1px 8px rgba(0,0,0,.06); border-radius: 14px; }
|
||||||
|
.btn { border-radius: 8px; }
|
||||||
|
.btn-sm { font-size: .8rem; }
|
||||||
|
.rounded-pill { border-radius: 50rem; }
|
||||||
|
.form-control, .form-select { border-radius: 10px; border-color: #dee2e6; }
|
||||||
|
.form-control:focus, .form-select:focus { border-color: #667eea; box-shadow: 0 0 0 .2rem rgba(102,126,234,.15); }
|
||||||
|
.modal-content { border: none; }
|
||||||
|
.btn-primary { background: #667eea; border-color: #667eea; }
|
||||||
|
.btn-primary:hover { background: #5a6fd6; border-color: #5a6fd6; }
|
||||||
|
.btn-success { background: #43a047; border-color: #43a047; }
|
||||||
|
.btn-success:hover { background: #388e3c; border-color: #388e3c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">
|
||||||
|
<i class="bi bi-lightning-charge-fill"></i> 微店抢购管理
|
||||||
|
</a>
|
||||||
|
<div class="navbar-nav ms-auto">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
templates/orders.html
Normal file
35
templates/orders.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="mb-3">订单记录</h4>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead><tr>
|
||||||
|
<th>ID</th><th>账号</th><th>商品链接</th><th>抢购时间</th>
|
||||||
|
<th>状态</th><th>详情</th><th>记录时间</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for o in orders %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ o.id }}</td>
|
||||||
|
<td>{{ o.account_name or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if o.target_url %}
|
||||||
|
<a href="{{ o.target_url }}" target="_blank">{{ o.target_url[:40] }}...</a>
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ o.snatch_time or '-' }}</td>
|
||||||
|
<td><span class="badge bg-info">{{ o.status }}</span></td>
|
||||||
|
<td>{{ o.detail or '-' }}</td>
|
||||||
|
<td>{{ o.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not orders %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">暂无订单记录</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
272
templates/tasks.html
Normal file
272
templates/tasks.html
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0">抢购任务</h4>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-success btn-sm me-1 rounded-pill" data-bs-toggle="modal" data-bs-target="#syncCartModal">
|
||||||
|
<i class="bi bi-cart-check"></i> 同步购物车
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-warning btn-sm me-1 rounded-pill" onclick="startAll()">
|
||||||
|
<i class="bi bi-play-circle"></i> 全部启动
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary btn-sm rounded-pill" data-bs-toggle="modal" data-bs-target="#addTaskModal">
|
||||||
|
<i class="bi bi-plus-lg"></i> 手动新建
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not grouped %}
|
||||||
|
<div class="card p-5 text-center text-muted">
|
||||||
|
<i class="bi bi-inbox" style="font-size:2.5rem"></i>
|
||||||
|
<p class="mt-2 mb-0">暂无任务,点击"同步购物车"自动添加预售商品</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% for account_id, group in grouped.items() %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center"
|
||||||
|
style="background:linear-gradient(135deg,#f8f9ff,#eef1fb);border-radius:14px 14px 0 0;border:none">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-person-circle text-primary"></i>
|
||||||
|
<span class="fw-bold">{{ group.name }}</span>
|
||||||
|
<span class="badge bg-light text-muted ms-1">{{ group.tasks|length }} 个任务</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-success btn-sm rounded-pill"
|
||||||
|
onclick="startAccount({{ account_id }})">
|
||||||
|
<i class="bi bi-play-fill"></i> 启动全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
{% for t in group.tasks %}
|
||||||
|
<div class="task-row d-flex align-items-start gap-3 p-2 {% if not loop.last %}border-bottom{% endif %}">
|
||||||
|
<div class="countdown-box text-center flex-shrink-0"
|
||||||
|
data-snatch-time="{{ t.snatch_time }}" data-status="{{ t.status }}">
|
||||||
|
<div class="cd-time font-monospace">--:--:--</div>
|
||||||
|
<div class="cd-label text-muted" style="font-size:.7rem">距开售</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 min-w-0">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span class="status-badge status-{{ t.status }}">{{ t.status }}</span>
|
||||||
|
{% if t.price %}
|
||||||
|
<span class="text-danger fw-bold" style="font-size:.85rem">¥{{ t.price }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="item-name mb-1">
|
||||||
|
{% if t.item_name %}
|
||||||
|
<a href="{{ t.target_url }}" target="_blank" class="text-decoration-none text-dark"
|
||||||
|
title="{{ t.target_url }}">{{ t.item_name[:50] }}{% if t.item_name|length > 50 %}...{% endif %}</a>
|
||||||
|
{% elif t.target_url %}
|
||||||
|
<a href="{{ t.target_url }}" target="_blank" class="text-decoration-none text-muted"
|
||||||
|
style="font-size:.85rem">{{ t.target_url[:60] }}...</a>
|
||||||
|
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:.78rem" class="text-muted">
|
||||||
|
<i class="bi bi-clock"></i> {{ t.snatch_time }}
|
||||||
|
{% if t.result %}
|
||||||
|
<span class="ms-2 text-truncate d-inline-block" style="max-width:250px;vertical-align:bottom"
|
||||||
|
title="{{ t.result }}">· {{ t.result[:50] }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 d-flex gap-1">
|
||||||
|
{% if t.status == 'pending' %}
|
||||||
|
<button class="btn btn-success btn-sm rounded-pill" onclick="startTask({{ t.id }})"><i class="bi bi-play-fill"></i></button>
|
||||||
|
{% elif t.id in running_ids %}
|
||||||
|
<button class="btn btn-warning btn-sm rounded-pill" onclick="stopTask({{ t.id }})"><i class="bi bi-stop-fill"></i></button>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-outline-danger btn-sm rounded-pill" onclick="deleteTask({{ t.id }})"><i class="bi bi-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 同步购物车弹窗 -->
|
||||||
|
<div class="modal fade" id="syncCartModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog"><div class="modal-content rounded-4">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title">同步购物车预售商品</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted small">获取购物车中的预售商品并自动创建抢购任务</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">选择账号</label>
|
||||||
|
<select class="form-select rounded-3" id="syncAccountId">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{% for a in accounts %}
|
||||||
|
{% if a.is_logged_in %}
|
||||||
|
<option value="{{ a.id }}">{{ a.name }}</option>
|
||||||
|
{% else %}
|
||||||
|
<option value="{{ a.id }}" disabled>{{ a.name }}(未登录)</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="syncResult" class="d-none">
|
||||||
|
<div class="alert rounded-3" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" data-bs-dismiss="modal">取消</button>
|
||||||
|
<button class="btn btn-success btn-sm rounded-pill" id="syncBtn" onclick="syncCart()">
|
||||||
|
<i class="bi bi-cart-check"></i> 同步选中
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm rounded-pill" id="syncAllBtn" onclick="syncAllCarts()">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> 同步全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<!-- 手动新建任务弹窗 -->
|
||||||
|
<div class="modal fade" id="addTaskModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog"><div class="modal-content rounded-4">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title">手动新建抢购任务</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="taskForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">选择账号</label>
|
||||||
|
<select class="form-select rounded-3" name="account_id" required>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{% for a in accounts %}
|
||||||
|
<option value="{{ a.id }}">{{ a.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">商品名称(可选)</label>
|
||||||
|
<input type="text" class="form-control rounded-3" name="item_name" placeholder="备注商品名">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">商品链接</label>
|
||||||
|
<input type="url" class="form-control rounded-3" name="target_url" required placeholder="https://weidian.com/...">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">抢购时间</label>
|
||||||
|
<input type="text" class="form-control rounded-3" name="snatch_time" required placeholder="2026-03-20 10:00:00">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<label class="form-label">商品ID(可选)</label>
|
||||||
|
<input type="text" class="form-control rounded-3" name="item_id">
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<label class="form-label">SKU ID(可选)</label>
|
||||||
|
<input type="text" class="form-control rounded-3" name="sku_id">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">价格(可选)</label>
|
||||||
|
<input type="text" class="form-control rounded-3" name="price">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0 pt-0">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" data-bs-dismiss="modal">取消</button>
|
||||||
|
<button class="btn btn-primary btn-sm rounded-pill" onclick="addTask()">创建</button>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<style>
|
||||||
|
.task-row { transition: background .15s; }
|
||||||
|
.task-row:hover { background: #f8f9ff; }
|
||||||
|
.countdown-box {
|
||||||
|
width: 84px; padding: 8px 4px;
|
||||||
|
background: linear-gradient(135deg, #f0f4ff, #e8ecf8);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.countdown-box.cd-active { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
||||||
|
.countdown-box.cd-passed { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||||
|
.countdown-box.cd-done { background: #f5f5f5; }
|
||||||
|
.cd-time { font-size: .95rem; font-weight: 700; color: #333; line-height: 1.2; }
|
||||||
|
.cd-active .cd-time { color: #e65100; }
|
||||||
|
.cd-passed .cd-time { color: #2e7d32; font-size: .82rem; }
|
||||||
|
.cd-done .cd-time { color: #999; font-size: .82rem; }
|
||||||
|
.cd-label { line-height: 1; }
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block; padding: 2px 10px; border-radius: 20px;
|
||||||
|
font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .3px;
|
||||||
|
}
|
||||||
|
.status-pending { background: #e3e8f0; color: #5a6a85; }
|
||||||
|
.status-running { background: #fff3cd; color: #856404; }
|
||||||
|
.status-completed { background: #d4edda; color: #155724; }
|
||||||
|
.status-failed { background: #f8d7da; color: #721c24; }
|
||||||
|
.status-cancelled { background: #e2e3e5; color: #383d41; }
|
||||||
|
.item-name { font-size: .92rem; font-weight: 500; }
|
||||||
|
.card-header { font-size: .9rem; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
function updateCountdowns() {
|
||||||
|
var now = new Date();
|
||||||
|
document.querySelectorAll('.countdown-box').forEach(function(box) {
|
||||||
|
var ts = box.dataset.snatchTime, st = box.dataset.status;
|
||||||
|
var ct = box.querySelector('.cd-time'), cl = box.querySelector('.cd-label');
|
||||||
|
if (['completed','cancelled'].includes(st)) {
|
||||||
|
box.className = 'countdown-box text-center flex-shrink-0 cd-done';
|
||||||
|
ct.textContent = st === 'completed' ? '已完成' : '已取消'; cl.textContent = ''; return;
|
||||||
|
}
|
||||||
|
if (st === 'failed') {
|
||||||
|
box.className = 'countdown-box text-center flex-shrink-0 cd-done';
|
||||||
|
ct.textContent = '失败'; cl.textContent = ''; return;
|
||||||
|
}
|
||||||
|
var target = new Date(ts.replace(/-/g, '/'));
|
||||||
|
if (isNaN(target.getTime())) { ct.textContent = ts || '--'; cl.textContent = ''; return; }
|
||||||
|
var diff = target - now;
|
||||||
|
if (diff <= 0) {
|
||||||
|
box.className = 'countdown-box text-center flex-shrink-0 cd-passed';
|
||||||
|
ct.textContent = '已开售'; cl.textContent = st === 'running' ? '抢购中' : ''; return;
|
||||||
|
}
|
||||||
|
box.className = 'countdown-box text-center flex-shrink-0 cd-active';
|
||||||
|
var h = Math.floor(diff/3600000), m = Math.floor((diff%3600000)/60000), s = Math.floor((diff%60000)/1000);
|
||||||
|
if (h > 24) { var d = Math.floor(h/24); ct.textContent = d + '天' + (h%24) + '时'; }
|
||||||
|
else { ct.textContent = pad(h)+':'+pad(m)+':'+pad(s); }
|
||||||
|
cl.textContent = '距开售';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function pad(n) { return n < 10 ? '0'+n : ''+n; }
|
||||||
|
updateCountdowns(); setInterval(updateCountdowns, 1000);
|
||||||
|
|
||||||
|
function syncCart() {
|
||||||
|
var id = document.getElementById('syncAccountId').value;
|
||||||
|
if (!id) { alert('请选择账号'); return; }
|
||||||
|
var btn = document.getElementById('syncBtn');
|
||||||
|
btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 同步中...';
|
||||||
|
var rd = document.getElementById('syncResult'); rd.classList.add('d-none');
|
||||||
|
fetch('/tasks/sync_cart/' + id, { method: 'POST' }).then(r=>r.json()).then(d => {
|
||||||
|
rd.classList.remove('d-none');
|
||||||
|
var a = rd.querySelector('.alert');
|
||||||
|
a.className = 'alert rounded-3 ' + (d.success ? 'alert-success' : 'alert-danger');
|
||||||
|
a.textContent = d.msg; btn.disabled = false; btn.innerHTML = '<i class="bi bi-cart-check"></i> 同步选中';
|
||||||
|
if (d.success && d.count > 0) setTimeout(() => location.reload(), 1500);
|
||||||
|
}).catch(() => { btn.disabled = false; btn.innerHTML = '<i class="bi bi-cart-check"></i> 同步选中'; });
|
||||||
|
}
|
||||||
|
function syncAllCarts() {
|
||||||
|
var btn = document.getElementById('syncAllBtn');
|
||||||
|
btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 同步中...';
|
||||||
|
var rd = document.getElementById('syncResult'); rd.classList.add('d-none');
|
||||||
|
fetch('/tasks/sync_all', { method: 'POST' }).then(r=>r.json()).then(d => {
|
||||||
|
rd.classList.remove('d-none');
|
||||||
|
var a = rd.querySelector('.alert');
|
||||||
|
a.className = 'alert rounded-3 ' + (d.success ? 'alert-success' : 'alert-danger');
|
||||||
|
a.textContent = d.msg; btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> 同步全部';
|
||||||
|
if (d.success && d.count > 0) setTimeout(() => location.reload(), 1500);
|
||||||
|
}).catch(() => { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> 同步全部'; });
|
||||||
|
}
|
||||||
|
function addTask() {
|
||||||
|
var data = new FormData(document.getElementById('taskForm'));
|
||||||
|
fetch('/tasks/add', { method: 'POST', body: data }).then(r=>r.json()).then(d => { if (d.success) location.reload(); else alert(d.msg); });
|
||||||
|
}
|
||||||
|
function startTask(id) { fetch('/tasks/start/'+id, { method:'POST' }).then(()=>location.reload()); }
|
||||||
|
function stopTask(id) { fetch('/tasks/stop/'+id, { method:'POST' }).then(()=>location.reload()); }
|
||||||
|
function deleteTask(id) { if (!confirm('确定删除?')) return; fetch('/tasks/delete/'+id, { method:'POST' }).then(r=>r.json()).then(d => { if (d.success) location.reload(); }); }
|
||||||
|
function startAll() { if (!confirm('启动所有账号的待执行任务?')) return; fetch('/tasks/start_all', { method:'POST' }).then(r=>r.json()).then(d => { alert(d.msg); location.reload(); }); }
|
||||||
|
function startAccount(aid) { fetch('/tasks/start_account/'+aid, { method:'POST' }).then(r=>r.json()).then(d => { alert(d.msg); location.reload(); }); }
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
83
test_cart.py
Normal file
83
test_cart.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""test cart v4 - by item_warp"""
|
||||||
|
import asyncio, json
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from utils.stealth import stealth_async
|
||||||
|
|
||||||
|
CART_URL = "https://weidian.com/new-cart/index.php"
|
||||||
|
|
||||||
|
async def test_cart():
|
||||||
|
p = await async_playwright().start()
|
||||||
|
browser = await p.chromium.launch(headless=False, args=["--disable-gpu"])
|
||||||
|
device = p.devices["iPhone 13"]
|
||||||
|
context = await browser.new_context(**device, storage_state="test_auth_state.json")
|
||||||
|
page = await context.new_page()
|
||||||
|
await stealth_async(page)
|
||||||
|
|
||||||
|
print("[1] open cart...")
|
||||||
|
await page.goto(CART_URL, wait_until="networkidle", timeout=30000)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
print(f" URL: {page.url}")
|
||||||
|
|
||||||
|
if "login" in page.url.lower() or "error" in page.url.lower():
|
||||||
|
print("[ERR] bad page"); await browser.close(); await p.stop(); return
|
||||||
|
|
||||||
|
print("\n[2] extract by item_warp...")
|
||||||
|
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: sn.trim(), cart_id: iw.id, title: '', sku: '', price: '', presale: false, cd: '', st: '', type: ''};
|
||||||
|
const te = iw.querySelector('.item_title'); if (te) o.title = te.textContent.trim();
|
||||||
|
const sk = iw.querySelector('.item_sku'); if (sk) o.sku = sk.textContent.trim();
|
||||||
|
const pr = iw.querySelector('.item_prices'); if (pr) o.price = pr.textContent.replace(/[^\\d.]/g, '');
|
||||||
|
const inp = iw.querySelector('.item_input input'); o.count = inp ? inp.value : '1';
|
||||||
|
const img = iw.querySelector('.item_img img'); o.img = img ? (img.src || '') : '';
|
||||||
|
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.presale = true;
|
||||||
|
const d = dd ? dd.textContent.trim() : '';
|
||||||
|
const w = wm ? wm.textContent.trim() : '';
|
||||||
|
if (d.includes('\u8ddd\u79bb\u5f00\u552e\u8fd8\u5269')) { o.type = 'countdown'; o.cd = w; }
|
||||||
|
else if (d.includes('\u5f00\u552e\u65f6\u95f4')) { o.type = 'scheduled'; o.st = w; }
|
||||||
|
else { o.type = 'unknown'; o.cd = w; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
R.push(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return R;
|
||||||
|
}"""
|
||||||
|
items = await page.evaluate(JS)
|
||||||
|
|
||||||
|
presale = []
|
||||||
|
for i, it in enumerate(items):
|
||||||
|
tag = "PRESALE" if it.get("presale") else "normal"
|
||||||
|
print(f"\n [{tag}] #{i}: {it.get('title','')[:60]}")
|
||||||
|
print(f" shop={it.get('shop','')} cart_id={it.get('cart_id','')}")
|
||||||
|
print(f" sku={it.get('sku','')} price={it.get('price','')} count={it.get('count','')}")
|
||||||
|
if it.get("presale"):
|
||||||
|
print(f" presale_type={it.get('type','')}")
|
||||||
|
if it.get("cd"): print(f" countdown={it['cd']}")
|
||||||
|
if it.get("st"): print(f" sale_time={it['st']}")
|
||||||
|
presale.append(it)
|
||||||
|
|
||||||
|
print(f"\n[3] total={len(items)}, presale={len(presale)}")
|
||||||
|
with open("debug_cart_items.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(items, f, ensure_ascii=False, indent=2)
|
||||||
|
await page.screenshot(path="debug_cart.png")
|
||||||
|
print(" saved")
|
||||||
|
|
||||||
|
print("\npress enter to close...")
|
||||||
|
try: await asyncio.get_event_loop().run_in_executor(None, input)
|
||||||
|
except: pass
|
||||||
|
await browser.close(); await p.stop()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_cart())
|
||||||
151
test_login.py
Normal file
151
test_login.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
微店登录流程测试脚本
|
||||||
|
用法: python test_login.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from utils.stealth import stealth_async
|
||||||
|
|
||||||
|
LOGIN_URL = "https://sso.weidian.com/login/index.php"
|
||||||
|
TEST_PHONE = "15556986013"
|
||||||
|
TEST_PASSWORD = "Precious171259"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login():
|
||||||
|
login_result = {"success": False, "msg": "未收到响应", "cookies": []}
|
||||||
|
|
||||||
|
p = await async_playwright().start()
|
||||||
|
browser = await p.chromium.launch(headless=False, args=["--disable-gpu"])
|
||||||
|
device = p.devices["iPhone 13"]
|
||||||
|
context = await browser.new_context(**device)
|
||||||
|
page = await context.new_page()
|
||||||
|
await stealth_async(page)
|
||||||
|
|
||||||
|
# 监听登录 API 响应
|
||||||
|
async def on_response(response):
|
||||||
|
url = response.url
|
||||||
|
if "user/login" in url or "user/bindcheck" in url:
|
||||||
|
try:
|
||||||
|
# 打印请求参数
|
||||||
|
req = response.request
|
||||||
|
print(f"\n[网络] {req.method} {url}")
|
||||||
|
print(f"[网络] 请求头: {dict(req.headers)}")
|
||||||
|
post_data = req.post_data
|
||||||
|
print(f"[网络] 请求体: {post_data}")
|
||||||
|
# 打印响应
|
||||||
|
data = await response.json()
|
||||||
|
status = data.get("status", {})
|
||||||
|
print(f"[网络] 响应 status_code={status.get('status_code')}")
|
||||||
|
if status.get("status_code") == 0 and "user/login" in url:
|
||||||
|
login_result["success"] = True
|
||||||
|
login_result["msg"] = "登录成功"
|
||||||
|
login_result["cookies"] = data.get("result", {}).get("cookie", [])
|
||||||
|
elif "user/login" in url:
|
||||||
|
login_result["msg"] = status.get("status_reason", "未知错误")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[网络] 解析失败: {e}")
|
||||||
|
|
||||||
|
page.on("response", on_response)
|
||||||
|
|
||||||
|
# Step 1: 打开登录页
|
||||||
|
print("[1] 打开登录页...")
|
||||||
|
await page.goto(LOGIN_URL, wait_until="networkidle", timeout=15000)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await page.screenshot(path="debug_step1.png")
|
||||||
|
print(f" URL: {page.url}")
|
||||||
|
|
||||||
|
# Step 2: 点击"登录"进入表单
|
||||||
|
print("[2] 点击 #login_init_by_login...")
|
||||||
|
await page.locator("#login_init_by_login").click(timeout=5000)
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
await page.screenshot(path="debug_step2.png")
|
||||||
|
|
||||||
|
# Step 3: 点击"账号密码登录" tab
|
||||||
|
print("[3] 点击'账号密码登录' tab...")
|
||||||
|
try:
|
||||||
|
await page.locator("h4.login_content_h4 span", has_text="账号密码登录").click(timeout=3000)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except Exception:
|
||||||
|
print(" 已在密码登录 tab")
|
||||||
|
|
||||||
|
await page.screenshot(path="debug_step3.png")
|
||||||
|
|
||||||
|
# Step 4: 填写手机号和密码
|
||||||
|
print(f"[4] 填写手机号: {TEST_PHONE}")
|
||||||
|
phone_input = page.locator('input[placeholder*="手机号"]').first
|
||||||
|
await phone_input.click()
|
||||||
|
await phone_input.fill("")
|
||||||
|
await page.keyboard.type(TEST_PHONE, delay=50)
|
||||||
|
|
||||||
|
print("[5] 填写密码...")
|
||||||
|
pwd_input = page.locator('input[placeholder*="登录密码"], input[type="password"]').first
|
||||||
|
await pwd_input.click()
|
||||||
|
await pwd_input.fill("")
|
||||||
|
await page.keyboard.type(TEST_PASSWORD, delay=50)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await page.screenshot(path="debug_step4.png")
|
||||||
|
print(" 截图已保存: debug_step4.png")
|
||||||
|
|
||||||
|
# Step 5: 点击登录按钮
|
||||||
|
print("[6] 点击 #login_pwd_submit...")
|
||||||
|
await page.locator("#login_pwd_submit").click(timeout=5000)
|
||||||
|
|
||||||
|
# 等待 API 响应
|
||||||
|
print(" 等待 API 响应...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
await page.screenshot(path="debug_step5.png")
|
||||||
|
print(f" 当前URL: {page.url}")
|
||||||
|
|
||||||
|
# 检查结果
|
||||||
|
print(f"\n[结果] success={login_result['success']}, msg={login_result['msg']}")
|
||||||
|
print(f"[结果] 拿到 {len(login_result['cookies'])} 个 cookie")
|
||||||
|
|
||||||
|
if login_result["success"] and login_result["cookies"]:
|
||||||
|
# 把 API 返回的 cookie 写入浏览器
|
||||||
|
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
|
||||||
|
await context.storage_state(path="test_auth_state.json")
|
||||||
|
print("[结果] cookie 已保存到 test_auth_state.json")
|
||||||
|
|
||||||
|
# 验证: 访问个人页
|
||||||
|
print("\n[验证] 访问个人页...")
|
||||||
|
await page.goto(
|
||||||
|
"https://h5.weidian.com/decoration/uni-mine/",
|
||||||
|
wait_until="networkidle", timeout=15000
|
||||||
|
)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
await page.screenshot(path="debug_verify.png")
|
||||||
|
verify_url = page.url
|
||||||
|
print(f"[验证] URL: {verify_url}")
|
||||||
|
if "login" in verify_url.lower():
|
||||||
|
print("[验证] 失败 - 被重定向到登录页")
|
||||||
|
else:
|
||||||
|
print("[验证] 成功 - cookie 有效!")
|
||||||
|
else:
|
||||||
|
print("[结果] 登录未成功")
|
||||||
|
body = await page.locator("body").text_content()
|
||||||
|
print(f"[页面] {body[:300]}")
|
||||||
|
|
||||||
|
print("\n按回车关闭浏览器...")
|
||||||
|
try:
|
||||||
|
await asyncio.get_event_loop().run_in_executor(None, input)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await browser.close()
|
||||||
|
await p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(test_login())
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user