diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2478f20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +.vscode/ +data/ +auth_state.json +test_auth_state.json +debug_* +*.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbed51b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index edde67d..3aa5488 100644 --- a/README.md +++ b/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. **安装依赖** - 请确保已安装 Python,并安装所需的库: - ```bash - pip install playwright pyyaml - playwright install - ``` +访问 http://localhost:9000 -2. **配置 config.yaml** - 修改 `config.yaml` 文件,填入目标商品 URL (`target_url`) 和抢购时间 (`snatch_time`)。 +### Docker 运行 -3. **运行脚本** - ```bash - python main.py - ``` - 如果是首次运行且无登录状态,脚本会提示登录。请手动登录后,脚本会自动保存状态供下次使用。 +```bash +docker compose up -d +``` + +数据持久化在 `./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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2b28768 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + snatcher: + build: . + container_name: weidian-snatcher + ports: + - "9000:9000" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/main.py b/main.py index e56620b..5925c9d 100644 --- a/main.py +++ b/main.py @@ -24,20 +24,16 @@ async def snatch(config): # 1. 预热:先打开页面 print(f"正在打开商品页面: {target_url}") await page.goto(target_url) - - # 如果未登录,可能需要在这里处理扫码 + + # 如果未登录,处理登录 if not auth.has_auth(): print("未发现登录状态,请手动操作并将状态保存...") - # 注意:login 会启动一个新的浏览器窗口 await auth.login(target_url) - - # 登录完成后,我们需要关闭当前空的 context 并重新加载带有 cookie 的 context await context.close() await browser.close() browser, context = await auth.get_context(p, headless=config.get("headless", False)) page = await context.new_page() await stealth_async(page) - print("已重新加载登录状态,正在打开商品页面...") await page.goto(target_url) @@ -46,42 +42,68 @@ async def snatch(config): if snatch_time: await timer.wait_until(snatch_time) - # 3. 抢购核心逻辑 (H5 流程) - # 注意:这里的选择器需要根据微店 H5 实际页面结构进行微调 - # 这里演示一般的微店抢购流程:点击购买 -> 选择规格 -> 确定 -> 提交订单 - try: - # 刷新页面以获取最新状态(可选,视具体页面倒计时逻辑而定) - # await page.reload() - - # 点击“立即购买”按钮 - # 微店 H5 常见的购买按钮类名类似于 .buy-btn, .footer-buy - # 我们尝试使用 text 匹配 - buy_button = page.get_by_text("立即购买") - await buy_button.click() - print("点击立即购买") + # 3. 抢购核心逻辑 + 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) - # 处理 SKU 选择(如果弹出 SKU 选择框) - # 这里简单起见,如果弹出了规格选择,点击第一个选项并确定 - # 实际需要根据 config 中的 sku_id 进行精准点击 - confirm_btn = page.get_by_text("确定") - if await confirm_btn.is_visible(): - await confirm_btn.click() - print("点击确定(SKU)") + # 点击购买按钮(兼容多种文案) + buy_btn = None + for text in ["立即抢购", "立即购买", "马上抢", "立即秒杀"]: + loc = page.get_by_text(text, exact=False) + if await loc.count() > 0: + buy_btn = loc.first + break - # 进入确认订单页面后,点击“提交订单” - # 提交订单按钮通常在底部,文字为“提交订单” - submit_btn = page.get_by_text("提交订单") - await submit_btn.wait_for(state="visible", timeout=5000) - await submit_btn.click() - print("点击提交订单!抢购请求已发送!") + if not buy_btn: + if attempt < max_retries - 1: + print(f"第{attempt+1}次未找到购买按钮,重试...") + continue + print("错误: 未找到购买按钮") + break + + await buy_btn.click(timeout=3000) + print("点击购买按钮") + + # 处理 SKU 选择(如果弹出规格选择框) + await asyncio.sleep(0.5) + try: + confirm_btn = page.get_by_text("确定", exact=True) + if await confirm_btn.count() > 0 and await confirm_btn.first.is_visible(): + # 自动选择第一个可用的 SKU 选项 + sku_items = page.locator('.sku-item:not(.disabled), .sku_item:not(.disabled), [class*="sku"] [class*="item"]:not([class*="disabled"])') + if await sku_items.count() > 0: + await sku_items.first.click() + print("自动选择 SKU") + await asyncio.sleep(0.3) + await confirm_btn.first.click(timeout=3000) + print("点击确定(SKU)") + except Exception: + pass + + # 提交订单 + submit_btn = page.get_by_text("提交订单") + await submit_btn.wait_for(state="visible", timeout=8000) + await submit_btn.click() + print("点击提交订单!抢购请求已发送!") + break + + except Exception as e: + if attempt < max_retries - 1: + print(f"第{attempt+1}次尝试失败: {e},重试...") + else: + print(f"抢购失败: {e}") - except Exception as e: - print(f"抢购过程中发生错误: {e}") - # 保持浏览器打开一段时间查看结果 await asyncio.sleep(10) await browser.close() + def load_config(): with open("config.yaml", "r", encoding="utf-8") as f: return yaml.safe_load(f) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8e2840a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0 +playwright>=1.40 +playwright-stealth>=1.0 +pyyaml>=6.0 +ntplib>=0.4 diff --git a/run.py b/run.py new file mode 100644 index 0000000..0b274ff --- /dev/null +++ b/run.py @@ -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) diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..2c6b67d --- /dev/null +++ b/server/__init__.py @@ -0,0 +1 @@ +# server package diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..28710f3 --- /dev/null +++ b/server/app.py @@ -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) diff --git a/server/database.py b/server/database.py new file mode 100644 index 0000000..91e8327 --- /dev/null +++ b/server/database.py @@ -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() diff --git a/server/routers/__init__.py b/server/routers/__init__.py new file mode 100644 index 0000000..9c8ddfa --- /dev/null +++ b/server/routers/__init__.py @@ -0,0 +1 @@ +# routers package diff --git a/server/routers/accounts.py b/server/routers/accounts.py new file mode 100644 index 0000000..139486f --- /dev/null +++ b/server/routers/accounts.py @@ -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/', 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/', 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/') +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() diff --git a/server/routers/orders.py b/server/routers/orders.py new file mode 100644 index 0000000..a173beb --- /dev/null +++ b/server/routers/orders.py @@ -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) diff --git a/server/routers/tasks.py b/server/routers/tasks.py new file mode 100644 index 0000000..5bc1414 --- /dev/null +++ b/server/routers/tasks.py @@ -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/', methods=['POST']) +def start(task_id): + ok, msg = start_task(task_id) + return jsonify(success=ok, msg=msg) + + +@bp.route('/stop/', methods=['POST']) +def stop(task_id): + ok, msg = stop_task(task_id) + return jsonify(success=ok, msg=msg) + + +@bp.route('/delete/', 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/', 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/', methods=['POST']) +def start_account(account_id): + """启动指定账号的所有 pending 任务""" + ok, msg = start_account_tasks(account_id) + return jsonify(success=ok, msg=msg) diff --git a/server/services/__init__.py b/server/services/__init__.py new file mode 100644 index 0000000..0274469 --- /dev/null +++ b/server/services/__init__.py @@ -0,0 +1 @@ +# services package diff --git a/server/services/auth_service.py b/server/services/auth_service.py new file mode 100644 index 0000000..df65f9b --- /dev/null +++ b/server/services/auth_service.py @@ -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 diff --git a/server/services/cart_service.py b/server/services/cart_service.py new file mode 100644 index 0000000..133c94b --- /dev/null +++ b/server/services/cart_service.py @@ -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 diff --git a/server/services/scheduler.py b/server/services/scheduler.py new file mode 100644 index 0000000..c56568d --- /dev/null +++ b/server/services/scheduler.py @@ -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()] diff --git a/server/services/snatcher.py b/server/services/snatcher.py new file mode 100644 index 0000000..3712f85 --- /dev/null +++ b/server/services/snatcher.py @@ -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() diff --git a/templates/accounts.html b/templates/accounts.html new file mode 100644 index 0000000..a79b57c --- /dev/null +++ b/templates/accounts.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} +{% block content %} +
+

