重构为 HTTP SSO 扫码方案并引入 Vue3 前端

移除 Playwright 浏览器自动化,改用 passport/SSO HTTP 接口获取二维码与轮询登录;后端模块化拆分,前端替换为 Vue3 SPA。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
travel
2026-06-25 10:47:55 +08:00
parent 853dacf528
commit 9e0644095f
33 changed files with 4792 additions and 1640 deletions
+4
View File
@@ -34,3 +34,7 @@ Thumbs.db
# --- Static generated files --- # --- Static generated files ---
static/qrcode.png static/qrcode.png
# --- Frontend ---
frontend/node_modules/
frontend/dist/
+75 -2
View File
@@ -1,3 +1,76 @@
# douyin_cookie_yunsya # 抖音 Cookie 一键提取
提取抖音cookies 扫码登录抖音后,自动导出 JSON 格式 Cookie
## 原理
采用 **HTTP SSO 接口**(无需 Playwright / 浏览器):
1. 注册 `ttwid` 设备标识
2. 调用 `passport/web/get_qrcode``sso.douyin.com/get_qrcode` 获取二维码
3. 轮询 `check_qrconnect` 检测扫码状态
4. 确认后跟随 `redirect_url` 完成会话,导出 Cookie
> 若服务器 IP 被抖音风控(错误码 4031),请配置**住宅代理 API**。
### 与其他方案对比
| 方案 | 说明 | 本项目 |
|------|------|--------|
| HTTP SSO | 逆向 passport 接口,轻量快速 | **当前采用** |
| Playwright | 模拟浏览器,重、易碎 | 已移除 |
| 开放平台 OAuth | 需注册应用,返回 access_token 非网页 Cookie | 不适用 |
## 环境要求
- Python 3.9+
- Node.js 18+(仅构建前端时需要)
## 安装
```bash
pip install -r requirements.txt
cd frontend
npm install
npm run build
```
## 运行
```bash
py -3 app.py
```
访问:http://127.0.0.1:5001
## 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | `5001` | 服务端口 |
| `LOGIN_TIMEOUT` | `600` | 登录超时(秒) |
| `QR_POLL_INTERVAL` | `2` | 建议前端轮询间隔 |
| `DOUYIN_AID` | `6383` | 抖音 Web 应用 ID |
| `DOUYIN_SERVICE` | `https://www.douyin.com` | SSO service 参数 |
## API
| 接口 | 说明 |
|------|------|
| `POST /api/start_qr` | 获取二维码(HTTP SSO |
| `GET /api/check_login` | 轮询扫码/登录状态 |
| `POST /api/reset` | 重置会话 |
| `GET /api/debug` | SSO 轮询调试信息 |
| `POST /api/test_proxy` | 测试代理 |
## 项目结构
```
app.py
backend/
sso/qr_login.py # HTTP 扫码登录核心
cookies.py # Cookie 检测
routes/ # API + SPA
frontend/ # Vue 3 前端
```
+6 -545
View File
@@ -1,550 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""抖音 Cookie 一键提取 — 入口"""
import asyncio from backend.app_factory import create_app
import base64 from backend.config import PORT
import json
import os
import time
import logging
import threading
import random
from logging.handlers import RotatingFileHandler
from io import BytesIO
from PIL import Image
import requests app = create_app()
from flask import Flask, render_template, request, jsonify, send_file
from playwright.async_api import async_playwright
# 配置日志 — 优先使用环境变量,否则使用项目相对路径 if __name__ == "__main__":
LOG_DIR = os.environ.get('LOG_DIR', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')) app.run(debug=False, host="0.0.0.0", port=PORT)
os.makedirs(LOG_DIR, exist_ok=True)
handler = RotatingFileHandler(
f'{LOG_DIR}/app.log',
maxBytes=1024*1024*10,
backupCount=5,
encoding="utf-8"
)
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
app = Flask(__name__)
# 持久化 secret_key:优先使用环境变量,否则从文件读取,都不存在则生成并保存
_secret_key_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.secret_key')
_secret_key = os.environ.get('SECRET_KEY')
if not _secret_key and os.path.exists(_secret_key_path):
with open(_secret_key_path, 'rb') as _f:
_secret_key = _f.read()
if not _secret_key:
_secret_key = os.urandom(24)
with open(_secret_key_path, 'wb') as _f:
_f.write(_secret_key)
app.secret_key = _secret_key
app.logger.addHandler(handler)
app.logger.setLevel(logging.INFO)
# 静态文件目录 — 优先使用环境变量,否则使用项目相对路径
STATIC_DIR = os.environ.get('STATIC_DIR', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static'))
os.makedirs(STATIC_DIR, exist_ok=True)
# 全局状态
class LoginSession:
def __init__(self):
self.playwright = None
self.browser = None
self.context = None
self.page = None
self.login_page = None
self.status = "idle"
self.cookies = None
self.message = ""
self.start_time = 0
self.proxy_used = None
self.lock = threading.Lock()
login_session = LoginSession()
# 每线程独立 event loop,避免 Flask 多线程下的竞态问题
_thread_local = threading.local()
def run_async_sync(coro):
"""在独立线程安全的 event loop 中运行异步协程"""
loop = getattr(_thread_local, 'loop', None)
if loop is None or loop.is_closed():
loop = asyncio.new_event_loop()
_thread_local.loop = loop
asyncio.set_event_loop(loop)
return loop.run_until_complete(coro)
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 # 指数退避: 1s, 2s, 4s
app.logger.warning(f"获取代理失败 (第{attempt+1}次): {e}{wait}s 后重试...")
time.sleep(wait)
app.logger.error(f"获取代理失败,已重试{max_retries}次: {last_error}")
return None
async def cleanup_session():
"""逐一清理浏览器资源,每步独立容错,防止单点失败导致资源泄漏"""
resources = []
if login_session.login_page:
resources.append(("login_page", login_session.login_page))
if login_session.page:
resources.append(("page", login_session.page))
if login_session.context:
resources.append(("context", login_session.context))
if login_session.browser:
resources.append(("browser", login_session.browser))
if login_session.playwright:
resources.append(("playwright", login_session.playwright))
for name, resource in resources:
try:
if name == "playwright":
await resource.stop()
else:
await resource.close()
except Exception as e:
app.logger.warning(f"清理 {name} 异常: {e}")
login_session.playwright = None
login_session.browser = None
login_session.context = None
login_session.page = None
login_session.login_page = None
login_session.status = "idle"
login_session.message = ""
async def inject_stealth(context):
await context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
window.navigator.chrome = { runtime: {} };
Object.defineProperty(navigator, 'plugins', { get: () => [] });
Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN','zh','en']});
""")
@app.route('/')
def index():
return render_template('index.html')
@app.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
@app.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 = run_async_sync(_start_qr(proxy_api))
return result
finally:
login_session.lock.release()
async def _start_qr(proxy_api):
await cleanup_session()
login_session.status = "loading"
login_session.message = "正在启动浏览器..."
login_session.proxy_used = proxy_api if proxy_api else "默认IP"
try:
login_session.playwright = await async_playwright().start()
launch_options = {
"headless": True,
"args": [
"--disable-blink-features=AutomationControlled",
"--disable-infobars",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--font-render-hinting=medium"
]
}
proxy = get_proxy_from_api(proxy_api) if proxy_api else None
if proxy:
launch_options["proxy"] = proxy
app.logger.info(f"使用代理: {proxy['server']}")
else:
app.logger.info("使用默认 IP")
login_session.browser = await login_session.playwright.chromium.launch(**launch_options)
login_session.context = await login_session.browser.new_context(
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",
viewport={"width": 1280, "height": 720},
locale="zh-CN",
timezone_id="Asia/Shanghai"
)
await inject_stealth(login_session.context)
login_session.page = await login_session.context.new_page()
async def handle_new_page(new_p):
app.logger.info("检测到新开登录页面")
login_session.login_page = new_p
await new_p.wait_for_load_state("domcontentloaded")
login_session.context.on("page", handle_new_page)
app.logger.info("访问抖音主页")
await login_session.page.goto("https://www.douyin.com/", wait_until="commit", timeout=20000)
await login_session.page.wait_for_timeout(random.uniform(1000, 2000))
page_target = login_session.page
login_btn = None
try:
login_btn = await page_target.wait_for_selector(
'button:has-text("登录"), a:has-text("登录"), div:has-text("登录")',
timeout=8000
)
except:
pass
if login_btn:
await login_btn.click()
app.logger.info("点击登录按钮成功")
else:
await page_target.evaluate('''()=>{
let els = document.querySelectorAll("*");
for(let el of els){
if(el.textContent.trim()==="登录" && el.click) {el.click(); return true;}
}
return false;
}''')
app.logger.info("JS兜底点击登录")
await login_session.page.wait_for_timeout(random.uniform(1000, 2000))
if login_session.login_page is not None:
page_target = login_session.login_page
app.logger.info("切换到新登录标签页")
await page_target.wait_for_load_state("domcontentloaded")
await page_target.wait_for_timeout(1000)
app.logger.info("等待登录弹窗...")
try:
await page_target.wait_for_selector(
'div[role="dialog"], .auth-modal, .login-box, [class*="login"], [class*="modal"]',
timeout=10000
)
except:
pass
await page_target.wait_for_timeout(1000)
# 强制点击“二维码登录”
app.logger.info("尝试切换到二维码登录...")
for attempt in range(3):
try:
qr_tab = await page_target.wait_for_selector(
'text=二维码登录, [data-e2e="qrcode-tab"], div:has-text("二维码登录")',
timeout=5000
)
if qr_tab:
await qr_tab.click()
app.logger.info(f"✅ 点击二维码登录选项卡 (第{attempt+1}次)")
await page_target.wait_for_timeout(1500)
break
except Exception as e:
app.logger.warning(f"点击二维码登录失败 {attempt+1}: {e}")
try:
await page_target.evaluate('''()=>{
let els = document.querySelectorAll("*");
for(let el of els){
if(el.textContent && el.textContent.includes("二维码登录") && el.click){
el.click();
return true;
}
}
return false;
}''')
app.logger.info("JS点击二维码登录")
await page_target.wait_for_timeout(1500)
break
except:
pass
await page_target.wait_for_timeout(1000)
app.logger.info("开始查找二维码...")
qr_img = None
max_wait = 60
start_time = time.time()
qr_selectors = [
'div[role="dialog"] img[src*="qrcode"]',
'div[role="dialog"] img[src*="passport"]',
'.auth-modal img[src*="qrcode"]',
'.login-box img[src*="qrcode"]',
'img[src*="qrcode"]',
'img[src*="passport"]',
'img[alt*="二维码"]',
'img[class*="qrcode"]',
'.qrcode-box img',
'.login-qrcode-image img',
'div[data-e2e="qrcode"] img',
'div[class*="qr"] img',
'canvas[class*="qrcode"]',
'svg[class*="qrcode"]'
]
# 前 3 次等待 DOM 稳定,后续用 500ms 快速轮询
iteration = 0
while time.time() - start_time < max_wait:
iteration += 1
if iteration <= 3:
await page_target.wait_for_load_state("domcontentloaded")
else:
await page_target.wait_for_timeout(500)
for sel in qr_selectors:
try:
elem = await page_target.query_selector(sel)
if elem:
if 'img' in sel:
src = await elem.get_attribute('src')
if src and len(src) > 10:
qr_img = elem
app.logger.info(f"✅ 找到二维码: {sel}")
break
else:
qr_img = elem
app.logger.info(f"✅ 找到二维码: {sel}")
break
except:
continue
if qr_img:
break
if int(time.time() - start_time) % 5 == 0:
app.logger.info(f"等待二维码... {int(time.time() - start_time)}s")
# ---------- 核心优化:优先提取真实 QR 图片,而非截图 ----------
if qr_img:
try:
await qr_img.wait_for_element_state("visible", timeout=3000)
except:
pass
img_bytes = None
# 策略 1:直接获取 img 元素的 src 数据(data URI 或 CDN 图片)
if 'img' in (qr_img._impl_obj._selector if hasattr(qr_img, '_impl_obj') else ''):
pass # skip complex check, just try
try:
src = await qr_img.get_attribute('src')
if src:
if src.startswith('data:image/'):
# data URI — 直接解码
app.logger.info("✅ 提取到 data URI 二维码")
header, b64 = src.split(',', 1)
img_bytes = base64.b64decode(b64)
elif src.startswith('http'):
# CDN URL — 下载原图
app.logger.info(f"✅ 下载 QR 原图: {src[:80]}...")
try:
resp = requests.get(src, timeout=10,
headers={'Referer': 'https://www.douyin.com/'})
if resp.status_code == 200 and len(resp.content) > 100:
img_bytes = resp.content
app.logger.info(f"✅ QR 原图下载成功 ({len(img_bytes)} bytes)")
except Exception as e:
app.logger.warning(f"下载 QR 原图失败: {e}")
except Exception as e:
app.logger.warning(f"获取 QR src 失败: {e}")
# 策略 2:截取二维码元素本身(仅 QR 图片区域,非整个弹窗)
if not img_bytes:
try:
img_bytes = await qr_img.screenshot()
app.logger.info(f"✅ 截取 QR 元素区域 ({len(img_bytes)} bytes)")
except Exception as e:
app.logger.warning(f"截取 QR 元素失败: {e}")
# 策略 3:截取登录弹窗 / 全屏(最后的兜底)
if not img_bytes:
try:
dialog = await page_target.query_selector(
'div[role="dialog"], .auth-modal, .login-box, [class*="modal"]')
if dialog:
img_bytes = await dialog.screenshot()
app.logger.info("⚠ 使用弹窗截图兜底")
else:
img_bytes = await page_target.screenshot(full_page=True)
app.logger.info("⚠ 使用全屏截图兜底")
except Exception as e:
app.logger.warning(f"弹窗截图失败: {e}")
img_bytes = await page_target.screenshot(full_page=True)
# -------------------------------------
# 保存到 static
qr_file_path = os.path.join(STATIC_DIR, 'qrcode.png')
with open(qr_file_path, 'wb') as f:
f.write(img_bytes)
app.logger.info(f"二维码已保存至 {qr_file_path} ({len(img_bytes)} bytes)")
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
login_session.status = "qr_ready"
login_session.message = "请使用抖音 App 扫码"
login_session.start_time = time.time()
return jsonify({
"status": "qr_ready",
"qr_image": f"data:image/png;base64,{img_base64}",
"proxy_used": login_session.proxy_used,
"qr_download": "/qrcode.png"
})
else:
app.logger.error(f"{max_wait}秒未检测到二维码,返回全屏截图")
img_bytes = await page_target.screenshot(full_page=True)
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
login_session.status = "qr_ready"
login_session.message = "未检测到二维码,全屏截图请查看"
login_session.start_time = time.time()
return jsonify({
"status": "qr_ready",
"qr_image": f"data:image/png;base64,{img_base64}",
"proxy_used": login_session.proxy_used
})
except Exception as e:
app.logger.error(f"启动扫码失败: {e}", exc_info=True)
login_session.status = "error"
login_session.message = str(e)
await cleanup_session()
return jsonify({"status": "error", "message": str(e)})
@app.route('/api/check_login', methods=['GET'])
def check_login():
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 time.time() - login_session.start_time > 300:
run_async_sync(cleanup_session())
return jsonify({"status": "error", "message": "登录超时,请重新发起扫码"})
try:
if not login_session.context:
run_async_sync(cleanup_session())
return jsonify({"status": "error", "message": "会话已失效"})
cookies = run_async_sync(login_session.context.cookies())
app.logger.info(f"当前Cookie数量: {len(cookies)}")
cookie_names = [c['name'] for c in cookies]
need_keys = {"sessionid_ss", "sessionid", "sid_guard", "uid_tt", "uid_tt_ss"}
# 至少匹配 2 个关键字段才算登录成功,降低误判概率
matched = sum(1 for k in need_keys if k in cookie_names)
has_valid = matched >= 2
if has_valid:
login_session.cookies = cookies
login_session.status = "success"
login_session.message = "登录成功"
app.logger.info(f"✅ 登录成功,获取有效Cookie {len(cookies)}")
run_async_sync(cleanup_session())
return jsonify({
"status": "success",
"cookies": cookies,
"message": "Cookie 提取成功"
})
else:
return jsonify({"status": "scanning", "message": "等待手机抖音确认登录..."})
except Exception as e:
app.logger.error(f"检查登录异常: {e}", exc_info=True)
return jsonify({"status": "error", "message": str(e)})
@app.route('/api/test_proxy', methods=['POST'])
def test_proxy():
data = request.get_json()
proxy_api = data.get('proxy_api', '').strip()
if not proxy_api:
return jsonify({"status": "error", "message": "请提供代理 API"})
try:
proxy = get_proxy_from_api(proxy_api, timeout=5)
if not proxy:
return jsonify({"status": "error", "message": "获取代理失败"})
proxies = {
'http': proxy['server'],
'https': proxy['server']
}
resp = requests.get('http://httpbin.org/ip', proxies=proxies, timeout=10)
if resp.status_code == 200:
data = resp.json()
return jsonify({
"status": "success",
"ip": data.get('origin', 'unknown'),
"message": "代理可用"
})
else:
return jsonify({"status": "error", "message": f"代理响应异常: {resp.status_code}"})
except Exception as e:
return jsonify({"status": "error", "message": str(e)})
@app.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)"
return jsonify({
"status": login_session.status,
"message": login_session.message + elapsed,
"proxy_used": login_session.proxy_used
})
@app.route('/api/reset', methods=['POST'])
def api_reset():
"""强制重置当前会话"""
try:
run_async_sync(cleanup_session())
except Exception as e:
app.logger.warning(f"重置会话异常: {e}")
login_session.status = "idle"
login_session.message = "会话已重置"
login_session.cookies = None
login_session.start_time = 0
login_session.proxy_used = None
return jsonify({"status": "success", "message": "会话已重置"})
@app.route('/api/health', methods=['GET'])
def api_health():
"""健康检查端点,用于生产环境监控"""
return jsonify({
"status": "ok",
"session_status": login_session.status,
"timestamp": time.time()
})
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5001)
+1
View File
@@ -0,0 +1 @@
# Douyin Cookie Extractor backend package
+44
View File
@@ -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
+22
View File
@@ -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"
)
+59
View File
@@ -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
+64
View File
@@ -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}"}
+1
View File
@@ -0,0 +1 @@
# API routes package
+139
View File
@@ -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
+55
View File
@@ -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())
+30
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# HTTP SSO login (no browser)
+413
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1890ff" />
<meta name="color-scheme" content="light" />
<title>抖音 Cookie 提取器</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1717
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "douyin-cookie-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>
+26
View File
@@ -0,0 +1,26 @@
import axios from 'axios'
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE || '',
headers: { 'Content-Type': 'application/json' },
timeout: 120000,
})
export async function post(path, body = {}, axiosConfig = {}) {
const { data } = await http.post(path, body, axiosConfig)
return data
}
export async function get(path, params = {}, axiosConfig = {}) {
const { data } = await http.get(path, { ...axiosConfig, params })
return data
}
export function formatApiError(err, fallback = '请求失败') {
const d = err?.response?.data
if (typeof d?.message === 'string' && d.message.trim()) return d.message
if (typeof d === 'string' && d.trim()) return d
return err?.message || fallback
}
export default http
+349
View File
@@ -0,0 +1,349 @@
<template>
<div class="layout-cloud" :class="{ 'is-nav-open': sidebarOpen }">
<div class="nav-backdrop" aria-hidden="true" @click="sidebarOpen = false" />
<aside class="sidebar" role="navigation" :aria-hidden="desktopNav ? undefined : !sidebarOpen">
<div class="sidebar-brand">
<button type="button" class="sidebar-close" aria-label="关闭菜单" @click="sidebarOpen = false">
×
</button>
<div class="brand-title">抖音 Cookie 提取</div>
<div class="brand-sub">扫码登录 · JSON 导出</div>
</div>
<nav class="sidebar-nav">
<router-link
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="nav-item"
exact-active-class="nav-item-active"
@click="onNavClick"
>
{{ item.label }}
</router-link>
</nav>
<div class="sidebar-footer">
<button type="button" class="btn btn-soft full-width" @click="refreshAll">刷新状态</button>
</div>
</aside>
<div class="main-wrap">
<header class="mobile-topbar">
<button
type="button"
class="nav-toggle"
aria-label="打开菜单"
:aria-expanded="sidebarOpen"
@click="sidebarOpen = true"
>
<span class="nav-toggle-bar"></span>
<span class="nav-toggle-bar"></span>
<span class="nav-toggle-bar"></span>
</button>
<span class="mobile-title">Cookie 提取</span>
</header>
<main class="main-inner">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const sidebarOpen = ref(false)
const desktopNav = ref(true)
const navItems = [
{ name: 'home', label: '扫码提取' },
{ name: 'debug', label: 'SSO 调试' },
]
let mq
function syncMq() {
desktopNav.value = typeof window !== 'undefined' && window.matchMedia('(min-width: 900px)').matches
if (desktopNav.value) sidebarOpen.value = false
}
function refreshAll() {
window.dispatchEvent(new CustomEvent('douyin-refresh-all'))
}
function onNavClick() {
if (!desktopNav.value) sidebarOpen.value = false
}
function onKeydown(e) {
if (e.key === 'Escape' && sidebarOpen.value) sidebarOpen.value = false
}
watch(
() => route.fullPath,
() => {
if (!desktopNav.value) sidebarOpen.value = false
},
)
watch(sidebarOpen, (open) => {
if (typeof document === 'undefined') return
document.body.style.overflow = open && !desktopNav.value ? 'hidden' : ''
})
onMounted(() => {
syncMq()
mq = window.matchMedia('(min-width: 900px)')
mq.addEventListener('change', syncMq)
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
if (mq) mq.removeEventListener('change', syncMq)
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
</script>
<style scoped>
.layout-cloud {
display: flex;
min-height: 100vh;
min-height: 100dvh;
height: 100vh;
height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
overflow: hidden;
}
.nav-backdrop {
display: none;
}
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
align-self: stretch;
background: var(--sidebar-bg);
border-right: 1px solid var(--border-light);
display: flex;
flex-direction: column;
box-shadow: 1px 0 0 rgba(0, 0, 0, 0.04);
min-height: 0;
}
.sidebar-brand {
position: relative;
flex-shrink: 0;
padding: 1rem 0.75rem 0.85rem;
border-bottom: 1px solid var(--border-light);
}
.sidebar-close {
display: none;
position: absolute;
top: 0.75rem;
right: 0.65rem;
width: var(--tap-min, 44px);
height: var(--tap-min, 44px);
margin: 0;
padding: 0;
border: none;
background: #f5f7fa;
border-radius: var(--radius-sm);
font-size: 1.5rem;
line-height: 1;
color: var(--muted);
cursor: pointer;
}
.brand-title {
font-size: 1.05rem;
font-weight: 700;
color: var(--text-strong);
letter-spacing: -0.02em;
padding-right: 2.5rem;
}
.brand-sub {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.25rem;
}
.sidebar-nav {
flex: 1;
min-height: 0;
padding: 0.55rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
min-height: var(--tap-min, 44px);
padding: 0.45rem 0.6rem;
border-radius: var(--radius-sm);
color: var(--text-strong);
font-size: 0.875rem;
border: 1px solid #e8e8e8;
background: #ffffff;
text-decoration: none;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.nav-item:hover {
background: #f7f8fa;
border-color: #dcdfe6;
}
.nav-item-active {
background: #eef5fe !important;
color: #3b82f6 !important;
font-weight: 600;
border-color: #bfdbfe !important;
}
.sidebar-footer {
flex-shrink: 0;
padding: 0.55rem 0.5rem;
border-top: 1px solid var(--border-light);
padding-bottom: max(0.55rem, env(safe-area-inset-bottom));
}
.full-width {
width: 100%;
}
.sidebar-footer .btn-soft {
background: #f5f5f5;
border-color: #e8e8e8;
color: #303133;
}
.mobile-topbar {
display: none;
}
.main-wrap {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.main-inner {
flex: 1;
width: 100%;
min-width: 0;
min-height: 0;
box-sizing: border-box;
padding-top: var(--main-pad-y);
padding-bottom: max(var(--main-pad-y), env(safe-area-inset-bottom));
padding-left: var(--main-pad-start);
padding-right: var(--main-pad-end);
overflow: auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.12s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 899px) {
.sidebar-close {
display: block;
}
.nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 250;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s;
}
.layout-cloud.is-nav-open .nav-backdrop {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(260px, 86vw);
z-index: 300;
transform: translateX(-100%);
transition: transform 0.22s ease;
}
.layout-cloud.is-nav-open .sidebar {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.12);
}
.mobile-topbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
padding: 0.5rem var(--main-pad-end, var(--main-pad-x));
padding-top: max(0.5rem, env(safe-area-inset-top));
background: var(--surface);
border-bottom: 1px solid var(--border-light);
position: sticky;
top: 0;
z-index: 100;
}
.nav-toggle {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
width: var(--tap-min, 44px);
height: var(--tap-min, 44px);
padding: 0 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
cursor: pointer;
}
.nav-toggle-bar {
display: block;
height: 2px;
background: var(--text-strong);
border-radius: 1px;
}
.mobile-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-strong);
}
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<transition name="toast-slide">
<div v-if="visible" class="toast-host" :class="{ 'toast-host--err': isError }" role="status">
<span class="toast-icon">{{ isError ? '✕' : '✓' }}</span>
<span class="toast-msg">{{ message }}</span>
</div>
</transition>
</template>
<script setup>
defineProps({
visible: { type: Boolean, default: false },
message: { type: String, default: '' },
isError: { type: Boolean, default: false },
})
</script>
<style scoped>
.toast-host {
position: fixed;
bottom: max(1.25rem, env(safe-area-inset-bottom));
right: max(1.25rem, env(safe-area-inset-right));
z-index: 2000;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1.1rem;
background: var(--surface);
border: 1px solid var(--border-light);
border-left: 4px solid var(--success);
border-radius: var(--radius-sm);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.1);
font-size: 0.875rem;
color: var(--text-strong);
max-width: min(360px, calc(100vw - 2rem));
}
.toast-host--err {
border-left-color: var(--danger);
}
.toast-icon {
font-weight: 700;
color: var(--success);
}
.toast-host--err .toast-icon {
color: var(--danger);
}
.toast-slide-enter-active,
.toast-slide-leave-active {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.toast-slide-enter-from,
.toast-slide-leave-to {
transform: translateX(120%);
opacity: 0;
}
</style>
+60
View File
@@ -0,0 +1,60 @@
import { ref, onUnmounted } from 'vue'
/**
* 自适应间隔轮询:tick 返回后按 getInterval() 调度下一次
* @param {() => Promise<void>|void} tickFn
* @param {() => number} getIntervalMs
*/
export function useLivePoll(tickFn, getIntervalMs = () => 1000) {
const lastTickAt = ref(null)
const ticking = ref(false)
let timer = null
let inFlight = false
let running = false
async function runTick() {
if (inFlight) return
inFlight = true
ticking.value = true
try {
await tickFn()
lastTickAt.value = Date.now()
} catch (e) {
console.error('[livePoll]', e)
} finally {
inFlight = false
ticking.value = false
}
}
function scheduleNext() {
if (!running) return
clearTimeout(timer)
const ms = Math.max(500, getIntervalMs())
timer = setTimeout(async () => {
await runTick()
scheduleNext()
}, ms)
}
function start() {
if (running) return
running = true
runTick().then(scheduleNext)
}
function stop() {
running = false
clearTimeout(timer)
timer = null
}
function restart() {
stop()
start()
}
onUnmounted(stop)
return { start, stop, restart, runTick, lastTickAt, ticking }
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).use(router).mount('#app')
+23
View File
@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import LayoutShell from '../components/LayoutShell.vue'
const HomeView = () => import('../views/HomeView.vue')
const DebugView = () => import('../views/DebugView.vue')
const routes = [
{
path: '/',
component: LayoutShell,
children: [
{ path: '', name: 'home', component: HomeView },
{ path: 'debug', name: 'debug', component: DebugView },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
+471
View File
@@ -0,0 +1,471 @@
/* Telegram Session — 浅色云端后台 · 双端适配(对齐 Ant Design / 常见后台布局) */
:root {
--bg-page: #f0f2f5;
--surface: #ffffff;
--sidebar-bg: #ffffff;
--border: #e4e7ed;
--border-light: #ebeef5;
--text: #303133;
--text-strong: #1d2129;
--muted: #909399;
--muted-light: #c0c4cc;
--primary: #1890ff;
--primary-light: #e6f4ff;
--primary-hover: #40a9ff;
--success: #52c41a;
--success-bg: #f6ffed;
--danger: #ff4d4f;
--danger-hover: #ff7875;
--warn: #faad14;
--info-banner-bg: #e6fffb;
--info-banner-border: #87e8de;
--info-banner-text: #006d75;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.04);
--radius: 12px;
--radius-sm: 8px;
/* 参考常见云端后台:侧栏略窄、主区靠侧栏一侧内边距更小,减少「缝隙」感 */
--sidebar-width: 208px;
--layout-break: 900px;
--tap-min: 44px;
--main-pad-y: 0.75rem;
--main-pad-start: 0.2rem;
--main-pad-end: 0.85rem;
--main-pad-x: 0.85rem;
--section-gap: 0.65rem;
/* 主内容铺满可用宽度,避免 max-width 居中造成侧栏与卡片之间大块留白 */
--content-max-width: none;
}
@media (max-width: 899px) {
:root {
--main-pad-start: 0.75rem;
--main-pad-end: 0.75rem;
--main-pad-y: 0.7rem;
--main-pad-x: 0.75rem;
}
}
/* 仪表盘 / 设置 / 账号等业务页根容器(与主区同宽,不额外居中收窄) */
.page-cloud {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
max-width: var(--content-max-width);
margin-left: 0;
margin-right: 0;
box-sizing: border-box;
}
/* 参考云端后台:主工作区单一大卡片,内容贴齐四边、减少「侧栏外一块空白」的松散感 */
.cloud-workspace {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 0.85rem 1rem;
box-shadow: var(--shadow-sm);
margin-bottom: var(--section-gap);
}
.cloud-workspace > .page-head:first-child {
margin-bottom: 0.6rem;
}
.cloud-workspace .stats-row {
margin-bottom: 0.6rem;
}
.cloud-workspace > .filter-card:last-child {
margin-bottom: 0;
}
@media (max-width: 599px) {
.cloud-workspace {
padding: 0.75rem 0.85rem;
border-radius: var(--radius-sm);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Segoe UI", system-ui, -apple-system, "PingFang SC", "Microsoft YaHei",
sans-serif;
background: var(--bg-page);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
min-height: 100dvh;
}
#app {
min-height: 100vh;
min-height: 100dvh;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--primary-hover);
}
@media (hover: hover) and (pointer: fine) {
a:focus-visible,
button:focus-visible,
.nav-item:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
}
/* 按钮 · 触控端加大可点区域 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem 1rem;
min-height: 2.25rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.btn-primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: #fff;
}
.btn-primary:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
}
.btn-danger:hover {
background: var(--danger-hover);
border-color: var(--danger-hover);
color: #fff;
}
.btn-ghost {
background: transparent;
}
.btn-soft {
background: var(--primary-light);
border-color: #91d5ff;
color: #096dd9;
}
.btn-soft:hover {
background: #bae0ff;
border-color: var(--primary);
color: #0050b3;
}
.btn-sm {
padding: 0.35rem 0.65rem;
font-size: 0.8125rem;
min-height: 2rem;
}
@media (max-width: 899px) {
.btn:not(.btn-sm) {
min-height: var(--tap-min);
padding-left: 1.1rem;
padding-right: 1.1rem;
}
}
/* 表单 */
input,
select,
textarea {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius-sm);
padding: 0.55rem 0.75rem;
font-size: 1rem;
-webkit-tap-highlight-color: transparent;
}
@media (min-width: 900px) {
input,
select,
textarea {
font-size: 0.875rem;
}
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.12);
}
@media (max-width: 899px) {
input:not([type="checkbox"]):not([type="radio"]),
select,
textarea {
min-height: var(--tap-min);
}
}
/* 卡片 */
.card {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 0.85rem 1rem;
box-shadow: var(--shadow-sm);
}
@media (max-width: 599px) {
.card {
padding: 0.75rem 0.85rem;
border-radius: var(--radius-sm);
}
}
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th,
td {
text-align: left;
padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--border-light);
}
th {
color: var(--muted);
font-weight: 600;
background: #fafafa;
}
@media (hover: hover) {
tr:hover td {
background: #fafafa;
}
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-ok {
background: rgba(82, 196, 26, 0.12);
color: var(--success);
}
.badge-off {
background: #fef0f0;
color: var(--danger);
}
.badge-info {
background: var(--primary-light);
color: var(--primary);
}
.badge-twofa-yes {
background: rgba(82, 196, 26, 0.12);
color: var(--success);
}
.badge-twofa-no {
background: #f5f5f5;
color: var(--muted);
}
/* 概览数字卡片(仪表盘 / 账号页共用) */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.5rem;
margin-bottom: var(--section-gap);
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 0.6rem 0.75rem;
box-shadow: var(--shadow-sm);
}
.stat-label {
font-size: 0.72rem;
color: var(--muted);
margin-bottom: 0.2rem;
}
.stat-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-strong);
line-height: 1.2;
}
.stat-success .stat-value {
color: var(--success);
}
.stat-warn .stat-value {
color: var(--danger);
}
.stat-accent .stat-value {
color: var(--warn);
}
.stat-muted .stat-value {
color: var(--muted);
font-size: 1.15rem;
}
@media (max-width: 599px) {
.stats-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat-value {
font-size: 1.25rem;
}
}
/* 提示条(说明/信息) */
.info-strip {
border-radius: var(--radius-sm);
padding: 0.45rem 0.65rem;
font-size: 0.8rem;
line-height: 1.45;
margin-bottom: var(--section-gap);
}
.info-strip--teal {
background: var(--info-banner-bg);
border: 1px solid var(--info-banner-border);
color: var(--info-banner-text);
}
.info-strip--primary {
background: var(--primary-light);
border: 1px solid #bae0ff;
color: #0958d9;
}
/* 弹层 */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
.modal {
background: var(--surface);
border: 1px solid var(--border-light);
border-radius: var(--radius);
max-width: 480px;
width: 100%;
max-height: min(90vh, 100dvh - 2rem);
overflow-y: auto;
padding: 1.5rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08), 0 0 1px rgba(0, 0, 0, 0.06);
-webkit-overflow-scrolling: touch;
}
.modal.modal-elevated {
padding: 0;
}
.modal-elevated .modal-hd {
padding: 1.25rem 1.35rem 1rem;
border-bottom: 1px solid var(--border-light);
}
.modal-elevated .modal-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-strong);
}
.modal-elevated .modal-sub {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
color: var(--muted);
font-weight: 400;
}
.modal-elevated .modal-body {
padding: 1.25rem 1.35rem 1rem;
}
.modal-elevated .modal-ft {
padding: 0.85rem 1.35rem 1.25rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
border-top: 1px solid var(--border-light);
background: #fafafa;
border-radius: 0 0 var(--radius) var(--radius);
}
.field-label {
display: block;
font-size: 0.78rem;
font-weight: 500;
color: var(--muted);
margin-bottom: 0.35rem;
}
.modal .field-block {
margin-bottom: 1rem;
}
.modal .field-block:last-of-type {
margin-bottom: 0.35rem;
}
.modal-input {
width: 100%;
}
.code-pill {
display: inline-block;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
background: #f5f5f5;
border: 1px solid var(--border-light);
padding: 0.35rem 0.55rem;
border-radius: 6px;
color: var(--text-strong);
word-break: break-all;
}
+277
View File
@@ -0,0 +1,277 @@
<template>
<div class="page-cloud">
<header class="page-head">
<h1 class="page-title">SSO 调试</h1>
<p class="page-desc">查看 HTTP 扫码轮询状态与 Cookie 采集进度</p>
</header>
<div v-if="err" class="card banner-err">{{ err }}</div>
<div class="cloud-workspace">
<div class="toolbar">
<button type="button" class="btn btn-primary" :disabled="loading" @click="refresh">
{{ loading ? '刷新中' : '立即刷新' }}
</button>
<button type="button" class="btn" :class="autoOn ? 'btn-soft' : ''" @click="toggleAuto">
{{ autoOn ? '停止实时刷新' : '开启实时刷新' }}
</button>
<span class="live-tag" v-if="autoOn"><span class="live-pulse"></span>实时</span>
<span class="auto-hint">{{ lastRefreshText }}</span>
</div>
<div class="debug-grid">
<section class="card">
<h3 class="col-title">会话信息</h3>
<dl class="info-dl">
<template v-for="row in infoRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd :class="row.class">{{ row.value }}</dd>
</template>
</dl>
</section>
<section class="card">
<h3 class="col-title">扫码轮询</h3>
<div v-if="qrMessage" class="poll-box">{{ qrMessage }}</div>
<div v-else class="shot-placeholder">暂无活跃扫码会话</div>
<p v-if="pollError" class="poll-error">{{ pollError }}</p>
</section>
<section class="card cookie-card">
<h3 class="col-title">Cookie 列表 ({{ cookieCount }})</h3>
<div class="cookie-list">
<div v-if="!cookieNames.length" class="empty-hint">暂无 Cookie</div>
<div v-for="name in cookieNames" :key="name" class="cookie-row">
<span class="mono">{{ name }}</span>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { get, formatApiError } from '../api/http.js'
import { useLivePoll } from '../composables/useLivePoll.js'
const loading = ref(false)
const err = ref('')
const data = ref(null)
const autoOn = ref(true)
const now = ref(Date.now())
let clockTimer = null
const cookieCount = computed(() => data.value?.cookie_count || 0)
const cookieNames = computed(() => data.value?.cookie_names || [])
const qrMessage = computed(() => data.value?.qr_message || '')
const pollError = computed(() => data.value?.poll_error || '')
const lastRefreshText = computed(() => {
if (!livePoll.lastTickAt.value) return '等待刷新…'
const sec = Math.max(0, Math.floor((now.value - livePoll.lastTickAt.value) / 1000))
if (sec === 0) return '刚刚更新'
if (sec < 60) return `${sec}s 前更新`
return new Date(livePoll.lastTickAt.value).toLocaleTimeString()
})
const infoRows = computed(() => {
const d = data.value || {}
const rows = [
{ label: '会话 ID', value: d.session_id || '无' },
{ label: '状态', value: d.status || '—' },
{ label: '接口模式', value: d.api_mode || '—' },
{ label: '扫码阶段', value: d.qr_phase || '—' },
{ label: 'Token', value: d.token_prefix ? `${d.token_prefix}` : '无' },
{ label: 'HTTP 会话', value: d.has_session ? '活跃' : '未启动' },
]
if (d.poll_error) {
rows.push({ label: '轮询错误', value: d.poll_error, class: 'text-danger' })
}
return rows
})
async function refresh() {
loading.value = true
err.value = ''
try {
data.value = await get('/api/debug')
} catch (e) {
err.value = formatApiError(e, '请求失败')
} finally {
loading.value = false
}
}
const livePoll = useLivePoll(refresh, () => (autoOn.value ? 1500 : 60000))
function toggleAuto() {
autoOn.value = !autoOn.value
livePoll.restart()
}
function onGlobalRefresh() {
livePoll.runTick()
}
onMounted(() => {
livePoll.start()
clockTimer = setInterval(() => {
now.value = Date.now()
}, 1000)
window.addEventListener('douyin-refresh-all', onGlobalRefresh)
})
onUnmounted(() => {
livePoll.stop()
if (clockTimer) clearInterval(clockTimer)
window.removeEventListener('douyin-refresh-all', onGlobalRefresh)
})
</script>
<style scoped>
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-strong);
}
.page-desc {
margin: 0.35rem 0 0;
font-size: 0.875rem;
color: var(--muted);
}
.page-head {
margin-bottom: 0.75rem;
}
.banner-err {
margin-bottom: 0.65rem;
color: var(--danger);
font-size: 0.875rem;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.65rem;
}
.auto-hint {
font-size: 0.78rem;
color: var(--muted);
margin-left: auto;
}
.live-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1rem 0.4rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--success);
background: var(--success-bg);
border-radius: 4px;
}
.live-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
animation: pulse 1.2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.45;
transform: scale(0.85);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
.debug-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.65rem;
}
.col-title {
margin: 0 0 0.65rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-strong);
}
.info-dl {
margin: 0;
display: grid;
grid-template-columns: 100px 1fr;
gap: 0.35rem 0.5rem;
font-size: 0.82rem;
}
.info-dl dt {
color: var(--muted);
}
.info-dl dd {
margin: 0;
word-break: break-all;
}
.text-danger {
color: var(--danger);
}
.poll-box {
padding: 0.75rem;
background: #fafbfc;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
font-size: 0.85rem;
line-height: 1.5;
}
.poll-error {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--danger);
}
.shot-placeholder,
.empty-hint {
padding: 2rem 1rem;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
background: #fafbfc;
border: 1px dashed var(--border-light);
border-radius: var(--radius-sm);
}
.cookie-list {
max-height: 320px;
overflow: auto;
}
.cookie-row {
padding: 0.35rem 0;
border-bottom: 1px solid var(--border-light);
font-size: 0.8rem;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
</style>
+657
View File
@@ -0,0 +1,657 @@
<template>
<div class="page-cloud">
<ToastMessage :visible="toast.show" :message="toast.msg" :is-error="toast.isError" />
<div class="cloud-workspace">
<header class="page-head">
<h1 class="page-title">扫码提取</h1>
<p class="page-desc">使用抖音 App 扫码登录一键导出 JSON 格式 Cookie</p>
</header>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">提取次数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card stat-success">
<div class="stat-label">成功</div>
<div class="stat-value">{{ stats.success }}</div>
</div>
<div class="stat-card stat-accent">
<div class="stat-label">平均耗时</div>
<div class="stat-value">{{ avgTime }}</div>
</div>
<div class="stat-card stat-muted">
<div class="stat-label">成功率</div>
<div class="stat-value">{{ successRate }}</div>
</div>
</div>
<div class="status-panel">
<div class="status-top">
<span class="status-badge" :class="'status-badge--' + sessionStatus">
<span class="status-dot" :class="'status-dot--' + sessionStatus"></span>
{{ statusLabel }}
<span v-if="isLiveSession" class="live-tag">
<span class="live-pulse"></span>实时
</span>
</span>
<span class="status-time" :title="lastRefreshTitle">{{ lastRefreshText }}</span>
</div>
<p class="status-msg">{{ statusText }}</p>
</div>
<div class="info-strip info-strip--primary">
通过抖音官方 SSO HTTP 接口获取二维码无需启动浏览器若提示 IP 被拦截请配置住宅代理 API 后重试
</div>
<div class="work-grid">
<section class="work-main card">
<div class="action-row">
<button type="button" class="btn btn-primary btn-lg" :disabled="loading" @click="startQR">
{{ loading ? '启动中' : '获取二维码' }}
</button>
<button type="button" class="btn" title="重置会话" :disabled="loading" @click="resetSession">
重置
</button>
</div>
<div v-if="qrImage" class="qr-block">
<div class="qr-frame" :class="{ scanning }">
<img class="qr-img" :src="qrImage" alt="登录二维码" />
</div>
<span class="badge" :class="badgeClass">{{ badgeText }}</span>
<a class="qr-dl" href="/qrcode.png" target="_blank" rel="noopener">下载二维码图片</a>
</div>
<div v-if="apiMode" class="api-mode-tag">接口模式{{ apiMode }}</div>
<div v-if="cookies" class="cookie-block">
<div class="cookie-head">
<h3 class="col-title">Cookie 结果</h3>
<button type="button" class="btn btn-sm" :class="copied ? 'btn-soft' : 'btn-primary'" @click="copyCookies">
{{ copied ? '已复制' : '复制 JSON' }}
</button>
</div>
<pre class="cookie-pre">{{ cookieJson }}</pre>
</div>
</section>
<aside class="work-side card">
<h3 class="col-title">使用说明</h3>
<ol class="steps-list">
<li>点击获取二维码HTTP 接口</li>
<li>用抖音 App 扫描页面二维码</li>
<li>手机确认登录</li>
<li>页面自动轮询并完成 Cookie 导出</li>
</ol>
<h3 class="col-title side-gap">代理配置</h3>
<p class="field-label">代理 API选填</p>
<input v-model="proxyApi" class="inp full" placeholder="https://api.example.com/proxy" autocomplete="off" />
<p class="proxy-hint">{{ proxyApi ? '已配置自定义代理' : '当前:服务器默认 IP' }}</p>
<button type="button" class="btn btn-soft full" :disabled="testingProxy" @click="testProxy">
{{ testingProxy ? '测试中' : '测试代理' }}
</button>
</aside>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { get, post, formatApiError } from '../api/http.js'
import { useLivePoll } from '../composables/useLivePoll.js'
import ToastMessage from '../components/ToastMessage.vue'
const ACTIVE_SESSION = new Set(['loading', 'qr_ready', 'scanning'])
const loading = ref(false)
const scanning = ref(false)
const qrImage = ref('')
const cookies = ref(null)
const copied = ref(false)
const proxyApi = ref('')
const testingProxy = ref(false)
const error = ref('')
const stats = reactive({
total: parseInt(localStorage.getItem('ck_total') || '0', 10),
success: parseInt(localStorage.getItem('ck_ok') || '0', 10),
totalTime: parseInt(localStorage.getItem('ck_time') || '0', 10),
})
const toast = reactive({ show: false, msg: '', isError: false })
let toastTimer = null
const sessionId = ref('')
const sessionStatus = ref('idle')
const sessionMsg = ref('')
const nickname = ref('')
let startTime = 0
const cookieCount = ref(0)
const loginMatched = ref(0)
const loginKeysFound = ref([])
const apiMode = ref('')
const now = ref(Date.now())
let clockTimer = null
const isLiveSession = computed(() => ACTIVE_SESSION.has(sessionStatus.value))
const livePoll = useLivePoll(liveTick, () => (isLiveSession.value ? 1000 : 3000))
const lastRefreshText = computed(() => {
if (!livePoll.lastTickAt.value) return now.value && new Date(now.value).toLocaleTimeString()
const sec = Math.max(0, Math.floor((now.value - livePoll.lastTickAt.value) / 1000))
if (sec === 0) return '刚刚更新'
if (sec < 60) return `${sec}s 前更新`
return new Date(livePoll.lastTickAt.value).toLocaleTimeString()
})
const lastRefreshTitle = computed(() => {
if (!livePoll.lastTickAt.value) return '等待首次刷新'
return `上次刷新: ${new Date(livePoll.lastTickAt.value).toLocaleString()}`
})
const statusLabel = computed(() => {
const m = {
idle: '就绪',
loading: '启动中',
scanning: '扫码中',
qr_ready: '二维码已就绪',
success: '成功',
error: '错误',
}
return m[sessionStatus.value] || '就绪'
})
const statusText = computed(() => {
if (error.value) return error.value
if (nickname.value && sessionStatus.value === 'success') {
return `${nickname.value},登录成功!`
}
if (sessionStatus.value === 'scanning') {
const keys = loginKeysFound.value.length ? ` [${loginKeysFound.value.join(', ')}]` : ''
return `已在手机扫码,等待确认… Cookie ${cookieCount.value} 个,登录匹配 ${loginMatched.value}${keys}`
}
if (sessionStatus.value === 'qr_ready') {
return '请使用抖音 App 扫描二维码'
}
return sessionMsg.value || '点击「获取二维码」开始'
})
const avgTime = computed(() => {
if (stats.success === 0) return '—'
return `${Math.round(stats.totalTime / stats.success)}s`
})
const successRate = computed(() => {
if (stats.total === 0) return '0%'
return `${Math.round((stats.success / stats.total) * 100)}%`
})
const cookieJson = computed(() => (cookies.value ? JSON.stringify(cookies.value, null, 2) : ''))
const badgeText = computed(() => {
if (cookies.value) return '登录成功'
if (sessionStatus.value === 'scanning') return '等待手机确认'
if (sessionStatus.value === 'qr_ready') return '请使用抖音扫码'
return '请获取二维码'
})
const badgeClass = computed(() => {
if (cookies.value) return 'badge-ok'
if (sessionStatus.value === 'scanning') return 'badge-info'
return 'badge-info'
})
function showToast(msg, isErr = false) {
toast.msg = msg
toast.isError = isErr
toast.show = true
clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toast.show = false
}, 4000)
}
function saveStats() {
localStorage.setItem('ck_total', String(stats.total))
localStorage.setItem('ck_ok', String(stats.success))
localStorage.setItem('ck_time', String(stats.totalTime))
}
async function liveTick() {
await pollStatus()
if (ACTIVE_SESSION.has(sessionStatus.value) || sessionId.value) {
await checkLogin()
}
}
function applyPollMeta(data) {
if (data.api_mode) apiMode.value = data.api_mode
}
async function pollStatus() {
try {
const data = await get('/api/status')
sessionMsg.value = data.message || ''
applyPollMeta(data)
if (data.status === 'error') {
error.value = data.message || ''
sessionStatus.value = 'error'
} else if (data.status === 'success') {
sessionStatus.value = 'success'
}
} catch {
/* ignore */
}
}
async function checkLogin() {
try {
const params = sessionId.value ? { session_id: sessionId.value } : {}
const data = await get('/api/check_login', params)
if (data.status === 'success') {
sessionStatus.value = 'success'
nickname.value = data.nickname || '用户'
cookies.value = data.cookies
scanning.value = false
loading.value = false
const elapsed = Math.round((Date.now() - startTime) / 1000)
showToast(`${nickname.value},登录成功!耗时 ${elapsed}s`)
stats.success++
stats.totalTime += elapsed
saveStats()
} else if (data.status === 'error') {
sessionStatus.value = 'error'
error.value = data.message || ''
scanning.value = false
loading.value = false
showToast(data.message || '登录失败', true)
} else if (data.status === 'qr_ready') {
sessionStatus.value = 'qr_ready'
scanning.value = false
cookieCount.value = data.cookie_count || 0
loginMatched.value = data.login_matched || 0
} else if (data.status === 'scanning') {
sessionStatus.value = 'scanning'
cookieCount.value = data.cookie_count || 0
loginMatched.value = data.login_matched || 0
loginKeysFound.value = data.login_keys_found || []
scanning.value = true
} else if (data.status === 'idle') {
showToast('会话已过期,请重新获取二维码', true)
}
} catch (e) {
console.error('checkLogin', e)
}
}
async function startQR() {
try {
const status = await get('/api/status')
if (status.status === 'loading' || status.status === 'scanning') {
showToast('已有任务正在执行', true)
return
}
} catch {
/* continue */
}
loading.value = true
scanning.value = false
qrImage.value = ''
cookies.value = null
error.value = ''
apiMode.value = ''
sessionStatus.value = 'loading'
startTime = Date.now()
stats.total++
saveStats()
try {
const data = await post('/api/start_qr', { proxy_api: proxyApi.value.trim() })
if (data.status === 'qr_ready') {
qrImage.value = data.qr_image
sessionId.value = data.session_id || ''
sessionStatus.value = 'qr_ready'
apiMode.value = data.api_mode || ''
loading.value = false
nickname.value = ''
if (data.message) showToast(data.message)
livePoll.restart()
} else {
error.value = data.message || '获取二维码失败'
sessionStatus.value = 'error'
loading.value = false
showToast(data.message || '获取二维码失败', true)
}
} catch (e) {
error.value = '连接服务器失败'
sessionStatus.value = 'error'
loading.value = false
showToast(formatApiError(e, '无法连接服务器'), true)
}
}
async function resetSession() {
try {
await post('/api/reset')
sessionId.value = ''
sessionStatus.value = 'idle'
sessionMsg.value = '会话已重置'
nickname.value = ''
qrImage.value = ''
cookies.value = null
scanning.value = false
loading.value = false
error.value = ''
apiMode.value = ''
showToast('已重置')
} catch (e) {
showToast(formatApiError(e, '重置失败'), true)
}
}
async function testProxy() {
const url = proxyApi.value.trim()
if (!url) {
showToast('请先输入代理 API', true)
return
}
testingProxy.value = true
try {
const data = await post('/api/test_proxy', { proxy_api: url })
if (data.status === 'success') {
showToast(`代理可用,IP: ${data.ip || 'ok'}`)
} else {
showToast(data.message || '代理失败', true)
}
} catch (e) {
showToast(formatApiError(e, '网络错误'), true)
} finally {
testingProxy.value = false
}
}
async function copyCookies() {
try {
await navigator.clipboard.writeText(cookieJson.value)
copied.value = true
showToast('已复制到剪贴板')
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
showToast('复制失败', true)
}
}
function onGlobalRefresh() {
livePoll.runTick()
}
onMounted(() => {
livePoll.start()
clockTimer = setInterval(() => {
now.value = Date.now()
}, 1000)
window.addEventListener('douyin-refresh-all', onGlobalRefresh)
})
onUnmounted(() => {
livePoll.stop()
if (clockTimer) clearInterval(clockTimer)
clearTimeout(toastTimer)
window.removeEventListener('douyin-refresh-all', onGlobalRefresh)
})
</script>
<style scoped>
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-strong);
}
.page-desc {
margin: 0.35rem 0 0;
font-size: 0.875rem;
color: var(--muted);
}
.status-panel {
background: #fafafa;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
padding: 0.75rem 0.85rem;
margin-bottom: 0.65rem;
}
.status-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-strong);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.status-dot--loading {
background: var(--warn);
animation: pulse 1.4s infinite;
}
.status-dot--scanning,
.status-dot--qr_ready {
background: var(--primary);
animation: pulse 1.4s infinite;
}
.status-dot--success {
background: var(--success);
}
.status-dot--error {
background: var(--danger);
}
@keyframes pulse {
0%,
100% {
opacity: 0.45;
transform: scale(0.85);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
.status-time {
font-size: 0.75rem;
color: var(--muted);
}
.live-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.35rem;
padding: 0.1rem 0.4rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--success);
background: var(--success-bg);
border-radius: 4px;
}
.live-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
animation: pulse 1.2s infinite;
}
.status-msg {
margin: 0;
font-size: 0.875rem;
color: var(--text);
word-break: break-word;
}
.work-grid {
display: grid;
grid-template-columns: 1fr 240px;
gap: 0.65rem;
align-items: start;
}
.work-main,
.work-side {
margin: 0;
}
.action-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.85rem;
}
.btn-lg {
flex: 1;
min-height: 2.5rem;
}
.qr-block {
text-align: center;
padding: 1rem;
background: #fafbfc;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
margin-bottom: 0.85rem;
}
.qr-frame {
position: relative;
display: inline-block;
padding: 0.5rem;
background: #fff;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
}
.qr-img {
display: block;
width: 220px;
max-width: 100%;
height: auto;
}
.qr-dl {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.8rem;
}
.api-mode-tag {
font-size: 0.78rem;
color: var(--muted);
margin-bottom: 0.65rem;
}
.steps-list {
margin: 0 0 1rem;
padding-left: 1.2rem;
font-size: 0.82rem;
color: var(--text);
line-height: 1.6;
}
.side-gap {
margin-top: 0.5rem;
}
.cookie-block {
border-top: 1px solid var(--border-light);
padding-top: 0.85rem;
}
.cookie-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.col-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-strong);
}
.cookie-pre {
margin: 0;
padding: 0.75rem;
background: #1a1a1a;
color: #e8e8e8;
border-radius: var(--radius-sm);
font-size: 0.75rem;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.field-label {
margin-bottom: 0.35rem;
}
.inp.full {
width: 100%;
margin-bottom: 0.5rem;
}
.proxy-hint {
font-size: 0.78rem;
color: var(--muted);
margin: 0.35rem 0 0.65rem;
}
.work-side .full {
width: 100%;
}
@media (max-width: 899px) {
.work-grid {
grid-template-columns: 1fr;
}
.qr-img {
width: 180px;
}
}
</style>
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router'],
http: ['axios'],
},
},
},
},
server: {
host: true,
port: 5173,
strictPort: false,
proxy: {
'/api': {
target: 'http://127.0.0.1:5001',
changeOrigin: true,
},
'/qrcode.png': {
target: 'http://127.0.0.1:5001',
changeOrigin: true,
},
},
},
})
-3
View File
@@ -1,6 +1,3 @@
Flask==2.3.3 Flask==2.3.3
playwright==1.40.0
playwright-stealth==1.0.6
requests==2.31.0 requests==2.31.0
gunicorn==21.2.0 gunicorn==21.2.0
eventlet==0.33.3
+47
View File
@@ -0,0 +1,47 @@
import requests
import time
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
headers = {
"User-Agent": UA,
"Referer": "https://www.douyin.com/",
"Accept": "application/json, text/plain, */*",
"Origin": "https://www.douyin.com",
}
s = requests.Session()
reg = s.post(
"https://ttwid.bytedance.com/ttwid/union/register/",
json={
"region": "cn",
"aid": 1768,
"needFid": False,
"service": "www.douyin.com",
"migrate_info": {"ticket": "", "source": "web"},
"cbUrlProtocol": "https",
"union": True,
},
headers=headers,
timeout=15,
).json()
cb = reg.get("redirect_url")
if cb:
s.get(cb, headers=headers, timeout=15, allow_redirects=True)
s.get("https://www.douyin.com/", headers=headers, timeout=15)
s.get("https://www.douyin.com/passport/web/login/", headers=headers, timeout=15)
print("cookies", [c.name for c in s.cookies])
try:
rr = s.get("https://www.douyin.com/passport/web/csrf/token/", headers=headers, timeout=10)
print("csrf", rr.status_code, rr.text[:200])
except Exception as e:
print("csrf err", e)
ts = int(time.time() * 1000)
tests = [
("sso", "https://sso.douyin.com/get_qrcode/", {"aid": "6383", "service": "https://www.douyin.com", "is_vcd": "1", "t": ts}),
("passport", "https://www.douyin.com/passport/web/get_qrcode/", {"aid": "6383", "service": "https://www.douyin.com", "need_logo": "false", "t": ts}),
("creator", "https://sso.douyin.com/get_qrcode/", {"aid": "2906", "next": "https://creator.douyin.com", "service": "https://creator.douyin.com", "is_vcd": "1", "t": ts}),
]
for name, url, params in tests:
r = s.get(url, params=params, headers=headers, timeout=15)
ct = r.headers.get("content-type", "")
print("---", name, r.status_code, ct)
print(r.text[:400])
+45
View File
@@ -0,0 +1,45 @@
import requests
import time
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
def try_flow(referer, service, aid, next_url=None):
headers = {
"User-Agent": UA,
"Referer": referer,
"Accept": "application/json, text/plain, */*",
"Origin": referer.rstrip("/"),
}
s = requests.Session()
s.post(
"https://ttwid.bytedance.com/ttwid/union/register/",
json={
"region": "cn",
"aid": 1768,
"needFid": False,
"service": "www.douyin.com",
"migrate_info": {"ticket": "", "source": "web"},
"cbUrlProtocol": "https",
"union": True,
},
headers=headers,
timeout=15,
)
s.get(referer, headers=headers, timeout=15)
ts = int(time.time() * 1000)
params = {"aid": str(aid), "service": service, "is_vcd": "1", "t": ts}
if next_url:
params["next"] = next_url
r = s.get("https://sso.douyin.com/get_qrcode/", params=params, headers=headers, timeout=15)
print("===", referer, aid, r.status_code, r.headers.get("content-type"))
if "json" in (r.headers.get("content-type") or ""):
print(r.text[:500])
else:
print(r.text[:100])
for ref, svc, aid, nxt in [
("https://creator.douyin.com/", "https://creator.douyin.com", 2906, "https://creator.douyin.com"),
("https://www.douyin.com/", "https://www.douyin.com", 6383, None),
("https://www.douyin.com/", "https://www.douyin.com", 1128, "https://www.douyin.com"),
]:
try_flow(ref, svc, aid, nxt)
+66
View File
@@ -0,0 +1,66 @@
import requests
import time
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
s = requests.Session()
base_headers = {
"User-Agent": UA,
"Referer": "https://www.douyin.com/",
"Accept": "application/json, text/plain, */*",
"Origin": "https://www.douyin.com",
}
s.post(
"https://ttwid.bytedance.com/ttwid/union/register/",
json={
"region": "cn",
"aid": 1768,
"needFid": False,
"service": "www.douyin.com",
"migrate_info": {"ticket": "", "source": "web"},
"cbUrlProtocol": "https",
"union": True,
},
headers=base_headers,
timeout=15,
)
s.get("https://www.douyin.com/", headers=base_headers, timeout=15)
s.get("https://www.douyin.com/passport/web/login/", headers=base_headers, timeout=15)
csrf = None
for c in s.cookies:
if c.name == "passport_csrf_token":
csrf = c.value
break
print("passport_csrf_token", csrf)
print("all cookies", [(c.name, c.domain) for c in s.cookies])
ts = int(time.time() * 1000)
params = {
"aid": "6383",
"service": "https://www.douyin.com",
"need_logo": "false",
"is_vcd": "1",
"t": str(ts),
}
headers = dict(base_headers)
if csrf:
headers["x-tt-passport-csrf-token"] = csrf
for method in ("GET", "POST"):
if method == "POST":
r = s.post(
"https://www.douyin.com/passport/web/get_qrcode/",
params=params,
headers=headers,
timeout=15,
)
else:
r = s.get(
"https://www.douyin.com/passport/web/get_qrcode/",
params=params,
headers=headers,
timeout=15,
)
print(method, r.status_code, r.text[:500])
-1090
View File
File diff suppressed because it is too large Load Diff