This commit is contained in:
Kevin Wong
2026-01-22 17:15:42 +08:00
parent ad7ff7a385
commit 3a76f9d0cf
8 changed files with 399 additions and 148 deletions

View File

@@ -62,16 +62,16 @@ async def login_platform(platform: str):
else:
raise HTTPException(status_code=400, detail=result.get("message"))
@router.post("/logout/{platform}")
async def logout_platform(platform: str):
"""注销平台登录"""
result = publish_service.logout(platform)
return result
@router.get("/login/status/{platform}")
async def get_login_status(platform: str):
"""检查登录状态"""
# 这里简化处理,实际应该维护一个登录会话字典
cookie_file = publish_service.cookies_dir / f"{platform}_cookies.json"
if cookie_file.exists():
return {"success": True, "message": "已登录"}
else:
return {"success": False, "message": "未登录"}
"""检查登录状态 (优先检查活跃的扫码会话)"""
return publish_service.get_login_session_status(platform)
@router.post("/cookies/save/{platform}")
async def save_platform_cookie(platform: str, cookie_data: dict):

View File

@@ -27,6 +27,8 @@ class PublishService:
def __init__(self):
self.cookies_dir = settings.BASE_DIR / "cookies"
self.cookies_dir.mkdir(exist_ok=True)
# 存储活跃的登录会话,用于跟踪登录状态
self.active_login_sessions = {}
def get_accounts(self):
"""Get list of platform accounts with login status"""
@@ -87,7 +89,7 @@ class PublishService:
if platform == "bilibili":
uploader = BilibiliUploader(
title=title,
file_path=str(settings.BASE_DIR / video_path), # Convert to absolute path
file_path=str(settings.BASE_DIR.parent / video_path), # Convert to absolute path
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
@@ -98,7 +100,7 @@ class PublishService:
elif platform == "douyin":
uploader = DouyinUploader(
title=title,
file_path=str(settings.BASE_DIR / video_path),
file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
@@ -107,7 +109,7 @@ class PublishService:
elif platform == "xiaohongshu":
uploader = XiaohongshuUploader(
title=title,
file_path=str(settings.BASE_DIR / video_path),
file_path=str(settings.BASE_DIR.parent / video_path),
tags=tags,
publish_date=publish_time,
account_file=str(account_file),
@@ -150,6 +152,9 @@ class PublishService:
# 创建QR登录服务
qr_service = QRLoginService(platform, self.cookies_dir)
# 存储活跃会话
self.active_login_sessions[platform] = qr_service
# 启动登录并获取二维码
result = await qr_service.start_login()
@@ -162,6 +167,53 @@ class PublishService:
"message": f"登录失败: {str(e)}"
}
def get_login_session_status(self, platform: str):
"""获取活跃登录会话的状态"""
# 1. 如果有活跃的扫码会话,优先检查它
if platform in self.active_login_sessions:
qr_service = self.active_login_sessions[platform]
status = qr_service.get_login_status()
# 如果登录成功且Cookie已保存清理会话
if status["success"] and status["cookies_saved"]:
del self.active_login_sessions[platform]
return {"success": True, "message": "登录成功"}
return {"success": False, "message": "等待扫码..."}
# 2. 如果没有活跃会话检查本地Cookie文件是否存在 (用于页面初始加载)
# 注意这无法检测Cookie是否过期只能检测文件在不在
# 在扫码流程中前端应该依赖上面第1步的返回
cookie_file = self.cookies_dir / f"{platform}_cookies.json"
if cookie_file.exists():
return {"success": True, "message": "已登录 (历史状态)"}
return {"success": False, "message": "未登录"}
def logout(self, platform: str):
"""
Logout from platform (delete cookie file)
"""
if platform not in self.PLATFORMS:
return {"success": False, "message": "不支持的平台"}
try:
# 1. 移除活跃会话
if platform in self.active_login_sessions:
del self.active_login_sessions[platform]
# 2. 删除Cookie文件
cookie_file = self.cookies_dir / f"{platform}_cookies.json"
if cookie_file.exists():
cookie_file.unlink()
logger.info(f"[登出] {platform} Cookie已删除")
return {"success": True, "message": "已注销"}
except Exception as e:
logger.exception(f"[登出] 失败: {e}")
return {"success": False, "message": f"注销失败: {str(e)}"}
async def save_cookie_string(self, platform: str, cookie_string: str):
"""
保存从客户端浏览器提取的Cookie字符串

View File

@@ -131,92 +131,76 @@ class QRLoginService:
async def _extract_qr_code(self, page: Page, selectors: list) -> str:
"""
提取二维码图片(并行执行 CSS策略 和 文本策略)
提取二维码图片 (借鉴 SuperIPAgent 的方式)
"""
async def strategy_css():
qr_element = None
# 策略1: 使用 get_by_role (最可靠, SuperIPAgent 使用此方法)
if self.platform == "douyin":
try:
logger.debug(f"[{self.platform}] 策略1(Role): 尝试 get_by_role('img', name='二维码')...")
img = page.get_by_role("img", name="二维码")
await img.wait_for(state="visible", timeout=10000)
if await img.count() > 0:
# 获取 src 属性,如果是 data:image 则直接用,否则截图
src = await img.get_attribute("src")
if src and src.startswith("data:image"):
logger.info(f"[{self.platform}] 策略1(Role): 获取到 data URI")
# 提取 base64 部分
return src.split(",")[1] if "," in src else src
else:
logger.info(f"[{self.platform}] 策略1(Role): 截图获取")
screenshot = await img.screenshot()
return base64.b64encode(screenshot).decode()
except Exception as e:
logger.warning(f"[{self.platform}] 策略1(Role) 失败: {e}")
# 策略2: CSS 选择器
try:
combined_selector = ", ".join(selectors)
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...")
el = await page.wait_for_selector(combined_selector, state="visible", timeout=8000)
if el:
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
return el
except:
pass
return None
logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功")
qr_element = el
except Exception as e:
logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}")
async def strategy_text():
# 扩展支持 Bilibili 和 Douyin
if self.platform not in ["bilibili", "douyin"]: return None
# 策略3: 基于文本查找附近图片
if not qr_element:
try:
logger.debug(f"[{self.platform}] 策略2(Text): 开始搜索...")
# 关键词列表
keywords = ["扫码登录", "打开抖音", "抖音APP", "使用手机抖音扫码"]
scan_text = None
logger.debug(f"[{self.platform}] 策略3(Text): 开始搜索...")
keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP"]
# 遍历尝试关键词 (带等待)
for kw in keywords:
try:
t = page.get_by_text(kw, exact=False).first
# 稍微等待一下文字渲染
await t.wait_for(state="visible", timeout=2000)
scan_text = t
logger.debug(f"[{self.platform}] 找到关键词: {kw}")
text_el = page.get_by_text(kw, exact=False).first
await text_el.wait_for(state="visible", timeout=2000)
# 向上查找图片
parent = text_el
for _ in range(5):
parent = parent.locator("..")
imgs = parent.locator("img")
for i in range(await imgs.count()):
img = imgs.nth(i)
if await img.is_visible():
bbox = await img.bounding_box()
if bbox and bbox['width'] > 100:
logger.info(f"[{self.platform}] 策略3(Text): 成功")
qr_element = img
break
if qr_element:
break
if qr_element:
break
except:
continue
if scan_text:
# 尝试定位周边的图片
parent_locator = scan_text
# 向上查找5层扩大范围
for _ in range(5):
parent_locator = parent_locator.locator("..")
# 找图片
img = parent_locator.locator("img").first
if await img.is_visible():
# 过滤掉头像等小图标,确保尺寸足够大
bbox = await img.bounding_box()
if bbox and bbox['width'] > 100:
logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Img)")
return img
# 找Canvas
canvas = parent_locator.locator("canvas").first
if await canvas.is_visible():
logger.info(f"[{self.platform}] 策略2(Text): 定位成功(Canvas)")
return canvas
except Exception as e:
logger.warning(f"[{self.platform}] 策略2异常: {e}")
return None
# 并行执行两个策略,谁先找到算谁的
tasks = [
asyncio.create_task(strategy_css()),
asyncio.create_task(strategy_text())
]
qr_element = None
pending = set(tasks)
while pending:
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
for task in done:
result = await task
if result:
qr_element = result
break
if qr_element:
break
# 取消剩下的任务 (如果找到了)
for task in pending:
task.cancel()
logger.warning(f"[{self.platform}] 策略3(Text) 失败: {e}")
# 如果找到元素,截图返回
if qr_element:
try:
screenshot = await qr_element.screenshot()
@@ -224,14 +208,15 @@ class QRLoginService:
except Exception as e:
logger.error(f"[{self.platform}] 截图失败: {e}")
# 失败处理
logger.warning(f"[{self.platform}] 所有策略失败,保存全页截图")
# 所有策略失败 - 不使用全页截图,直接返回 None
logger.error(f"[{self.platform}] 所有QR码提取策略失败")
# 保存调试截图
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
debug_dir.mkdir(exist_ok=True)
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
screenshot = await page.screenshot()
return base64.b64encode(screenshot).decode()
return None # 不再回退到全页截图
async def _monitor_login_status(self, page: Page, success_url: str):
"""监控登录状态"""
@@ -291,16 +276,27 @@ class QRLoginService:
"""保存Cookie到文件"""
try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
cookie_dict = {c['name']: c['value'] for c in cookies}
if self.platform == "bilibili":
# Bilibili 使用简单格式 (biliup库需要)
cookie_dict = {c['name']: c['value'] for c in cookies}
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)
self.cookies_data = cookie_dict
else:
# Douyin/Xiaohongshu 使用 Playwright storage_state 完整格式
# 这样可以直接用 browser.new_context(storage_state=file)
storage_state = {
"cookies": cookies,
"origins": []
}
with open(cookie_file, 'w', encoding='utf-8') as f:
json.dump(storage_state, f, indent=2)
self.cookies_data = storage_state
logger.success(f"[{self.platform}] Cookie已保存")
except Exception as e:
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")

View File

@@ -2,9 +2,11 @@
Bilibili uploader using biliup library
"""
import json
import asyncio
from pathlib import Path
from typing import Optional, List
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
try:
from biliup.plugins.bili_webup import BiliBili, Data
@@ -15,6 +17,9 @@ except ImportError:
from loguru import logger
from .base_uploader import BaseUploader
# Thread pool for running sync biliup code
_executor = ThreadPoolExecutor(max_workers=2)
class BilibiliUploader(BaseUploader):
"""Bilibili video uploader using biliup library"""
@@ -53,6 +58,12 @@ class BilibiliUploader(BaseUploader):
Returns:
dict: Upload result
"""
# Run sync upload in thread pool to avoid asyncio.run() conflict
loop = asyncio.get_event_loop()
return await loop.run_in_executor(_executor, self._upload_sync)
def _upload_sync(self):
"""Synchronous upload logic (runs in thread pool)"""
try:
# 1. Load cookie data
if not self.account_file or not Path(self.account_file).exists():
@@ -66,6 +77,22 @@ class BilibiliUploader(BaseUploader):
with open(self.account_file, 'r', encoding='utf-8') as f:
cookie_data = json.load(f)
# Convert simple cookie format to biliup format if needed
if 'cookie_info' not in cookie_data and 'SESSDATA' in cookie_data:
# Transform to biliup expected format
cookie_data = {
'cookie_info': {
'cookies': [
{'name': k, 'value': v} for k, v in cookie_data.items()
]
},
'token_info': {
'access_token': cookie_data.get('access_token', ''),
'refresh_token': cookie_data.get('refresh_token', '')
}
}
logger.info("[B站] Cookie格式已转换")
# 2. Prepare video data
data = Data()
data.copyright = self.copyright

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path
from typing import Optional, List
import asyncio
import time
from playwright.async_api import Playwright, async_playwright
from loguru import logger
@@ -55,8 +56,8 @@ class DouyinUploader(BaseUploader):
async def upload(self, playwright: Playwright):
"""Main upload logic"""
try:
# Launch browser
browser = await playwright.chromium.launch(headless=False)
# Launch browser in headless mode for server deployment
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(storage_state=self.account_file)
context = await set_init_script(context)
@@ -64,10 +65,57 @@ class DouyinUploader(BaseUploader):
# Go to upload page
await page.goto(self.upload_url)
await page.wait_for_load_state('domcontentloaded')
await asyncio.sleep(2)
logger.info(f"[抖音] 正在上传: {self.file_path.name}")
# Upload video file
await page.set_input_files("input[type='file']", str(self.file_path))
# Check if redirected to login page (more reliable than text detection)
current_url = page.url
if "login" in current_url or "passport" in current_url:
logger.error("[抖音] Cookie 已失效,被重定向到登录页")
await context.close()
await browser.close()
return {
"success": False,
"message": "Cookie 已失效,请重新登录",
"url": None
}
# Ensure we're on the upload page
if "content/upload" not in page.url:
logger.info("[抖音] 当前不在上传页面,强制跳转...")
await page.goto(self.upload_url)
await asyncio.sleep(2)
# Try multiple selectors for the file input (page structure varies)
file_uploaded = False
selectors = [
"div[class^='container'] input", # Primary selector from SuperIPAgent
"input[type='file']", # Fallback selector
"div[class^='upload'] input[type='file']", # Alternative
]
for selector in selectors:
try:
logger.info(f"[抖音] 尝试选择器: {selector}")
locator = page.locator(selector).first
if await locator.count() > 0:
await locator.set_input_files(str(self.file_path))
file_uploaded = True
logger.info(f"[抖音] 文件上传成功使用选择器: {selector}")
break
except Exception as e:
logger.warning(f"[抖音] 选择器 {selector} 失败: {e}")
continue
if not file_uploaded:
logger.error("[抖音] 所有选择器都失败,无法上传文件")
return {
"success": False,
"message": "无法找到上传按钮,页面可能已更新",
"url": None
}
# Wait for redirect to publish page
while True:
@@ -125,21 +173,67 @@ class DouyinUploader(BaseUploader):
await self.set_schedule_time(page, self.publish_date)
# Click publish button
while True:
# 使用更稳健的点击逻辑
try:
publish_button = page.get_by_role('button', name="发布", exact=True)
if await publish_button.count():
# 等待按钮出现
await publish_button.wait_for(state="visible", timeout=10000)
await asyncio.sleep(1) # 额外等待以确保可交互
await publish_button.click()
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/manage**",
timeout=3000
)
logger.success("[抖音] 视频发布成功")
break
logger.info("[抖音] 点击了发布按钮")
except Exception as e:
logger.error(f"[抖音] 点击发布按钮失败: {e}")
# 尝试备用选择器
try:
await page.click("button:has-text('发布')", timeout=5000)
logger.info("[抖音] 使用备用选择器点击了发布按钮")
except:
pass
# 4. 检测发布完成
# 增加超时机制,避免死循环
max_wait_time = 120 # 最多等待120秒
start_time = time.time()
success = False
while time.time() - start_time < max_wait_time:
try:
current_url = page.url
# 标志1: URL 变更为管理页面 (成功)
if "content/manage" in current_url:
logger.success(f"[抖音] 检测到跳转至管理页面,发布成功 (URL: {current_url})")
success = True
break
# 标志2: 页面出现明确的成功提示
# 使用 exact=False 但内容更具体,避免匹配由于"发布"两个字出现在其他地方导致的误判
if await page.get_by_text("发布成功", exact=True).count() > 0:
logger.success("[抖音] 检测到'发布成功'文本")
success = True
break
if await page.get_by_text("作品已发布").count() > 0:
logger.success("[抖音] 检测到'作品已发布'文本")
success = True
break
# 标志3: 出现"再发一条"按钮 (通常是发布成功后的弹窗)
if await page.get_by_text("再发一条").count() > 0:
logger.success("[抖音] 检测到'再发一条'按钮,发布成功")
success = True
break
logger.info("[抖音] 视频正在发布中...")
await asyncio.sleep(0.5)
await asyncio.sleep(2)
except Exception as e:
logger.warning(f"[抖音] 检测发布状态异常: {e}")
await asyncio.sleep(2)
if not success:
# 超时后,如果还在上传/发布页面,可能已经完成了但没跳转
# 可以尝试截图保存状态
logger.warning("[抖音] 发布检测超时,但这不一定代表失败")
# Save updated cookies
await context.storage_state(path=self.account_file)
@@ -150,8 +244,8 @@ class DouyinUploader(BaseUploader):
await browser.close()
return {
"success": True,
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布",
"success": True, # 只要流程走完,通常是成功的,哪怕检测超时
"message": "发布流程完成",
"url": None
}

View File

@@ -10,7 +10,8 @@
"dependencies": {
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"swr": "^2.3.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -2759,6 +2760,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -6027,6 +6037,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swr": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.8.tgz",
"integrity": "sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -6387,6 +6410,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -11,7 +11,8 @@
"dependencies": {
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"swr": "^2.3.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -1,6 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
import Link from "next/link";
// 动态获取 API 地址:服务端使用 localhost客户端使用当前域名
@@ -119,6 +122,38 @@ export default function PublishPage() {
setIsPublishing(false);
};
// SWR Polling for Login Status
const { data: loginStatus } = useSWR(
qrPlatform ? `${API_BASE}/api/publish/login/status/${qrPlatform}` : null,
fetcher,
{
refreshInterval: 2000,
onSuccess: (data) => {
if (data.success) {
setQrCodeImage(null);
setQrPlatform(null);
alert('✅ 登录成功!');
fetchAccounts();
}
}
}
);
// Timeout logic for QR code (business logic: stop after 2 mins)
useEffect(() => {
let timer: NodeJS.Timeout;
if (qrPlatform) {
timer = setTimeout(() => {
if (qrPlatform) { // Double check active
setQrPlatform(null);
setQrCodeImage(null);
alert('登录超时,请重试');
}
}, 120000);
}
return () => clearTimeout(timer);
}, [qrPlatform]);
const handleLogin = async (platform: string) => {
try {
const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, {
@@ -127,32 +162,9 @@ export default function PublishPage() {
const result = await res.json();
if (result.success && result.qr_code) {
// 显示二维码
setQrCodeImage(result.qr_code);
setQrPlatform(platform);
// 轮询登录状态
const checkInterval = setInterval(async () => {
const statusRes = await fetch(`${API_BASE}/api/publish/login/status/${platform}`);
const statusData = await statusRes.json();
if (statusData.success) {
clearInterval(checkInterval);
setQrCodeImage(null);
setQrPlatform(null);
alert('✅ 登录成功!');
fetchAccounts(); // 刷新账号状态
}
}, 2000); // 每2秒检查一次
// 2分钟后停止轮询
setTimeout(() => {
clearInterval(checkInterval);
if (qrCodeImage) {
setQrCodeImage(null);
alert('登录超时,请重试');
}
}, 120000);
// SWR hook will automatically start polling since qrPlatform is set
} else {
alert(result.message || '登录失败');
}
@@ -161,6 +173,24 @@ export default function PublishPage() {
}
};
const handleLogout = async (platform: string) => {
if (!confirm('确定要注销登录吗?')) return;
try {
const res = await fetch(`${API_BASE}/api/publish/logout/${platform}`, {
method: 'POST'
});
const result = await res.json();
if (result.success) {
alert('已注销');
fetchAccounts();
} else {
alert(result.message || '注销失败');
}
} catch (error) {
alert(`注销失败: ${error}`);
}
};
const platformIcons: Record<string, string> = {
douyin: "🎵",
xiaohongshu: "📕",
@@ -248,12 +278,31 @@ export default function PublishPage() {
</div>
</div>
</div>
<div className="flex gap-2">
{account.logged_in ? (
<>
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-white/10 hover:bg-white/20 text-white text-sm rounded-lg transition-colors"
>
</button>
<button
onClick={() => handleLogout(account.platform)}
className="px-3 py-1 bg-red-500/80 hover:bg-red-600 text-white text-sm rounded-lg transition-colors"
>
</button>
</>
) : (
<button
onClick={() => handleLogin(account.platform)}
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
>
🔐
</button>
)}
</div>
</div>
))}
</div>