账号管理

+ +
+
+
+ + + + + + {% for a in accounts %} + + + + + + + + + {% endfor %} + {% if not accounts %} + + {% endif %} + +
ID名称手机号登录状态更新时间操作
{{ a.id }}{{ a.name }}{{ a.phone[:3] }}****{{ a.phone[-4:] if a.phone|length > 4 else '' }} + {% if a.is_logged_in %} + 已登录 + {% elif a.login_msg == '登录中...' %} + + 登录中... + + {% else %} + 未登录 + {% endif %} + {{ a.updated_at }} + + +
暂无账号,点击右上角添加
+
+
+ +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..9b6d0ab --- /dev/null +++ b/templates/base.html @@ -0,0 +1,47 @@ + + + + + + 微店抢购管理 + + + + + + +
+ {% block content %}{% endblock %} +
+ + {% block scripts %}{% endblock %} + + diff --git a/templates/orders.html b/templates/orders.html new file mode 100644 index 0000000..404f268 --- /dev/null +++ b/templates/orders.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block content %} +

订单记录

+ +
+
+ + + + + + + {% for o in orders %} + + + + + + + + + + {% endfor %} + {% if not orders %} + + {% endif %} + +
ID账号商品链接抢购时间状态详情记录时间
{{ o.id }}{{ o.account_name or '-' }} + {% if o.target_url %} + {{ o.target_url[:40] }}... + {% else %}-{% endif %} + {{ o.snatch_time or '-' }}{{ o.status }}{{ o.detail or '-' }}{{ o.created_at }}
暂无订单记录
+
+
+{% endblock %} diff --git a/templates/tasks.html b/templates/tasks.html new file mode 100644 index 0000000..3ab59be --- /dev/null +++ b/templates/tasks.html @@ -0,0 +1,272 @@ +{% extends "base.html" %} +{% block content %} +
+

