From 9e0644095f88292f80f6ccd9424c3cde41a97d6d Mon Sep 17 00:00:00 2001 From: travel Date: Thu, 25 Jun 2026 10:47:55 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=BA=20HTTP=20SSO=20?= =?UTF-8?q?=E6=89=AB=E7=A0=81=E6=96=B9=E6=A1=88=E5=B9=B6=E5=BC=95=E5=85=A5?= =?UTF-8?q?=20Vue3=20=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 Playwright 浏览器自动化,改用 passport/SSO HTTP 接口获取二维码与轮询登录;后端模块化拆分,前端替换为 Vue3 SPA。 Co-authored-by: Cursor --- .gitignore | 4 + README.md | 77 +- app.py | 551 +------ backend/__init__.py | 1 + backend/app_factory.py | 44 + backend/config.py | 22 + backend/cookies.py | 59 + backend/proxy.py | 64 + backend/routes/__init__.py | 1 + backend/routes/api.py | 139 ++ backend/routes/spa.py | 55 + backend/session.py | 30 + backend/sso/__init__.py | 1 + backend/sso/qr_login.py | 413 ++++++ frontend/index.html | 14 + frontend/package-lock.json | 1717 ++++++++++++++++++++++ frontend/package.json | 20 + frontend/src/App.vue | 6 + frontend/src/api/http.js | 26 + frontend/src/components/LayoutShell.vue | 349 +++++ frontend/src/components/ToastMessage.vue | 61 + frontend/src/composables/useLivePoll.js | 60 + frontend/src/main.js | 6 + frontend/src/router/index.js | 23 + frontend/src/style.css | 471 ++++++ frontend/src/views/DebugView.vue | 277 ++++ frontend/src/views/HomeView.vue | 657 +++++++++ frontend/vite.config.js | 33 + requirements.txt | 3 - scripts/test_qr_api.py | 47 + scripts/test_qr_api2.py | 45 + scripts/test_qr_api3.py | 66 + templates/index.html | 1090 -------------- 33 files changed, 4792 insertions(+), 1640 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/app_factory.py create mode 100644 backend/config.py create mode 100644 backend/cookies.py create mode 100644 backend/proxy.py create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/api.py create mode 100644 backend/routes/spa.py create mode 100644 backend/session.py create mode 100644 backend/sso/__init__.py create mode 100644 backend/sso/qr_login.py create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/http.js create mode 100644 frontend/src/components/LayoutShell.vue create mode 100644 frontend/src/components/ToastMessage.vue create mode 100644 frontend/src/composables/useLivePoll.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/DebugView.vue create mode 100644 frontend/src/views/HomeView.vue create mode 100644 frontend/vite.config.js create mode 100644 scripts/test_qr_api.py create mode 100644 scripts/test_qr_api2.py create mode 100644 scripts/test_qr_api3.py delete mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore index b9c0fa3..b461752 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ Thumbs.db # --- Static generated files --- static/qrcode.png + +# --- Frontend --- +frontend/node_modules/ +frontend/dist/ diff --git a/README.md b/README.md index 524a2cc..1ae558f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,76 @@ -# douyin_cookie_yunsya +# 抖音 Cookie 一键提取 -提取抖音cookies \ No newline at end of file +扫码登录抖音后,自动导出 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 前端 +``` diff --git a/app.py b/app.py index 16db19e..f9147b0 100644 --- a/app.py +++ b/app.py @@ -1,550 +1,11 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +"""抖音 Cookie 一键提取 — 入口""" -import asyncio -import base64 -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 +from backend.app_factory import create_app +from backend.config import PORT -import requests -from flask import Flask, render_template, request, jsonify, send_file -from playwright.async_api import async_playwright +app = create_app() -# 配置日志 — 优先使用环境变量,否则使用项目相对路径 -LOG_DIR = os.environ.get('LOG_DIR', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')) -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) \ No newline at end of file +if __name__ == "__main__": + app.run(debug=False, host="0.0.0.0", port=PORT) diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e4df58c --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# Douyin Cookie Extractor backend package diff --git a/backend/app_factory.py b/backend/app_factory.py new file mode 100644 index 0000000..795ebc2 --- /dev/null +++ b/backend/app_factory.py @@ -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 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..3470ec3 --- /dev/null +++ b/backend/config.py @@ -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" +) diff --git a/backend/cookies.py b/backend/cookies.py new file mode 100644 index 0000000..d478ded --- /dev/null +++ b/backend/cookies.py @@ -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 diff --git a/backend/proxy.py b/backend/proxy.py new file mode 100644 index 0000000..f985835 --- /dev/null +++ b/backend/proxy.py @@ -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}"} diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..5cf4b1c --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1 @@ +# API routes package diff --git a/backend/routes/api.py b/backend/routes/api.py new file mode 100644 index 0000000..3d68bf3 --- /dev/null +++ b/backend/routes/api.py @@ -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 diff --git a/backend/routes/spa.py b/backend/routes/spa.py new file mode 100644 index 0000000..31eee2c --- /dev/null +++ b/backend/routes/spa.py @@ -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/") +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("/") +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()) diff --git a/backend/session.py b/backend/session.py new file mode 100644 index 0000000..fec0420 --- /dev/null +++ b/backend/session.py @@ -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() diff --git a/backend/sso/__init__.py b/backend/sso/__init__.py new file mode 100644 index 0000000..37ef8cf --- /dev/null +++ b/backend/sso/__init__.py @@ -0,0 +1 @@ +# HTTP SSO login (no browser) diff --git a/backend/sso/qr_login.py b/backend/sso/qr_login.py new file mode 100644 index 0000000..fab9d61 --- /dev/null +++ b/backend/sso/qr_login.py @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1152a6f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + 抖音 Cookie 提取器 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9c69565 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1717 @@ +{ + "name": "douyin-cookie-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "douyin-cookie-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0c667f0 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d372e96 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,6 @@ + + + diff --git a/frontend/src/api/http.js b/frontend/src/api/http.js new file mode 100644 index 0000000..4d31cc9 --- /dev/null +++ b/frontend/src/api/http.js @@ -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 diff --git a/frontend/src/components/LayoutShell.vue b/frontend/src/components/LayoutShell.vue new file mode 100644 index 0000000..e91b3b1 --- /dev/null +++ b/frontend/src/components/LayoutShell.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/frontend/src/components/ToastMessage.vue b/frontend/src/components/ToastMessage.vue new file mode 100644 index 0000000..a4f3484 --- /dev/null +++ b/frontend/src/components/ToastMessage.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/src/composables/useLivePoll.js b/frontend/src/composables/useLivePoll.js new file mode 100644 index 0000000..252eadf --- /dev/null +++ b/frontend/src/composables/useLivePoll.js @@ -0,0 +1,60 @@ +import { ref, onUnmounted } from 'vue' + +/** + * 自适应间隔轮询:tick 返回后按 getInterval() 调度下一次 + * @param {() => Promise|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 } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..54ce204 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..e31b680 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..53e50f4 --- /dev/null +++ b/frontend/src/style.css @@ -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; +} diff --git a/frontend/src/views/DebugView.vue b/frontend/src/views/DebugView.vue new file mode 100644 index 0000000..d31425f --- /dev/null +++ b/frontend/src/views/DebugView.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..da3c3fb --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,657 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..bac7ae9 --- /dev/null +++ b/frontend/vite.config.js @@ -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, + }, + }, + }, +}) diff --git a/requirements.txt b/requirements.txt index 0bbf3d9..9facfcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,3 @@ Flask==2.3.3 -playwright==1.40.0 -playwright-stealth==1.0.6 requests==2.31.0 gunicorn==21.2.0 -eventlet==0.33.3 \ No newline at end of file diff --git a/scripts/test_qr_api.py b/scripts/test_qr_api.py new file mode 100644 index 0000000..eb9a85f --- /dev/null +++ b/scripts/test_qr_api.py @@ -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]) diff --git a/scripts/test_qr_api2.py b/scripts/test_qr_api2.py new file mode 100644 index 0000000..0137ddc --- /dev/null +++ b/scripts/test_qr_api2.py @@ -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) diff --git a/scripts/test_qr_api3.py b/scripts/test_qr_api3.py new file mode 100644 index 0000000..1677c53 --- /dev/null +++ b/scripts/test_qr_api3.py @@ -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]) diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index fb557a1..0000000 --- a/templates/index.html +++ /dev/null @@ -1,1090 +0,0 @@ - - - - - - 抖音 Cookie 扫码提取器 - - - - -
- -
-
-
- 🔄 - 0 -
-
总提取次数
-
-
-
- - 0 -
-
成功次数
-
-
-
- ⏱️ - 0s -
-
平均耗时
-
-
-
- 📊 - 0% -
-
成功率
-
-
- - -
-
-
🍪
-
-

抖音 Cookie 提取器

-
扫码登录 · 一键提取 JSON 格式 Cookie
-
-
- - -
-
-
- - 就绪 -
- - -
-
等待操作...
-
-
-
-
- - -
- -
-
- - -
- - - - - - -
- - -
-
- - -
- ● 默认:服务器 IP -
-
- -
- ℹ 留空则使用服务器默认 IP -
-
-
-
-
- - -
- - -
- - - - \ No newline at end of file