重构为 HTTP SSO 扫码方案并引入 Vue3 前端
移除 Playwright 浏览器自动化,改用 passport/SSO HTTP 接口获取二维码与轮询登录;后端模块化拆分,前端替换为 Vue3 SPA。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Douyin Cookie Extractor backend package
|
||||
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from backend.config import FRONTEND_DIST, LOG_DIR, PORT, SECRET_KEY_PATH, STATIC_DIR
|
||||
from backend.routes.api import api_bp
|
||||
from backend.routes.spa import spa_bp
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
os.makedirs(STATIC_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _load_secret_key():
|
||||
secret = os.environ.get("SECRET_KEY")
|
||||
if not secret and os.path.exists(SECRET_KEY_PATH):
|
||||
with open(SECRET_KEY_PATH, "rb") as f:
|
||||
secret = f.read()
|
||||
if not secret:
|
||||
secret = os.urandom(24)
|
||||
with open(SECRET_KEY_PATH, "wb") as f:
|
||||
f.write(secret)
|
||||
return secret
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.secret_key = _load_secret_key()
|
||||
|
||||
handler = RotatingFileHandler(
|
||||
os.path.join(LOG_DIR, "app.log"),
|
||||
maxBytes=1024 * 1024 * 10,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setLevel(logging.INFO)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
|
||||
app.logger.addHandler(handler)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(spa_bp)
|
||||
return app
|
||||
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
LOG_DIR = os.environ.get("LOG_DIR", os.path.join(BASE_DIR, "logs"))
|
||||
STATIC_DIR = os.environ.get("STATIC_DIR", os.path.join(BASE_DIR, "static"))
|
||||
FRONTEND_DIST = os.environ.get("FRONTEND_DIST", os.path.join(BASE_DIR, "frontend", "dist"))
|
||||
SECRET_KEY_PATH = os.path.join(BASE_DIR, ".secret_key")
|
||||
|
||||
PORT = int(os.environ.get("PORT", 5001))
|
||||
LOGIN_TIMEOUT = int(os.environ.get("LOGIN_TIMEOUT", 600))
|
||||
QR_POLL_INTERVAL = float(os.environ.get("QR_POLL_INTERVAL", "2"))
|
||||
|
||||
# 抖音 Web SSO 参数(www.douyin.com 主站)
|
||||
DOUYIN_AID = os.environ.get("DOUYIN_AID", "6383")
|
||||
DOUYIN_SERVICE = os.environ.get("DOUYIN_SERVICE", "https://www.douyin.com")
|
||||
DOUYIN_REFERER = os.environ.get("DOUYIN_REFERER", "https://www.douyin.com/")
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
LOGIN_COOKIE_KEYS = frozenset({
|
||||
"sessionid", "sessionid_ss", "sid_guard", "uid_tt", "uid_tt_ss",
|
||||
"sso_uid_tt", "sso_uid_tt_ss", "passport_sso_user_id",
|
||||
"sid_ucp_sso", "sid_guard_ee", "uid_tt_ee",
|
||||
"passport_auth_status", "login_time", "multi_sids",
|
||||
"is_login", "douyin_tt",
|
||||
})
|
||||
|
||||
|
||||
def match_login_cookies(cookie_names):
|
||||
name_set = set(cookie_names)
|
||||
return [k for k in LOGIN_COOKIE_KEYS if k in name_set]
|
||||
|
||||
|
||||
def is_logged_in(cookies, cookie_names):
|
||||
matched = match_login_cookies(cookie_names)
|
||||
name_set = set(cookie_names)
|
||||
if len(matched) >= 2:
|
||||
return True
|
||||
has_session = bool(name_set & {"sessionid", "sessionid_ss", "sid_guard", "sid_tt"})
|
||||
has_uid = bool(name_set & {"uid_tt", "uid_tt_ss", "passport_sso_user_id"})
|
||||
if has_session and has_uid:
|
||||
return True
|
||||
if has_session and len(cookies) >= 30:
|
||||
return True
|
||||
for c in cookies:
|
||||
if c["name"] == "passport_auth_status":
|
||||
val = str(c.get("value", "")).lower()
|
||||
if val in ("1", "true", "yes", "2") and has_session:
|
||||
return True
|
||||
if c["name"] == "login_time" and c.get("value"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_nickname(cookies):
|
||||
for c in cookies:
|
||||
if c["name"] in ("passport_sso_user_name", "nickname", "user_name", "user_nickname"):
|
||||
return c["value"]
|
||||
for c in cookies:
|
||||
if c["name"] == "uid_tt" and c.get("value"):
|
||||
return f"用户{c['value'][:8]}"
|
||||
return "用户"
|
||||
|
||||
|
||||
def cookies_to_json(cookie_jar) -> list[dict]:
|
||||
"""将 requests CookieJar 转为前端 JSON 列表"""
|
||||
items = []
|
||||
for c in cookie_jar:
|
||||
items.append({
|
||||
"name": c.name,
|
||||
"value": c.value,
|
||||
"domain": c.domain or ".douyin.com",
|
||||
"path": c.path or "/",
|
||||
"expires": c.expires,
|
||||
"httpOnly": bool(getattr(c, "_rest", {}).get("HttpOnly") or False),
|
||||
"secure": bool(c.secure),
|
||||
})
|
||||
return items
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_proxy_from_api(api_url, max_retries=3, timeout=10):
|
||||
if not api_url:
|
||||
return None
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = requests.get(api_url, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
content = resp.text.strip()
|
||||
if not content:
|
||||
raise ValueError("代理 API 返回空内容")
|
||||
try:
|
||||
data = resp.json()
|
||||
if "ip" in data and "port" in data:
|
||||
proxy_str = f"{data['ip']}:{data['port']}"
|
||||
else:
|
||||
proxy_str = content
|
||||
except json.JSONDecodeError:
|
||||
proxy_str = content
|
||||
if not proxy_str.startswith(("http://", "https://")):
|
||||
proxy_str = f"http://{proxy_str}"
|
||||
return {"server": proxy_str}
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
wait = 2 ** attempt
|
||||
logger.warning("获取代理失败 (第%s次): %s,%ss 后重试", attempt + 1, e, wait)
|
||||
time.sleep(wait)
|
||||
logger.error("获取代理失败,已重试%s次: %s", max_retries, last_error)
|
||||
return None
|
||||
|
||||
|
||||
def test_proxy_api(proxy_api):
|
||||
proxy = get_proxy_from_api(proxy_api, timeout=5)
|
||||
if not proxy:
|
||||
return {"status": "error", "message": "获取代理失败"}
|
||||
proxies = {"http": proxy["server"], "https": proxy["server"]}
|
||||
test_urls = [
|
||||
"http://httpbin.org/ip",
|
||||
"https://api.ipify.org?format=json",
|
||||
]
|
||||
last_err = None
|
||||
for test_url in test_urls:
|
||||
try:
|
||||
resp = requests.get(test_url, proxies=proxies, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
ip = data.get("origin") or data.get("ip", "unknown")
|
||||
except json.JSONDecodeError:
|
||||
ip = resp.text.strip()[:64] or "unknown"
|
||||
return {"status": "success", "ip": ip, "message": "代理可用"}
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
return {"status": "error", "message": f"代理测试失败: {last_err}"}
|
||||
@@ -0,0 +1 @@
|
||||
# API routes package
|
||||
@@ -0,0 +1,139 @@
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
|
||||
from backend.config import LOGIN_TIMEOUT, STATIC_DIR
|
||||
from backend.proxy import test_proxy_api
|
||||
from backend.session import login_session
|
||||
from backend.sso.qr_login import check_login_state, cleanup_session, debug_snapshot, start_qr_login
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
login_session.login_timeout = LOGIN_TIMEOUT
|
||||
|
||||
|
||||
def _save_qr_png(qrcode_b64: str):
|
||||
if not qrcode_b64:
|
||||
return
|
||||
raw = qrcode_b64.split(",", 1)[-1]
|
||||
img_bytes = base64.b64decode(raw)
|
||||
path = os.path.join(STATIC_DIR, "qrcode.png")
|
||||
with open(path, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
|
||||
|
||||
@api_bp.route("/api/start_qr", methods=["POST"])
|
||||
def start_qr():
|
||||
if not login_session.lock.acquire(blocking=False):
|
||||
return jsonify({"status": "error", "message": "已有正在进行的扫码任务,请稍后重试"})
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
proxy_api = data.get("proxy_api", "").strip()
|
||||
result, qr_b64 = start_qr_login(proxy_api)
|
||||
if qr_b64:
|
||||
try:
|
||||
_save_qr_png(qr_b64)
|
||||
except Exception as e:
|
||||
logger.warning("保存二维码图片失败: %s", e)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
login_session.lock.release()
|
||||
|
||||
|
||||
@api_bp.route("/api/check_login", methods=["GET"])
|
||||
def check_login():
|
||||
req_sid = request.args.get("session_id", "")
|
||||
if req_sid and login_session.session_id and req_sid != login_session.session_id:
|
||||
return jsonify({"status": "idle", "message": "会话已过期,请重新获取二维码"})
|
||||
|
||||
if login_session.status in ("idle", "loading"):
|
||||
return jsonify({"status": login_session.status, "message": login_session.message})
|
||||
|
||||
if login_session.status == "error":
|
||||
return jsonify({"status": "error", "message": login_session.message})
|
||||
|
||||
if login_session.status == "success" and login_session.cookies:
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"session_id": login_session.session_id,
|
||||
"nickname": login_session.nickname,
|
||||
"cookies": login_session.cookies,
|
||||
"message": login_session.message,
|
||||
})
|
||||
|
||||
try:
|
||||
return jsonify(check_login_state())
|
||||
except Exception as e:
|
||||
logger.error("检查登录异常: %s", e, exc_info=True)
|
||||
return jsonify({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
@api_bp.route("/api/test_proxy", methods=["POST"])
|
||||
def test_proxy():
|
||||
data = request.get_json() or {}
|
||||
proxy_api = data.get("proxy_api", "").strip()
|
||||
if not proxy_api:
|
||||
return jsonify({"status": "error", "message": "请提供代理 API"})
|
||||
try:
|
||||
return jsonify(test_proxy_api(proxy_api))
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
@api_bp.route("/api/status", methods=["GET"])
|
||||
def api_status():
|
||||
elapsed = ""
|
||||
if login_session.status in ("qr_ready", "scanning", "success") and login_session.start_time:
|
||||
elapsed_sec = int(time.time() - login_session.start_time)
|
||||
elapsed = f" (已等待 {elapsed_sec}s)"
|
||||
client = login_session.sso_client
|
||||
return jsonify({
|
||||
"status": login_session.status,
|
||||
"message": login_session.message + elapsed,
|
||||
"session_id": login_session.session_id or "",
|
||||
"nickname": login_session.nickname or "",
|
||||
"proxy_used": login_session.proxy_used,
|
||||
"api_mode": getattr(client, "api_mode", None) if client else None,
|
||||
"login_timeout": LOGIN_TIMEOUT,
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route("/api/reset", methods=["POST"])
|
||||
def api_reset():
|
||||
cleanup_session()
|
||||
login_session.reset()
|
||||
login_session.message = "会话已重置"
|
||||
return jsonify({"status": "success", "message": "会话已重置"})
|
||||
|
||||
|
||||
@api_bp.route("/api/health", methods=["GET"])
|
||||
def api_health():
|
||||
client = login_session.sso_client
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"session_status": login_session.status,
|
||||
"session_id": login_session.session_id or "",
|
||||
"api_mode": getattr(client, "api_mode", None) if client else None,
|
||||
"engine": "http_sso",
|
||||
"timestamp": time.time(),
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route("/api/debug", methods=["GET"])
|
||||
def api_debug():
|
||||
try:
|
||||
return jsonify(debug_snapshot())
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
@api_bp.route("/qrcode.png")
|
||||
def get_qrcode():
|
||||
qr_path = os.path.join(STATIC_DIR, "qrcode.png")
|
||||
if os.path.exists(qr_path):
|
||||
return send_file(qr_path, mimetype="image/png")
|
||||
return "二维码未生成,请先点击获取二维码", 404
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
|
||||
from flask import Blueprint, abort, send_file, send_from_directory
|
||||
|
||||
from backend.config import FRONTEND_DIST
|
||||
|
||||
spa_bp = Blueprint("spa", __name__)
|
||||
|
||||
|
||||
def _frontend_index_path():
|
||||
return os.path.join(FRONTEND_DIST, "index.html")
|
||||
|
||||
|
||||
def _has_frontend_build():
|
||||
return os.path.isfile(_frontend_index_path())
|
||||
|
||||
|
||||
def serve_spa():
|
||||
if not _has_frontend_build():
|
||||
return (
|
||||
"前端未构建,请在 frontend 目录执行: npm install && npm run build",
|
||||
503,
|
||||
{"Content-Type": "text/plain; charset=utf-8"},
|
||||
)
|
||||
return send_file(_frontend_index_path())
|
||||
|
||||
|
||||
@spa_bp.route("/")
|
||||
def index():
|
||||
return serve_spa()
|
||||
|
||||
|
||||
@spa_bp.route("/debug")
|
||||
def debug_view():
|
||||
return serve_spa()
|
||||
|
||||
|
||||
@spa_bp.route("/assets/<path:filename>")
|
||||
def frontend_assets(filename):
|
||||
assets_dir = os.path.join(FRONTEND_DIST, "assets")
|
||||
if not os.path.isdir(assets_dir):
|
||||
abort(404)
|
||||
return send_from_directory(assets_dir, filename)
|
||||
|
||||
|
||||
@spa_bp.route("/<path:path>")
|
||||
def spa_fallback(path):
|
||||
if path.startswith("api/") or path == "qrcode.png":
|
||||
abort(404)
|
||||
if not _has_frontend_build():
|
||||
abort(404)
|
||||
dist_file = os.path.join(FRONTEND_DIST, path)
|
||||
if os.path.isfile(dist_file):
|
||||
return send_from_directory(FRONTEND_DIST, path)
|
||||
return send_file(_frontend_index_path())
|
||||
@@ -0,0 +1,30 @@
|
||||
import threading
|
||||
|
||||
|
||||
class LoginSession:
|
||||
"""全局单例登录会话(一次仅支持一个扫码流程)"""
|
||||
|
||||
def __init__(self):
|
||||
self.session_id = None
|
||||
self.sso_client = None
|
||||
self.status = "idle"
|
||||
self.cookies = None
|
||||
self.message = ""
|
||||
self.start_time = 0
|
||||
self.proxy_used = None
|
||||
self.nickname = None
|
||||
self.login_timeout = 600
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def reset(self):
|
||||
self.session_id = None
|
||||
self.sso_client = None
|
||||
self.cookies = None
|
||||
self.nickname = None
|
||||
self.start_time = 0
|
||||
self.proxy_used = None
|
||||
self.status = "idle"
|
||||
self.message = ""
|
||||
|
||||
|
||||
login_session = LoginSession()
|
||||
@@ -0,0 +1 @@
|
||||
# HTTP SSO login (no browser)
|
||||
@@ -0,0 +1,413 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from backend.config import (
|
||||
DOUYIN_AID,
|
||||
DOUYIN_REFERER,
|
||||
DOUYIN_SERVICE,
|
||||
QR_POLL_INTERVAL,
|
||||
USER_AGENT,
|
||||
)
|
||||
from backend.cookies import cookies_to_json, extract_nickname, is_logged_in, match_login_cookies
|
||||
from backend.proxy import get_proxy_from_api
|
||||
from backend.session import login_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SSO_GET_QR = "https://sso.douyin.com/get_qrcode/"
|
||||
SSO_CHECK_QR = "https://sso.douyin.com/check_qrconnect/"
|
||||
PASSPORT_GET_QR = "https://www.douyin.com/passport/web/get_qrcode/"
|
||||
PASSPORT_CHECK_QR = "https://www.douyin.com/passport/web/check_qrconnect/"
|
||||
TTWID_REGISTER = "https://ttwid.bytedance.com/ttwid/union/register/"
|
||||
USER_INFO_URL = "https://www.douyin.com/aweme/v1/web/user/profile/self/"
|
||||
|
||||
|
||||
class DouyinSSOError(Exception):
|
||||
def __init__(self, message, error_code=None):
|
||||
super().__init__(message)
|
||||
self.error_code = error_code
|
||||
|
||||
|
||||
class DouyinSSOClient:
|
||||
"""纯 HTTP 抖音扫码登录(sso.douyin.com / passport API)"""
|
||||
|
||||
def __init__(self, proxy_server: str | None = None):
|
||||
self.session = requests.Session()
|
||||
self.proxy_server = proxy_server
|
||||
if proxy_server:
|
||||
self.session.proxies = {"http": proxy_server, "https": proxy_server}
|
||||
self.token = ""
|
||||
self.qr_params: dict[str, str] = {}
|
||||
self.check_url_base = SSO_CHECK_QR
|
||||
self.api_mode = "sso"
|
||||
|
||||
def _headers(self, referer: str | None = None) -> dict[str, str]:
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Referer": referer or DOUYIN_REFERER,
|
||||
"Origin": "https://www.douyin.com",
|
||||
}
|
||||
csrf = self._cookie_value("passport_csrf_token")
|
||||
if csrf:
|
||||
headers["x-tt-passport-csrf-token"] = csrf
|
||||
return headers
|
||||
|
||||
def _cookie_value(self, name: str) -> str | None:
|
||||
for cookie in self.session.cookies:
|
||||
if cookie.name == name:
|
||||
return cookie.value
|
||||
return None
|
||||
|
||||
def init_session(self):
|
||||
payload = {
|
||||
"region": "cn",
|
||||
"aid": 1768,
|
||||
"needFid": False,
|
||||
"service": "www.douyin.com",
|
||||
"migrate_info": {"ticket": "", "source": "web"},
|
||||
"cbUrlProtocol": "https",
|
||||
"union": True,
|
||||
}
|
||||
resp = self.session.post(
|
||||
TTWID_REGISTER,
|
||||
json=payload,
|
||||
headers={"User-Agent": USER_AGENT, "Content-Type": "application/json"},
|
||||
timeout=20,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
redirect_url = data.get("redirect_url")
|
||||
if redirect_url:
|
||||
self.session.get(redirect_url, headers=self._headers(), timeout=20)
|
||||
|
||||
self.session.get(DOUYIN_REFERER, headers=self._headers(), timeout=20)
|
||||
self.session.get(
|
||||
"https://www.douyin.com/passport/web/login/",
|
||||
headers=self._headers(),
|
||||
timeout=20,
|
||||
)
|
||||
logger.info("SSO 会话初始化完成,cookies=%s", [c.name for c in self.session.cookies])
|
||||
|
||||
def _parse_api_json(self, resp: requests.Response) -> dict:
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "json" not in content_type:
|
||||
snippet = resp.text[:120].replace("\n", " ")
|
||||
raise DouyinSSOError(
|
||||
f"接口返回非 JSON(可能被风控拦截),请配置代理后重试。响应: {snippet}"
|
||||
)
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as e:
|
||||
raise DouyinSSOError(f"解析接口响应失败: {e}") from e
|
||||
|
||||
def _raise_api_error(self, data: dict, context: str):
|
||||
payload = data.get("data") or {}
|
||||
code = payload.get("error_code", data.get("error_code"))
|
||||
desc = payload.get("description") or data.get("message") or context
|
||||
if code == 4031:
|
||||
desc = "当前 IP 被抖音安全策略拦截,请配置住宅代理 API 后重试"
|
||||
raise DouyinSSOError(desc, error_code=code)
|
||||
|
||||
def fetch_qrcode(self) -> dict[str, Any]:
|
||||
ts = str(int(time.time() * 1000))
|
||||
passport_params = {
|
||||
"aid": DOUYIN_AID,
|
||||
"service": DOUYIN_SERVICE,
|
||||
"need_logo": "false",
|
||||
"is_vcd": "1",
|
||||
"t": ts,
|
||||
}
|
||||
resp = self.session.post(
|
||||
PASSPORT_GET_QR,
|
||||
params=passport_params,
|
||||
headers=self._headers(),
|
||||
timeout=20,
|
||||
)
|
||||
data = self._parse_api_json(resp)
|
||||
if data.get("message") == "success" and (data.get("data") or {}).get("token"):
|
||||
self.api_mode = "passport"
|
||||
self.check_url_base = PASSPORT_CHECK_QR
|
||||
return self._apply_qr_data(data["data"], passport_params)
|
||||
|
||||
if data.get("message") != "success":
|
||||
logger.warning("passport get_qrcode 失败 code=%s,尝试 sso 接口", (data.get("data") or {}).get("error_code"))
|
||||
|
||||
sso_params = {
|
||||
"aid": DOUYIN_AID,
|
||||
"service": DOUYIN_SERVICE,
|
||||
"is_vcd": "1",
|
||||
"t": ts,
|
||||
}
|
||||
resp = self.session.get(
|
||||
SSO_GET_QR,
|
||||
params=sso_params,
|
||||
headers={**self._headers(), "authority": "sso.douyin.com"},
|
||||
timeout=20,
|
||||
)
|
||||
data = self._parse_api_json(resp)
|
||||
if data.get("message") != "success":
|
||||
self._raise_api_error(data, "获取二维码失败")
|
||||
self.api_mode = "sso"
|
||||
self.check_url_base = SSO_CHECK_QR
|
||||
return self._apply_qr_data(data["data"], sso_params)
|
||||
|
||||
def _apply_qr_data(self, qr_data: dict, base_params: dict) -> dict[str, Any]:
|
||||
token = qr_data.get("token")
|
||||
qrcode_b64 = qr_data.get("qrcode")
|
||||
if not token or not qrcode_b64:
|
||||
raise DouyinSSOError("接口未返回 token 或二维码图片")
|
||||
|
||||
self.token = token
|
||||
self.qr_params = dict(base_params)
|
||||
return {
|
||||
"token": token,
|
||||
"qrcode_b64": qrcode_b64,
|
||||
"qrcode_index_url": qr_data.get("qrcode_index_url", ""),
|
||||
"app_name": qr_data.get("app_name", "抖音"),
|
||||
}
|
||||
|
||||
def check_qr_status(self) -> dict[str, Any]:
|
||||
if not self.token:
|
||||
raise DouyinSSOError("缺少 QR token,请重新获取二维码")
|
||||
|
||||
params = {**self.qr_params, "token": self.token, "t": str(int(time.time() * 1000))}
|
||||
resp = self.session.get(
|
||||
self.check_url_base,
|
||||
params=params,
|
||||
headers=self._headers(),
|
||||
timeout=20,
|
||||
)
|
||||
data = self._parse_api_json(resp)
|
||||
if data.get("message") != "success":
|
||||
self._raise_api_error(data, "轮询扫码状态失败")
|
||||
|
||||
qr_data = data.get("data") or {}
|
||||
status = str(qr_data.get("status", "")).lower()
|
||||
result = {
|
||||
"raw_status": status,
|
||||
"redirect_url": qr_data.get("redirect_url", ""),
|
||||
}
|
||||
|
||||
if status in ("1", "new"):
|
||||
result["phase"] = "waiting"
|
||||
result["message"] = "请使用抖音 App 扫码"
|
||||
elif status in ("2", "scanned"):
|
||||
result["phase"] = "scanned"
|
||||
result["message"] = "已扫码,请在手机上确认登录"
|
||||
elif status in ("3", "confirmed"):
|
||||
result["phase"] = "confirmed"
|
||||
result["message"] = "已确认,正在完成登录..."
|
||||
elif status in ("5", "expired"):
|
||||
result["phase"] = "expired"
|
||||
result["message"] = "二维码已过期,请重新获取"
|
||||
else:
|
||||
result["phase"] = "unknown"
|
||||
result["message"] = f"未知状态: {status}"
|
||||
|
||||
return result
|
||||
|
||||
def complete_login(self, redirect_url: str):
|
||||
if not redirect_url:
|
||||
raise DouyinSSOError("缺少 redirect_url")
|
||||
self.session.get(redirect_url, headers=self._headers(), timeout=20, allow_redirects=True)
|
||||
self.session.get(DOUYIN_REFERER, headers=self._headers(), timeout=20)
|
||||
|
||||
def get_cookies_json(self) -> list[dict]:
|
||||
return cookies_to_json(self.session.cookies)
|
||||
|
||||
def fetch_nickname(self) -> str:
|
||||
try:
|
||||
resp = self.session.get(
|
||||
USER_INFO_URL,
|
||||
params={"aid": DOUYIN_AID, "t": str(int(time.time() * 1000))},
|
||||
headers=self._headers(),
|
||||
timeout=15,
|
||||
)
|
||||
data = resp.json()
|
||||
user = (data.get("user") or data.get("data") or {})
|
||||
if user.get("nickname"):
|
||||
return user["nickname"]
|
||||
except Exception as e:
|
||||
logger.debug("获取昵称失败: %s", e)
|
||||
cookies = self.get_cookies_json()
|
||||
return extract_nickname(cookies)
|
||||
|
||||
|
||||
def _proxy_server_from_api(proxy_api: str) -> str | None:
|
||||
if not proxy_api:
|
||||
return None
|
||||
proxy = get_proxy_from_api(proxy_api)
|
||||
return proxy["server"] if proxy else None
|
||||
|
||||
|
||||
def start_qr_login(proxy_api: str = ""):
|
||||
login_session.reset()
|
||||
login_session.session_id = uuid.uuid4().hex[:12]
|
||||
login_session.status = "loading"
|
||||
login_session.message = "正在通过 SSO 接口获取二维码..."
|
||||
login_session.proxy_used = proxy_api or "默认IP"
|
||||
login_session.start_time = time.time()
|
||||
|
||||
try:
|
||||
proxy_server = _proxy_server_from_api(proxy_api)
|
||||
client = DouyinSSOClient(proxy_server=proxy_server)
|
||||
client.init_session()
|
||||
qr = client.fetch_qrcode()
|
||||
|
||||
login_session.sso_client = client
|
||||
login_session.status = "qr_ready"
|
||||
login_session.message = "请使用抖音 App 扫码"
|
||||
|
||||
img_b64 = qr["qrcode_b64"]
|
||||
if not img_b64.startswith("data:"):
|
||||
img_data_url = f"data:image/png;base64,{img_b64}"
|
||||
else:
|
||||
img_data_url = img_b64
|
||||
|
||||
return {
|
||||
"status": "qr_ready",
|
||||
"session_id": login_session.session_id,
|
||||
"qr_image": img_data_url,
|
||||
"qr_download": "/qrcode.png",
|
||||
"proxy_used": login_session.proxy_used,
|
||||
"api_mode": client.api_mode,
|
||||
"message": "二维码已通过 HTTP 接口获取,无需启动浏览器",
|
||||
}, img_b64
|
||||
except DouyinSSOError as e:
|
||||
login_session.status = "error"
|
||||
login_session.message = str(e)
|
||||
login_session.sso_client = None
|
||||
return {"status": "error", "message": str(e), "error_code": e.error_code}, None
|
||||
except Exception as e:
|
||||
logger.error("SSO 获取二维码失败: %s", e, exc_info=True)
|
||||
login_session.status = "error"
|
||||
login_session.message = str(e)
|
||||
login_session.sso_client = None
|
||||
return {"status": "error", "message": str(e)}, None
|
||||
|
||||
|
||||
def check_login_state() -> dict[str, Any]:
|
||||
if not login_session.sso_client:
|
||||
return {"status": "error", "message": "会话已失效,请重新获取二维码"}
|
||||
|
||||
if time.time() - login_session.start_time > login_session.login_timeout:
|
||||
cleanup_session()
|
||||
return {"status": "error", "message": "登录超时,请重新发起扫码"}
|
||||
|
||||
client: DouyinSSOClient = login_session.sso_client
|
||||
cookies = client.get_cookies_json()
|
||||
cookie_names = [c["name"] for c in cookies]
|
||||
login_matched = match_login_cookies(cookie_names)
|
||||
|
||||
if is_logged_in(cookies, cookie_names):
|
||||
nickname = client.fetch_nickname()
|
||||
login_session.cookies = cookies
|
||||
login_session.nickname = nickname
|
||||
login_session.status = "success"
|
||||
login_session.message = f"{nickname},登录成功!"
|
||||
login_session.sso_client = None
|
||||
logger.info("HTTP SSO 登录成功 [%s] %s", login_session.session_id, nickname)
|
||||
return {
|
||||
"status": "success",
|
||||
"session_id": login_session.session_id,
|
||||
"nickname": nickname,
|
||||
"cookies": cookies,
|
||||
"message": login_session.message,
|
||||
}
|
||||
|
||||
try:
|
||||
poll = client.check_qr_status()
|
||||
except DouyinSSOError as e:
|
||||
return {"status": "error", "message": str(e), "error_code": e.error_code}
|
||||
|
||||
phase = poll["phase"]
|
||||
if phase == "expired":
|
||||
login_session.status = "error"
|
||||
login_session.message = poll["message"]
|
||||
return {"status": "error", "message": poll["message"]}
|
||||
|
||||
if phase == "confirmed":
|
||||
try:
|
||||
client.complete_login(poll["redirect_url"])
|
||||
except Exception as e:
|
||||
logger.warning("跟随 redirect_url 失败: %s", e)
|
||||
|
||||
cookies = client.get_cookies_json()
|
||||
cookie_names = [c["name"] for c in cookies]
|
||||
if is_logged_in(cookies, cookie_names):
|
||||
nickname = client.fetch_nickname()
|
||||
login_session.cookies = cookies
|
||||
login_session.nickname = nickname
|
||||
login_session.status = "success"
|
||||
login_session.message = f"{nickname},登录成功!"
|
||||
login_session.sso_client = None
|
||||
return {
|
||||
"status": "success",
|
||||
"session_id": login_session.session_id,
|
||||
"nickname": nickname,
|
||||
"cookies": cookies,
|
||||
"message": login_session.message,
|
||||
}
|
||||
|
||||
login_session.status = "scanning"
|
||||
return {
|
||||
"status": "scanning",
|
||||
"message": "已确认,正在同步 Cookie...",
|
||||
"cookie_count": len(cookies),
|
||||
"login_matched": len(login_matched),
|
||||
}
|
||||
|
||||
if phase == "scanned":
|
||||
login_session.status = "scanning"
|
||||
return {
|
||||
"status": "scanning",
|
||||
"message": poll["message"],
|
||||
"cookie_count": len(cookies),
|
||||
"login_matched": len(login_matched),
|
||||
"qr_visible": False,
|
||||
}
|
||||
|
||||
login_session.status = "qr_ready"
|
||||
return {
|
||||
"status": "qr_ready",
|
||||
"message": poll["message"],
|
||||
"cookie_count": len(cookies),
|
||||
"login_matched": len(login_matched),
|
||||
"qr_visible": True,
|
||||
}
|
||||
|
||||
|
||||
def cleanup_session():
|
||||
login_session.sso_client = None
|
||||
|
||||
|
||||
def debug_snapshot() -> dict[str, Any]:
|
||||
client = login_session.sso_client
|
||||
result = {
|
||||
"session_id": login_session.session_id,
|
||||
"status": login_session.status,
|
||||
"api_mode": getattr(client, "api_mode", None) if client else None,
|
||||
"has_session": client is not None,
|
||||
"poll_interval": QR_POLL_INTERVAL,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
if client:
|
||||
cookies = client.get_cookies_json()
|
||||
result["cookie_count"] = len(cookies)
|
||||
result["cookie_names"] = [c["name"] for c in cookies]
|
||||
result["token_prefix"] = (client.token or "")[:8]
|
||||
try:
|
||||
poll = client.check_qr_status()
|
||||
result["qr_phase"] = poll.get("phase")
|
||||
result["qr_message"] = poll.get("message")
|
||||
except Exception as e:
|
||||
result["poll_error"] = str(e)
|
||||
elif login_session.cookies:
|
||||
result["cookie_count"] = len(login_session.cookies)
|
||||
result["cookie_names"] = [c["name"] for c in login_session.cookies]
|
||||
return result
|
||||
Reference in New Issue
Block a user