抢购任务

+
+ + + +
+
+ +{% if not grouped %} +
+ +

暂无任务,点击"同步购物车"自动添加预售商品

+
+{% else %} +{% for account_id, group in grouped.items() %} +
+
+
+ + {{ group.name }} + {{ group.tasks|length }} 个任务 +
+ +
+
+ {% for t in group.tasks %} +
+
+
--:--:--
+
距开售
+
+
+
+ {{ t.status }} + {% if t.price %} + ¥{{ t.price }} + {% endif %} +
+
+ {% if t.item_name %} + {{ t.item_name[:50] }}{% if t.item_name|length > 50 %}...{% endif %} + {% elif t.target_url %} + {{ t.target_url[:60] }}... + {% else %}-{% endif %} +
+
+ {{ t.snatch_time }} + {% if t.result %} + · {{ t.result[:50] }} + {% endif %} +
+
+
+ {% if t.status == 'pending' %} + + {% elif t.id in running_ids %} + + {% endif %} + +
+
+ {% endfor %} +
+
+{% endfor %} +{% endif %} + + + + + +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/test_cart.py b/test_cart.py new file mode 100644 index 0000000..7b21b4d --- /dev/null +++ b/test_cart.py @@ -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()) diff --git a/test_login.py b/test_login.py new file mode 100644 index 0000000..9a58156 --- /dev/null +++ b/test_login.py @@ -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()) diff --git a/utils/__pycache__/auth.cpython-313.pyc b/utils/__pycache__/auth.cpython-313.pyc deleted file mode 100644 index 546f621..0000000 Binary files a/utils/__pycache__/auth.cpython-313.pyc and /dev/null differ diff --git a/utils/__pycache__/stealth.cpython-313.pyc b/utils/__pycache__/stealth.cpython-313.pyc deleted file mode 100644 index b836b45..0000000 Binary files a/utils/__pycache__/stealth.cpython-313.pyc and /dev/null differ diff --git a/utils/__pycache__/timer.cpython-313.pyc b/utils/__pycache__/timer.cpython-313.pyc deleted file mode 100644 index cf14d4d..0000000 Binary files a/utils/__pycache__/timer.cpython-313.pyc and /dev/null differ