feat: 短信验证码登录 + v4 人机协作方案
核心改动: - weidian_sso_login_v4.py: 全新人机协作登录方案 - Playwright 打开页面 + 自动填手机号 - 人拖滑块(唯一需要人做的事) - 脚本自动拦截 ticket → 发短信 - 人输入验证码 → 自动提交 → 保存 auth - 反检测: 隐藏 webdriver 标记、模拟 iPhone 设备、逐字输入 - 多 selector 兼容(微店不同版本 DOM 结构) - 自动截图 debug(失败时) - auth_service.py: 重写,集成 v4 方案 - login_with_password(): 密码登录(全自动) - login_with_sms(): 短信登录(人机协作) - 保存 Playwright storage_state + 精简 cookies JSON - accounts.py 路由: 新增 /login_sms/<id> 接口 - 密码登录和短信登录两条路径 - 状态轮询支持新的交互状态 - accounts.html 模板: - 新增「短信登录」按钮 - 确认弹窗提醒用户需要浏览器交互
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from flask import Blueprint, request, jsonify, render_template
|
from flask import Blueprint, request, jsonify, render_template
|
||||||
from server.database import get_db
|
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')
|
bp = Blueprint('accounts', __name__, url_prefix='/accounts')
|
||||||
|
|
||||||
@@ -22,13 +25,13 @@ def add_account():
|
|||||||
phone = request.form.get('phone', '').strip()
|
phone = request.form.get('phone', '').strip()
|
||||||
password = request.form.get('password', '').strip()
|
password = request.form.get('password', '').strip()
|
||||||
|
|
||||||
if not name or not phone or not password:
|
if not name or not phone:
|
||||||
return jsonify(success=False, msg='请填写完整信息'), 400
|
return jsonify(success=False, msg='请填写名称和手机号'), 400
|
||||||
|
|
||||||
db = get_db()
|
db = get_db()
|
||||||
cursor = db.execute(
|
cursor = db.execute(
|
||||||
'INSERT INTO accounts (name, phone, password, auth_file, login_msg) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO accounts (name, phone, password, auth_file, login_msg) VALUES (?, ?, ?, ?, ?)',
|
||||||
(name, phone, password, '', '登录中...')
|
(name, phone, password, '', '待登录')
|
||||||
)
|
)
|
||||||
account_id = cursor.lastrowid
|
account_id = cursor.lastrowid
|
||||||
auth_file = get_auth_path(account_id)
|
auth_file = get_auth_path(account_id)
|
||||||
@@ -36,9 +39,6 @@ def add_account():
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# 后台异步登录
|
|
||||||
_start_bg_login(account_id, phone, password)
|
|
||||||
|
|
||||||
return jsonify(success=True, id=account_id)
|
return jsonify(success=True, id=account_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ def delete_account(account_id):
|
|||||||
|
|
||||||
@bp.route('/login/<int:account_id>', methods=['POST'])
|
@bp.route('/login/<int:account_id>', methods=['POST'])
|
||||||
def do_login(account_id):
|
def do_login(account_id):
|
||||||
"""用 Playwright 模拟浏览器自动登录微店"""
|
"""密码登录(自动,无需人参与)"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
account = db.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone()
|
account = db.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone()
|
||||||
db.close()
|
db.close()
|
||||||
@@ -74,7 +74,8 @@ def do_login(account_id):
|
|||||||
# 标记为登录中
|
# 标记为登录中
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.execute(
|
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,)
|
(account_id,)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -86,6 +87,39 @@ def do_login(account_id):
|
|||||||
return jsonify(success=True, msg='登录中...')
|
return jsonify(success=True, msg='登录中...')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/login_sms/<int:account_id>', 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/<int:account_id>')
|
@bp.route('/status/<int:account_id>')
|
||||||
def get_status(account_id):
|
def get_status(account_id):
|
||||||
"""轮询账号登录状态"""
|
"""轮询账号登录状态"""
|
||||||
@@ -100,13 +134,12 @@ def get_status(account_id):
|
|||||||
|
|
||||||
msg = account['login_msg'] or ''
|
msg = account['login_msg'] or ''
|
||||||
is_logged_in = bool(account['is_logged_in'])
|
is_logged_in = bool(account['is_logged_in'])
|
||||||
# 登录中... 表示还在进行
|
done = msg not in ('登录中...', '等待人机交互...')
|
||||||
done = msg != '登录中...'
|
|
||||||
return jsonify(is_logged_in=is_logged_in, login_msg=msg, done=done)
|
return jsonify(is_logged_in=is_logged_in, login_msg=msg, done=done)
|
||||||
|
|
||||||
|
|
||||||
def _start_bg_login(account_id, phone, password):
|
def _start_bg_login(account_id, phone, password):
|
||||||
"""在后台线程中执行登录"""
|
"""后台线程执行密码登录"""
|
||||||
def _run():
|
def _run():
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(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 = threading.Thread(target=_run, daemon=True)
|
||||||
t.start()
|
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()
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
|
"""
|
||||||
|
登录态管理服务 —— 基于 v4 人机协作登录
|
||||||
|
支持两种登录方式:
|
||||||
|
1. 短信验证码登录(需要人拖滑块 + 输入验证码)
|
||||||
|
2. 账号密码登录(纯 Playwright 自动化,无需人干预)
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
from utils.stealth import stealth_async
|
from utils.stealth import stealth_async
|
||||||
|
|
||||||
AUTH_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'auth')
|
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):
|
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')
|
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):
|
def has_auth(account_id):
|
||||||
path = get_auth_path(account_id)
|
path = get_auth_path(account_id)
|
||||||
return os.path.exists(path) and os.path.getsize(path) > 10
|
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):
|
async def login_with_password(account_id, phone, password):
|
||||||
"""
|
"""
|
||||||
用 Playwright 模拟浏览器登录微店,通过监听 API 响应提取 cookie。
|
账号密码登录 — 纯 Playwright 自动化,无需人参与。
|
||||||
流程:
|
流程:
|
||||||
1. 打开登录页
|
1. 打开登录页 → 点"登录" → 切到"账号密码" tab
|
||||||
2. 点击 #login_init_by_login 进入登录表单
|
2. 填手机号、密码 → 点击登录
|
||||||
3. 点击"账号密码登录" tab
|
3. 监听 /user/login 响应提取 cookie
|
||||||
4. 填写手机号、密码
|
4. 保存 storage_state
|
||||||
5. 点击 #login_pwd_submit
|
|
||||||
6. 监听 /user/login 响应,从中提取 cookie 并保存
|
|
||||||
"""
|
"""
|
||||||
login_result = {'success': False, 'msg': '登录超时', 'cookies': []}
|
login_result = {'success': False, 'msg': '登录超时', 'cookies': []}
|
||||||
|
|
||||||
p = await async_playwright().start()
|
p = await async_playwright().start()
|
||||||
browser = await p.chromium.launch(
|
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']
|
device = p.devices['iPhone 13']
|
||||||
context = await browser.new_context(**device)
|
context = await browser.new_context(**device)
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
await stealth_async(page)
|
await stealth_async(page)
|
||||||
|
|
||||||
|
# 反检测
|
||||||
|
await page.add_init_script("""
|
||||||
|
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
||||||
|
""")
|
||||||
|
|
||||||
# 监听登录接口响应
|
# 监听登录接口响应
|
||||||
async def on_response(response):
|
async def on_response(response):
|
||||||
if 'user/login' in response.url and response.status == 200:
|
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)
|
page.on("response", on_response)
|
||||||
|
|
||||||
try:
|
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 asyncio.sleep(1)
|
||||||
|
|
||||||
# 点击"登录"进入表单
|
# 点击"登录"进入表单
|
||||||
|
try:
|
||||||
await page.locator('#login_init_by_login').click(timeout=5000)
|
await page.locator('#login_init_by_login').click(timeout=5000)
|
||||||
await asyncio.sleep(1.5)
|
await asyncio.sleep(1.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 点击"账号密码登录" tab
|
# 点击"账号密码登录" tab
|
||||||
try:
|
try:
|
||||||
@@ -72,7 +111,7 @@ async def login_with_password(account_id, phone, password):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 填写手机号(逐字输入,触发 JS 事件)
|
# 填写手机号
|
||||||
phone_input = page.locator('input[placeholder*="手机号"]').first
|
phone_input = page.locator('input[placeholder*="手机号"]').first
|
||||||
await phone_input.click()
|
await phone_input.click()
|
||||||
await phone_input.fill("")
|
await phone_input.fill("")
|
||||||
@@ -104,9 +143,11 @@ async def login_with_password(account_id, phone, password):
|
|||||||
"sameSite": "Lax",
|
"sameSite": "Lax",
|
||||||
}])
|
}])
|
||||||
|
|
||||||
# 保存完整的 storage_state
|
# 保存 storage_state + 精简 cookies
|
||||||
auth_path = get_auth_path(account_id)
|
auth_path = get_auth_path(account_id)
|
||||||
await context.storage_state(path=auth_path)
|
await context.storage_state(path=auth_path)
|
||||||
|
_save_cookies_file(account_id, context)
|
||||||
|
|
||||||
return True, "登录成功"
|
return True, "登录成功"
|
||||||
|
|
||||||
return False, login_result['msg']
|
return False, login_result['msg']
|
||||||
@@ -118,58 +159,253 @@ async def login_with_password(account_id, phone, password):
|
|||||||
await p.stop()
|
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",
|
Returns: (success: bool, msg: str)
|
||||||
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
|
"""
|
||||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
if not account_id:
|
||||||
"Referer": "https://sso.weidian.com/login/index.php",
|
account_id = int(time.time()) % 100000
|
||||||
"Origin": "https://sso.weidian.com",
|
|
||||||
|
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:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
print(f"\n{'='*50}")
|
||||||
async with session.post(login_api, data=payload, headers=headers) as resp:
|
print(f" 微店短信验证码登录")
|
||||||
data = await resp.json()
|
print(f" 手机号: {phone}")
|
||||||
status_code = data.get("status", {}).get("status_code", -1)
|
print(f"{'='*50}\n")
|
||||||
status_reason = data.get("status", {}).get("status_reason", "未知错误")
|
|
||||||
|
|
||||||
if status_code == 0:
|
# 打开登录页
|
||||||
api_cookies = data.get("result", {}).get("cookie", [])
|
print("📡 打开登录页...")
|
||||||
pw_cookies = []
|
await page.goto(SSO_LOGIN_URL, wait_until="load", timeout=20000)
|
||||||
for c in api_cookies:
|
await asyncio.sleep(3)
|
||||||
pw_cookies.append({
|
|
||||||
"name": c.get("name", ""),
|
# 点击"登录"
|
||||||
"value": c.get("value", ""),
|
try:
|
||||||
"domain": c.get("domain", ".weidian.com"),
|
btn = page.locator("#login_init_by_login")
|
||||||
"path": c.get("path", "/"),
|
if await btn.count() > 0 and await btn.is_visible():
|
||||||
"expires": -1,
|
await btn.click()
|
||||||
"httpOnly": c.get("httpOnly", False),
|
await asyncio.sleep(2)
|
||||||
"secure": c.get("secure", False),
|
except Exception:
|
||||||
"sameSite": "Lax",
|
pass
|
||||||
})
|
|
||||||
state = {"cookies": pw_cookies, "origins": []}
|
# 填手机号
|
||||||
auth_path = get_auth_path(account_id)
|
print(f"📱 填写手机号...")
|
||||||
with open(auth_path, 'w', encoding='utf-8') as f:
|
tele = page.locator('input[placeholder*="手机号"]').first
|
||||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
await tele.click()
|
||||||
return True, "API登录成功"
|
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:
|
else:
|
||||||
return False, f"API登录失败: {status_reason}"
|
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, "登录成功"
|
||||||
|
|
||||||
except Exception as e:
|
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):
|
async def get_browser_context(playwright_instance, account_id, headless=True):
|
||||||
"""创建带有已保存登录状态的浏览器上下文"""
|
"""创建带有已保存登录状态的浏览器上下文"""
|
||||||
browser = await playwright_instance.chromium.launch(
|
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']
|
device = playwright_instance.devices['iPhone 13']
|
||||||
auth_path = get_auth_path(account_id)
|
auth_path = get_auth_path(account_id)
|
||||||
|
|||||||
@@ -32,7 +32,10 @@
|
|||||||
<td>{{ a.updated_at }}</td>
|
<td>{{ a.updated_at }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-outline-primary btn-sm" onclick="doLogin({{ a.id }}, this)">
|
<button class="btn btn-outline-primary btn-sm" onclick="doLogin({{ a.id }}, this)">
|
||||||
<i class="bi bi-box-arrow-in-right"></i> 登录
|
<i class="bi bi-key"></i> 密码登录
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-info btn-sm" onclick="doSmsLogin({{ a.id }}, this)">
|
||||||
|
<i class="bi bi-chat-dots"></i> 短信登录
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteAccount({{ a.id }})">
|
<button class="btn btn-outline-danger btn-sm" onclick="deleteAccount({{ a.id }})">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
@@ -104,7 +107,27 @@ function doLogin(id, btn) {
|
|||||||
fetch('/accounts/login/' + id, { method: 'POST' })
|
fetch('/accounts/login/' + id, { method: 'POST' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function() { pollStatus(id, btn); })
|
.then(function() { pollStatus(id, btn); })
|
||||||
.catch(function() { btn.disabled = false; btn.textContent = '重试'; });
|
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-key"></i> 密码登录'; });
|
||||||
|
}
|
||||||
|
function doSmsLogin(id, btn) {
|
||||||
|
if (!confirm('短信登录需要您在弹出的浏览器中拖动滑块,并在服务器终端输入验证码。确定继续?')) return;
|
||||||
|
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-info'; badge.textContent = '等待人机交互...'; }
|
||||||
|
fetch('/accounts/login_sms/' + id, { method: 'POST' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.success) {
|
||||||
|
alert(d.msg);
|
||||||
|
pollStatus(id, btn);
|
||||||
|
} else {
|
||||||
|
alert(d.msg);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录'; });
|
||||||
}
|
}
|
||||||
function pollStatus(id, btn) {
|
function pollStatus(id, btn) {
|
||||||
var interval = setInterval(function() {
|
var interval = setInterval(function() {
|
||||||
@@ -127,7 +150,9 @@ function pollStatus(id, btn) {
|
|||||||
}
|
}
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="bi bi-box-arrow-in-right"></i> 登录';
|
btn.innerHTML = btn.textContent.includes('短信') ?
|
||||||
|
'<i class="bi bi-chat-dots"></i> 短信登录' :
|
||||||
|
'<i class="bi bi-key"></i> 密码登录';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
544
weidian_sso_login_v4.py
Normal file
544
weidian_sso_login_v4.py
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
"""
|
||||||
|
微店 SSO 登录 v4 —— 以"人机协作"为核心的最佳实践
|
||||||
|
=================================================
|
||||||
|
核心思路:腾讯滑块验证码无法被程序绕过,必须有人参与。
|
||||||
|
本方案将"人的操作"缩到最少(只需拖一次滑块、输入一次验证码),
|
||||||
|
其余全自动化。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. Playwright 打开登录页,自动填手机号
|
||||||
|
2. 点击"获取验证码",触发腾讯滑块
|
||||||
|
3. 👆 用户在浏览器窗口拖动滑块(唯一需要人做的事)
|
||||||
|
4. 脚本自动拦截 ticket → 自动发短信
|
||||||
|
5. 👆 用户在终端输入 6 位短信验证码
|
||||||
|
6. 脚本自动提交登录 → 保存 auth_state → 导出 cookies
|
||||||
|
7. 后续业务 API 直接用 cookies,不需要再开浏览器
|
||||||
|
|
||||||
|
两种使用方式:
|
||||||
|
- CLI 模式:python weidian_sso_login_v4.py
|
||||||
|
- 代码调用:from weidian_sso_login_v4 import WeidianLoginV4
|
||||||
|
login = WeidianLoginV4()
|
||||||
|
result = login.login("13800138000")
|
||||||
|
|
||||||
|
依赖:
|
||||||
|
pip install playwright
|
||||||
|
playwright install chromium
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page
|
||||||
|
|
||||||
|
|
||||||
|
# ── 配置 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
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"
|
||||||
|
SYNC_LOGIN_URL = "https://sso.weidian.com/user/synclogin"
|
||||||
|
CAPTCHA_APPID = "2003473469"
|
||||||
|
|
||||||
|
# 保存目录
|
||||||
|
AUTH_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "auth")
|
||||||
|
|
||||||
|
|
||||||
|
class WeidianLoginV4:
|
||||||
|
"""微店 SSO 登录 v4 — 人机协作"""
|
||||||
|
|
||||||
|
def __init__(self, headless: bool = False, auth_dir: str = AUTH_DIR):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
headless: 是否无头模式(登录时必须 False,因为要人拖滑块)
|
||||||
|
auth_dir: auth 状态保存目录
|
||||||
|
"""
|
||||||
|
if headless:
|
||||||
|
print("⚠️ 登录需要人拖滑块,强制 headless=False")
|
||||||
|
headless = False
|
||||||
|
self.headless = headless
|
||||||
|
self.auth_dir = auth_dir
|
||||||
|
os.makedirs(auth_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# ── 核心登录流程 ────────────────────────────────
|
||||||
|
|
||||||
|
def login(self, phone: str, country_code: str = "86",
|
||||||
|
account_id: int = 0) -> dict:
|
||||||
|
"""
|
||||||
|
完整登录流程。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
phone: 手机号
|
||||||
|
country_code: 国家代码
|
||||||
|
account_id: 账号 ID(用于保存 auth 文件名),0 则自动生成
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"success": bool,
|
||||||
|
"uid": str,
|
||||||
|
"cookies": dict,
|
||||||
|
"auth_file": str,
|
||||||
|
"error": str (失败时)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not account_id:
|
||||||
|
account_id = int(time.time()) % 100000
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f" 微店 SSO 登录 v4")
|
||||||
|
print(f" 手机号: +{country_code} {phone}")
|
||||||
|
print(f"{'='*50}\n")
|
||||||
|
|
||||||
|
pw = sync_playwright().start()
|
||||||
|
browser = pw.chromium.launch(
|
||||||
|
headless=False, # 必须有窗口,人要拖滑块
|
||||||
|
args=[
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
"--no-sandbox",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 模拟真实设备,降低被识别概率
|
||||||
|
ctx = 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}, # iPhone 14 尺寸
|
||||||
|
device_scale_factor=3,
|
||||||
|
is_mobile=True,
|
||||||
|
has_touch=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注入反检测脚本
|
||||||
|
ctx.add_init_script("""
|
||||||
|
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
||||||
|
window.chrome = { runtime: {} };
|
||||||
|
""")
|
||||||
|
|
||||||
|
page = ctx.new_page()
|
||||||
|
|
||||||
|
# ── 拦截器 ──
|
||||||
|
captcha_state = {
|
||||||
|
"ticket": None, "randstr": None,
|
||||||
|
"sms_sent": False, "sms_error": None,
|
||||||
|
"login_success": False, "login_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_response(response):
|
||||||
|
url = response.url
|
||||||
|
|
||||||
|
# 拦截腾讯验证码验证结果
|
||||||
|
if "cap_union_new_verify" in url:
|
||||||
|
try:
|
||||||
|
data = 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 = 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 = response.json()
|
||||||
|
sc = str(data.get("status", {}).get("status_code",
|
||||||
|
data.get("status", {}).get("code", "")))
|
||||||
|
if sc == "0":
|
||||||
|
captcha_state["login_success"] = True
|
||||||
|
print(" ✅ 登录 API 返回成功!")
|
||||||
|
else:
|
||||||
|
captcha_state["login_error"] = data.get("status", {}).get("status_reason", f"code={sc}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
page.on("response", on_response)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._do_login_flow(page, ctx, browser, pw, phone, country_code,
|
||||||
|
account_id, captcha_state)
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": f"登录异常: {e}"}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
browser.close()
|
||||||
|
pw.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _do_login_flow(self, page: Page, ctx: BrowserContext,
|
||||||
|
browser: Browser, pw, phone: str, country_code: str,
|
||||||
|
account_id: int, captcha_state: dict) -> dict:
|
||||||
|
"""内部登录流程"""
|
||||||
|
|
||||||
|
# ── Step 1: 打开登录页 ──
|
||||||
|
print("📡 Step 1: 打开微店登录页...")
|
||||||
|
page.goto(SSO_LOGIN_URL, wait_until="load", timeout=20000)
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# 点击"登录"进入表单(有些版本需要先点一下)
|
||||||
|
try:
|
||||||
|
login_btn = page.locator("#login_init_by_login")
|
||||||
|
if login_btn.count() > 0 and login_btn.is_visible():
|
||||||
|
login_btn.click()
|
||||||
|
time.sleep(2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Step 2: 定位手机号输入框 ──
|
||||||
|
print(f"📱 Step 2: 填写手机号 +{country_code} {phone}")
|
||||||
|
|
||||||
|
# 尝试多种 selector(微店不同版本 DOM 结构不同)
|
||||||
|
tele_input = None
|
||||||
|
selectors = [
|
||||||
|
"#login_autoRegiTele_input",
|
||||||
|
'input[placeholder*="手机号"]',
|
||||||
|
'input[name="phone"]',
|
||||||
|
'input[type="tel"]',
|
||||||
|
'#login_tele_input',
|
||||||
|
]
|
||||||
|
|
||||||
|
for sel in selectors:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0:
|
||||||
|
tele_input = loc.first
|
||||||
|
break
|
||||||
|
|
||||||
|
if not tele_input:
|
||||||
|
# 可能默认在密码登录 tab,切换到快捷登录
|
||||||
|
try:
|
||||||
|
quick_tab = page.locator('text=短信验证登录')
|
||||||
|
if quick_tab.count() > 0:
|
||||||
|
quick_tab.click()
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
quick_tab = page.locator('[data-quick="1"]')
|
||||||
|
if quick_tab.count() > 0:
|
||||||
|
quick_tab.click()
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 再试一次
|
||||||
|
for sel in selectors:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0:
|
||||||
|
tele_input = loc.first
|
||||||
|
break
|
||||||
|
|
||||||
|
if not tele_input:
|
||||||
|
page.screenshot(path="debug_login_no_input.png")
|
||||||
|
return {"success": False, "error": "找不到手机号输入框,已截图 debug_login_no_input.png"}
|
||||||
|
|
||||||
|
tele_input.click()
|
||||||
|
tele_input.fill("")
|
||||||
|
page.keyboard.type(phone, delay=80) # 模拟逐字输入
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# ── Step 3: 点击获取验证码,触发滑块 ──
|
||||||
|
print("🔘 Step 3: 点击 '获取验证码'...")
|
||||||
|
|
||||||
|
code_btn = None
|
||||||
|
btn_selectors = [
|
||||||
|
"#login_quickCode_right",
|
||||||
|
"text=获取验证码",
|
||||||
|
"text=发送验证码",
|
||||||
|
'button:has-text("验证码")',
|
||||||
|
]
|
||||||
|
for sel in btn_selectors:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
code_btn = loc.first
|
||||||
|
break
|
||||||
|
|
||||||
|
if not code_btn:
|
||||||
|
page.screenshot(path="debug_login_no_btn.png")
|
||||||
|
return {"success": False, "error": "找不到'获取验证码'按钮"}
|
||||||
|
|
||||||
|
code_btn.click()
|
||||||
|
|
||||||
|
# ── Step 4: 等待人完成滑块 ──
|
||||||
|
print()
|
||||||
|
print(" ┌──────────────────────────────────────────┐")
|
||||||
|
print(" │ 👆 请在浏览器窗口中完成滑块验证 │")
|
||||||
|
print(" │ 拖动滑块到缺口位置即可 │")
|
||||||
|
print(" │ 完成后会自动继续 │")
|
||||||
|
print(" └──────────────────────────────────────────┘")
|
||||||
|
print()
|
||||||
|
|
||||||
|
captcha_done = self._wait_for_captcha(page, captcha_state, timeout=180)
|
||||||
|
if not captcha_done:
|
||||||
|
return {"success": False, "error": "滑块验证超时(180秒)"}
|
||||||
|
|
||||||
|
# 滑块通过后,页面通常会自动发短信
|
||||||
|
# 等一下看看短信是否自动发送
|
||||||
|
print("⏳ 等待短信发送...")
|
||||||
|
for _ in range(15):
|
||||||
|
time.sleep(1)
|
||||||
|
if captcha_state["sms_sent"]:
|
||||||
|
break
|
||||||
|
if captcha_state["sms_error"]:
|
||||||
|
return {"success": False, "error": f"短信发送失败: {captcha_state['sms_error']}"}
|
||||||
|
|
||||||
|
if not captcha_state["sms_sent"]:
|
||||||
|
# 滑块通过但短信没自动发,手动调 API 发送
|
||||||
|
print(" ⚠️ 短信未自动发送,手动触发...")
|
||||||
|
# 用页面已有的 cookie 直接在页面上再点一次
|
||||||
|
try:
|
||||||
|
code_btn.click()
|
||||||
|
time.sleep(5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not captcha_state["sms_sent"]:
|
||||||
|
print(" ⚠️ 尝试通过页面 JS 发送...")
|
||||||
|
try:
|
||||||
|
page.evaluate(f"""
|
||||||
|
fetch('{SEND_SMS_URL}', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{'Content-Type': 'application/x-www-form-urlencoded'}},
|
||||||
|
body: 'param=' + encodeURIComponent(JSON.stringify({{
|
||||||
|
phone: '{phone}',
|
||||||
|
countryCode: '{country_code}',
|
||||||
|
action: 'weidian',
|
||||||
|
scene: 'H5Login',
|
||||||
|
forceGraph: false
|
||||||
|
}}))
|
||||||
|
}}).then(r => r.json()).then(d => console.log('sms:', JSON.stringify(d)))
|
||||||
|
""")
|
||||||
|
time.sleep(5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Step 5: 用户输入短信验证码 ──
|
||||||
|
print()
|
||||||
|
vcode = self._input_vcode()
|
||||||
|
if not vcode:
|
||||||
|
return {"success": False, "error": "未输入验证码"}
|
||||||
|
|
||||||
|
# ── Step 6: 填写验证码并提交 ──
|
||||||
|
print("📝 Step 6: 提交登录...")
|
||||||
|
|
||||||
|
vcode_input = None
|
||||||
|
vcode_selectors = [
|
||||||
|
"#login_quick_input",
|
||||||
|
'input[placeholder*="验证码"]',
|
||||||
|
'input[name="vcode"]',
|
||||||
|
'#login_code_input',
|
||||||
|
]
|
||||||
|
for sel in vcode_selectors:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0:
|
||||||
|
vcode_input = loc.first
|
||||||
|
break
|
||||||
|
|
||||||
|
if vcode_input:
|
||||||
|
vcode_input.fill(vcode)
|
||||||
|
time.sleep(0.3)
|
||||||
|
else:
|
||||||
|
# 直接通过 API 提交
|
||||||
|
print(" ⚠️ 找不到验证码输入框,通过 API 提交...")
|
||||||
|
page.evaluate(f"""
|
||||||
|
fetch('{LOGIN_BY_VCODE_URL}', {{
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {{'Content-Type': 'application/x-www-form-urlencoded'}},
|
||||||
|
body: 'phone={phone}&countryCode={country_code}&vcode={vcode}'
|
||||||
|
}}).then(r => r.json()).then(d => console.log('login:', JSON.stringify(d)))
|
||||||
|
""")
|
||||||
|
time.sleep(8)
|
||||||
|
|
||||||
|
# 点击提交按钮
|
||||||
|
submit_btn = None
|
||||||
|
submit_selectors = [
|
||||||
|
"#login_quick_submit",
|
||||||
|
'button:has-text("登录")',
|
||||||
|
'button[type="submit"]',
|
||||||
|
]
|
||||||
|
for sel in submit_selectors:
|
||||||
|
loc = page.locator(sel)
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
submit_btn = loc.first
|
||||||
|
break
|
||||||
|
|
||||||
|
if submit_btn:
|
||||||
|
submit_btn.click()
|
||||||
|
|
||||||
|
# ── Step 7: 等待登录完成 ──
|
||||||
|
print("⏳ Step 7: 等待登录完成...")
|
||||||
|
result = self._wait_for_login(page, ctx, captcha_state, timeout=30)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
# 保存 auth 状态
|
||||||
|
auth_file = self._save_auth(ctx, account_id, result)
|
||||||
|
result["auth_file"] = auth_file
|
||||||
|
print(f"\n✅ 登录成功!uid={result.get('uid', '?')}")
|
||||||
|
print(f" Auth 已保存: {auth_file}")
|
||||||
|
else:
|
||||||
|
# 截图方便调试
|
||||||
|
page.screenshot(path="debug_login_failed.png")
|
||||||
|
print(f"\n❌ 登录失败: {result.get('error')}")
|
||||||
|
print(" 已截图: debug_login_failed.png")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── 辅助方法 ────────────────────────────────────
|
||||||
|
|
||||||
|
def _wait_for_captcha(self, page: Page, state: dict, timeout: int = 180) -> bool:
|
||||||
|
"""等待滑块验证完成"""
|
||||||
|
for i in range(timeout):
|
||||||
|
time.sleep(1)
|
||||||
|
if state["ticket"]:
|
||||||
|
return True
|
||||||
|
# 检查是否短信已经直接发送成功(有些情况下不需要滑块)
|
||||||
|
if state["sms_sent"]:
|
||||||
|
print(" ℹ️ 无需滑块,短信已直接发送")
|
||||||
|
return True
|
||||||
|
if i == 59:
|
||||||
|
print(" ⏳ 已等待 60 秒...")
|
||||||
|
if i == 119:
|
||||||
|
print(" ⏳ 已等待 120 秒...")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _input_vcode(self) -> Optional[str]:
|
||||||
|
"""让用户在终端输入验证码,支持超时"""
|
||||||
|
try:
|
||||||
|
vcode = input(" 📨 请输入 6 位短信验证码(120秒内): ").strip()
|
||||||
|
if len(vcode) == 6 and vcode.isdigit():
|
||||||
|
return vcode
|
||||||
|
if vcode:
|
||||||
|
print(f" ⚠️ 无效验证码: {vcode}(需要6位数字)")
|
||||||
|
return None
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _wait_for_login(self, page: Page, ctx: BrowserContext,
|
||||||
|
state: dict, timeout: int = 30) -> dict:
|
||||||
|
"""等待登录成功(检查 cookie 或 API 响应)"""
|
||||||
|
for i in range(timeout):
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 方法 1: 检查 API 拦截结果
|
||||||
|
if state["login_success"]:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 方法 2: 检查 cookie
|
||||||
|
cookies = {c["name"]: c["value"] for c in ctx.cookies()}
|
||||||
|
if cookies.get("is_login") == "true" and cookies.get("uid"):
|
||||||
|
state["login_success"] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# 方法 3: 检查 URL 跳转
|
||||||
|
if "weidian.com" in page.url and "login" not in page.url:
|
||||||
|
state["login_success"] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if state["login_error"]:
|
||||||
|
return {"success": False, "error": state["login_error"]}
|
||||||
|
|
||||||
|
if not state["login_success"]:
|
||||||
|
return {"success": False, "error": "登录超时,未检测到登录状态"}
|
||||||
|
|
||||||
|
# 提取 cookies
|
||||||
|
cookies = {c["name"]: c["value"] for c in ctx.cookies()}
|
||||||
|
uid = cookies.get("uid", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"uid": uid,
|
||||||
|
"cookies": cookies,
|
||||||
|
"url": page.url,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _save_auth(self, ctx: BrowserContext, account_id: int, result: dict) -> str:
|
||||||
|
"""保存 Playwright storage_state + 关键 cookies"""
|
||||||
|
auth_file = os.path.join(self.auth_dir, f"auth_state_{account_id}.json")
|
||||||
|
|
||||||
|
# 保存 Playwright storage_state(包含所有 cookie + localStorage)
|
||||||
|
ctx.storage_state(path=auth_file)
|
||||||
|
|
||||||
|
# 额外保存一份精简版 cookies(给 requests 用)
|
||||||
|
cookies_file = os.path.join(self.auth_dir, f"cookies_{account_id}.json")
|
||||||
|
with open(cookies_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({
|
||||||
|
"uid": result.get("uid", ""),
|
||||||
|
"cookies": result.get("cookies", {}),
|
||||||
|
"saved_at": datetime.now().isoformat(),
|
||||||
|
}, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return auth_file
|
||||||
|
|
||||||
|
# ── 工具方法 ────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_auth_file(account_id: int, auth_dir: str = AUTH_DIR) -> str:
|
||||||
|
return os.path.join(auth_dir, f"auth_state_{account_id}.json")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def has_auth(account_id: int, auth_dir: str = AUTH_DIR) -> bool:
|
||||||
|
path = os.path.join(auth_dir, f"auth_state_{account_id}.json")
|
||||||
|
return os.path.exists(path) and os.path.getsize(path) > 10
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_cookies_for_requests(account_id: int, auth_dir: str = AUTH_DIR) -> dict:
|
||||||
|
"""获取给 requests 库使用的 cookies"""
|
||||||
|
path = os.path.join(auth_dir, f"cookies_{account_id}.json")
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("cookies", {})
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── CLI 入口 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 50)
|
||||||
|
print(" 微店 SSO 登录 v4 — 人机协作")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
phone = input("\n📱 请输入手机号: ").strip()
|
||||||
|
if not phone:
|
||||||
|
print("❌ 手机号不能为空")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
account_id_input = input("📋 账号 ID(可选,回车跳过): ").strip()
|
||||||
|
account_id = int(account_id_input) if account_id_input.isdigit() else 0
|
||||||
|
|
||||||
|
login = WeidianLoginV4()
|
||||||
|
result = login.login(phone, account_id=account_id)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
if result["success"]:
|
||||||
|
print(" ✅ 登录成功!")
|
||||||
|
print(f" UID: {result.get('uid', '?')}")
|
||||||
|
print(f" Auth: {result.get('auth_file', '?')}")
|
||||||
|
important = {k: v for k, v in result.get("cookies", {}).items()
|
||||||
|
if k in ("uid", "is_login", "login_source", "smart_login_type")}
|
||||||
|
print(f" 关键 Cookie: {json.dumps(important, ensure_ascii=False)}")
|
||||||
|
else:
|
||||||
|
print(f" ❌ 失败: {result.get('error', '未知')}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user