diff --git a/server/routers/accounts.py b/server/routers/accounts.py index 139486f..dace7bf 100644 --- a/server/routers/accounts.py +++ b/server/routers/accounts.py @@ -1,9 +1,12 @@ import asyncio import os import threading +import time 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 +from server.services.auth_service import ( + get_auth_path, has_auth, login_with_password, login_with_sms +) bp = Blueprint('accounts', __name__, url_prefix='/accounts') @@ -22,13 +25,13 @@ def add_account(): 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 + if not name or not phone: + 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, '', '登录中...') + (name, phone, password, '', '待登录') ) account_id = cursor.lastrowid auth_file = get_auth_path(account_id) @@ -36,9 +39,6 @@ def add_account(): db.commit() db.close() - # 后台异步登录 - _start_bg_login(account_id, phone, password) - return jsonify(success=True, id=account_id) @@ -57,7 +57,7 @@ def delete_account(account_id): @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() @@ -74,7 +74,8 @@ def do_login(account_id): # 标记为登录中 db = get_db() db.execute( - "UPDATE accounts SET is_logged_in = 0, login_msg = '登录中...', updated_at = datetime('now','localtime') WHERE id = ?", + "UPDATE accounts SET is_logged_in = 0, login_msg = '登录中...', " + "updated_at = datetime('now','localtime') WHERE id = ?", (account_id,) ) db.commit() @@ -86,6 +87,39 @@ def do_login(account_id): return jsonify(success=True, msg='登录中...') +@bp.route('/login_sms/', methods=['POST']) +def do_sms_login(account_id): + """ + 短信验证码登录(需要人机交互) + 会弹出浏览器窗口,需要人拖滑块 + 输入验证码。 + """ + 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'] + if not phone: + 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_sms_login(account_id, phone) + + return jsonify(success=True, msg='已启动短信登录,请在弹出的浏览器中完成滑块验证,并在终端输入验证码') + + @bp.route('/status/') def get_status(account_id): """轮询账号登录状态""" @@ -100,13 +134,12 @@ def get_status(account_id): msg = account['login_msg'] or '' is_logged_in = bool(account['is_logged_in']) - # 登录中... 表示还在进行 - done = msg != '登录中...' + done = msg not in ('登录中...', '等待人机交互...') 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) @@ -137,3 +170,37 @@ def _start_bg_login(account_id, phone, password): t = threading.Thread(target=_run, daemon=True) t.start() + + +def _start_bg_sms_login(account_id, phone): + """后台线程执行短信登录""" + def _run(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + ok, msg = loop.run_until_complete( + login_with_sms(phone, account_id=account_id) + ) + 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/services/auth_service.py b/server/services/auth_service.py index df65f9b..804a2d0 100644 --- a/server/services/auth_service.py +++ b/server/services/auth_service.py @@ -1,11 +1,26 @@ +""" +登录态管理服务 —— 基于 v4 人机协作登录 +支持两种登录方式: + 1. 短信验证码登录(需要人拖滑块 + 输入验证码) + 2. 账号密码登录(纯 Playwright 自动化,无需人干预) +""" import asyncio import json import os +import time +from datetime import datetime + 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" + +# ── 微店 SSO 端点 ── +SSO_LOGIN_URL = "https://sso.weidian.com/login/index.php" +SEND_SMS_URL = "https://thor.weidian.com/passport/get.vcode/2.0" +LOGIN_BY_VCODE_URL = "https://sso.weidian.com/user/loginbyvcode" +LOGIN_BY_PWD_URL = "https://sso.weidian.com/user/login" +CAPTCHA_APPID = "2003473469" def get_auth_path(account_id): @@ -13,33 +28,54 @@ def get_auth_path(account_id): return os.path.join(AUTH_DIR, f'auth_state_{account_id}.json') +def get_cookies_path(account_id): + os.makedirs(AUTH_DIR, exist_ok=True) + return os.path.join(AUTH_DIR, f'cookies_{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 +def get_cookies_for_requests(account_id) -> dict: + """获取给 requests/aiohttp 使用的 cookies""" + path = get_cookies_path(account_id) + if os.path.exists(path): + with open(path, "r") as f: + data = json.load(f) + return data.get("cookies", {}) + return {} + + async def login_with_password(account_id, phone, password): """ - 用 Playwright 模拟浏览器登录微店,通过监听 API 响应提取 cookie。 + 账号密码登录 — 纯 Playwright 自动化,无需人参与。 流程: - 1. 打开登录页 - 2. 点击 #login_init_by_login 进入登录表单 - 3. 点击"账号密码登录" tab - 4. 填写手机号、密码 - 5. 点击 #login_pwd_submit - 6. 监听 /user/login 响应,从中提取 cookie 并保存 + 1. 打开登录页 → 点"登录" → 切到"账号密码" tab + 2. 填手机号、密码 → 点击登录 + 3. 监听 /user/login 响应提取 cookie + 4. 保存 storage_state """ login_result = {'success': False, 'msg': '登录超时', 'cookies': []} p = await async_playwright().start() browser = await p.chromium.launch( - headless=True, args=['--disable-gpu', '--no-sandbox'] + headless=True, args=[ + '--disable-gpu', '--no-sandbox', + '--disable-blink-features=AutomationControlled', + ] ) device = p.devices['iPhone 13'] context = await browser.new_context(**device) page = await context.new_page() await stealth_async(page) + # 反检测 + await page.add_init_script(""" + Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); + """) + # 监听登录接口响应 async def on_response(response): if 'user/login' in response.url and response.status == 200: @@ -58,12 +94,15 @@ async def login_with_password(account_id, phone, password): page.on("response", on_response) try: - await page.goto(LOGIN_URL, wait_until='networkidle', timeout=15000) + await page.goto(SSO_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) + try: + await page.locator('#login_init_by_login').click(timeout=5000) + await asyncio.sleep(1.5) + except Exception: + pass # 点击"账号密码登录" tab try: @@ -72,7 +111,7 @@ async def login_with_password(account_id, phone, password): except Exception: pass - # 填写手机号(逐字输入,触发 JS 事件) + # 填写手机号 phone_input = page.locator('input[placeholder*="手机号"]').first await phone_input.click() await phone_input.fill("") @@ -104,9 +143,11 @@ async def login_with_password(account_id, phone, password): "sameSite": "Lax", }]) - # 保存完整的 storage_state + # 保存 storage_state + 精简 cookies auth_path = get_auth_path(account_id) await context.storage_state(path=auth_path) + _save_cookies_file(account_id, context) + return True, "登录成功" return False, login_result['msg'] @@ -118,58 +159,253 @@ async def login_with_password(account_id, phone, password): await p.stop() -async def login_with_api(account_id, phone, password): +async def login_with_sms(phone, account_id=0): """ - 通过微店 SSO API 直接登录(备选方案,速度快但更容易触发风控)。 - """ - import aiohttp + 短信验证码登录 — 人机协作模式。 + 流程: + 1. Playwright 打开登录页,自动填手机号 + 2. 点击"获取验证码"触发腾讯滑块 + 3. 👆 用户在浏览器窗口拖动滑块 + 4. 脚本拦截 ticket 并自动发短信 + 5. 👆 用户输入 6 位验证码 + 6. 脚本自动提交登录 + 7. 保存 auth 状态 - 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", + 注意:此函数会弹出浏览器窗口,需要人在终端和浏览器之间交互。 + + Returns: (success: bool, msg: str) + """ + if not account_id: + account_id = int(time.time()) % 100000 + + captcha_state = { + "ticket": None, "randstr": None, + "sms_sent": False, "sms_error": None, + "login_success": False, "login_error": None, } - payload = {"phone": phone, "password": password, "loginMode": "password"} + + p = await async_playwright().start() + browser = await p.chromium.launch( + headless=False, # 必须有窗口 + args=[ + '--no-sandbox', + '--disable-blink-features=AutomationControlled', + ] + ) + + context = await browser.new_context( + user_agent=( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) " + "Version/18.0 Mobile/15E148 Safari/604.1" + ), + viewport={"width": 390, "height": 844}, + device_scale_factor=3, + is_mobile=True, + has_touch=True, + ) + + await context.add_init_script(""" + Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); + """) + + page = await context.new_page() + + # 拦截器 + async def on_response(response): + url = response.url + if "cap_union_new_verify" in url: + try: + data = await response.json() + if data.get("errorCode") == "0": + captcha_state["ticket"] = data.get("ticket", "") + captcha_state["randstr"] = data.get("randstr", "") + print(" ✅ 滑块验证通过!") + except Exception: + pass + elif "get.vcode" in url: + try: + data = await response.json() + code = str(data.get("status", {}).get("code", "")) + if code == "0": + captcha_state["sms_sent"] = True + print(" ✅ 短信验证码已发送!") + elif code not in ("", "0"): + captcha_state["sms_error"] = data.get("status", {}).get("msg", f"code={code}") + except Exception: + pass + elif "user/loginbyvcode" in url: + try: + data = await response.json() + sc = str(data.get("status", {}).get("status_code", + data.get("status", {}).get("code", ""))) + if sc == "0": + captcha_state["login_success"] = True + print(" ✅ 登录成功!") + else: + captcha_state["login_error"] = data.get("status", {}).get("status_reason", f"code={sc}") + except Exception: + pass + + page.on("response", on_response) 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", "未知错误") + print(f"\n{'='*50}") + print(f" 微店短信验证码登录") + print(f" 手机号: {phone}") + print(f"{'='*50}\n") + + # 打开登录页 + print("📡 打开登录页...") + await page.goto(SSO_LOGIN_URL, wait_until="load", timeout=20000) + await asyncio.sleep(3) + + # 点击"登录" + try: + btn = page.locator("#login_init_by_login") + if await btn.count() > 0 and await btn.is_visible(): + await btn.click() + await asyncio.sleep(2) + except Exception: + pass + + # 填手机号 + print(f"📱 填写手机号...") + tele = page.locator('input[placeholder*="手机号"]').first + await tele.click() + await tele.fill("") + await page.keyboard.type(phone, delay=80) + await asyncio.sleep(0.5) + + # 点击获取验证码 + print("🔘 点击 '获取验证码'...") + code_btn = page.locator("#login_quickCode_right, text=获取验证码").first + await code_btn.click() + + # 等待滑块 + print() + print(" ┌──────────────────────────────────────────┐") + print(" │ 👆 请在浏览器窗口中完成滑块验证 │") + print(" │ 拖动滑块到缺口位置即可 │") + print(" └──────────────────────────────────────────┘") + print() + + for i in range(180): + await asyncio.sleep(1) + if captcha_state["ticket"] or captcha_state["sms_sent"]: + break + if captcha_state["sms_error"]: + return False, f"短信发送失败: {captcha_state['sms_error']}" + if i == 59: + print(" ⏳ 已等待 60 秒...") + if i == 119: + print(" ⏳ 已等待 120 秒...") + else: + return False, "滑块验证超时(180秒)" + + # 等待短信 + print("⏳ 等待短信发送...") + for _ in range(10): + await asyncio.sleep(1) + if captcha_state["sms_sent"]: + break + + # 输入验证码 + print() + try: + import sys + loop = asyncio.get_event_loop() + vcode = await loop.run_in_executor(None, lambda: input(" 📨 请输入 6 位短信验证码: ").strip()) + except (EOFError, KeyboardInterrupt): + return False, "用户取消" + + if len(vcode) != 6 or not vcode.isdigit(): + return False, f"无效验证码(需要6位数字)" + + # 填写并提交 + print("📝 提交登录...") + vcode_input = page.locator("#login_quick_input, input[placeholder*='验证码']").first + await vcode_input.fill(vcode) + await asyncio.sleep(0.3) + + submit = page.locator("#login_quick_submit, button:has-text('登录')").first + await submit.click() + + # 等待登录 + print("⏳ 等待登录...") + for i in range(30): + await asyncio.sleep(1) + if captcha_state["login_success"]: + break + if captcha_state["login_error"]: + return False, captcha_state["login_error"] + # 检查 cookie + cookies = await context.cookies() + cookie_map = {c["name"]: c["value"] for c in cookies} + if cookie_map.get("is_login") == "true" and cookie_map.get("uid"): + captcha_state["login_success"] = True + break + + if not captcha_state["login_success"]: + return False, "登录超时" + + # 保存 + auth_path = get_auth_path(account_id) + await context.storage_state(path=auth_path) + await _save_cookies_file_async(context, account_id) + + print(f"\n✅ 登录成功!Auth 已保存: {auth_path}") + return True, "登录成功" - 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}" + return False, f"登录异常: {e}" + finally: + try: + await browser.close() + await p.stop() + except Exception: + pass + + +def _save_cookies_file(account_id, context): + """同步版:保存精简 cookies""" + try: + cookies = context.cookies() if hasattr(context, 'cookies') else [] + cookie_map = {c["name"]: c["value"] for c in cookies} + path = get_cookies_path(account_id) + with open(path, "w", encoding="utf-8") as f: + json.dump({ + "uid": cookie_map.get("uid", ""), + "cookies": cookie_map, + "saved_at": datetime.now().isoformat(), + }, f, ensure_ascii=False, indent=2) + except Exception: + pass + + +async def _save_cookies_file_async(context, account_id): + """异步版:保存精简 cookies""" + try: + cookies = await context.cookies() + cookie_map = {c["name"]: c["value"] for c in cookies} + path = get_cookies_path(account_id) + with open(path, "w", encoding="utf-8") as f: + json.dump({ + "uid": cookie_map.get("uid", ""), + "cookies": cookie_map, + "saved_at": datetime.now().isoformat(), + }, f, ensure_ascii=False, indent=2) + except Exception: + pass async def get_browser_context(playwright_instance, account_id, headless=True): """创建带有已保存登录状态的浏览器上下文""" browser = await playwright_instance.chromium.launch( - headless=headless, args=['--disable-gpu', '--no-sandbox'] + headless=headless, args=[ + '--disable-gpu', '--no-sandbox', + '--disable-blink-features=AutomationControlled', + ] ) device = playwright_instance.devices['iPhone 13'] auth_path = get_auth_path(account_id) diff --git a/templates/accounts.html b/templates/accounts.html index a79b57c..24a3788 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -32,7 +32,10 @@ {{ a.updated_at }} +