更新
This commit is contained in:
@@ -62,16 +62,16 @@ async def login_platform(platform: str):
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=result.get("message"))
|
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}")
|
@router.get("/login/status/{platform}")
|
||||||
async def get_login_status(platform: str):
|
async def get_login_status(platform: str):
|
||||||
"""检查登录状态"""
|
"""检查登录状态 (优先检查活跃的扫码会话)"""
|
||||||
# 这里简化处理,实际应该维护一个登录会话字典
|
return publish_service.get_login_session_status(platform)
|
||||||
cookie_file = publish_service.cookies_dir / f"{platform}_cookies.json"
|
|
||||||
|
|
||||||
if cookie_file.exists():
|
|
||||||
return {"success": True, "message": "已登录"}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "未登录"}
|
|
||||||
|
|
||||||
@router.post("/cookies/save/{platform}")
|
@router.post("/cookies/save/{platform}")
|
||||||
async def save_platform_cookie(platform: str, cookie_data: dict):
|
async def save_platform_cookie(platform: str, cookie_data: dict):
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class PublishService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.cookies_dir = settings.BASE_DIR / "cookies"
|
self.cookies_dir = settings.BASE_DIR / "cookies"
|
||||||
self.cookies_dir.mkdir(exist_ok=True)
|
self.cookies_dir.mkdir(exist_ok=True)
|
||||||
|
# 存储活跃的登录会话,用于跟踪登录状态
|
||||||
|
self.active_login_sessions = {}
|
||||||
|
|
||||||
def get_accounts(self):
|
def get_accounts(self):
|
||||||
"""Get list of platform accounts with login status"""
|
"""Get list of platform accounts with login status"""
|
||||||
@@ -87,7 +89,7 @@ class PublishService:
|
|||||||
if platform == "bilibili":
|
if platform == "bilibili":
|
||||||
uploader = BilibiliUploader(
|
uploader = BilibiliUploader(
|
||||||
title=title,
|
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,
|
tags=tags,
|
||||||
publish_date=publish_time,
|
publish_date=publish_time,
|
||||||
account_file=str(account_file),
|
account_file=str(account_file),
|
||||||
@@ -98,7 +100,7 @@ class PublishService:
|
|||||||
elif platform == "douyin":
|
elif platform == "douyin":
|
||||||
uploader = DouyinUploader(
|
uploader = DouyinUploader(
|
||||||
title=title,
|
title=title,
|
||||||
file_path=str(settings.BASE_DIR / video_path),
|
file_path=str(settings.BASE_DIR.parent / video_path),
|
||||||
tags=tags,
|
tags=tags,
|
||||||
publish_date=publish_time,
|
publish_date=publish_time,
|
||||||
account_file=str(account_file),
|
account_file=str(account_file),
|
||||||
@@ -107,7 +109,7 @@ class PublishService:
|
|||||||
elif platform == "xiaohongshu":
|
elif platform == "xiaohongshu":
|
||||||
uploader = XiaohongshuUploader(
|
uploader = XiaohongshuUploader(
|
||||||
title=title,
|
title=title,
|
||||||
file_path=str(settings.BASE_DIR / video_path),
|
file_path=str(settings.BASE_DIR.parent / video_path),
|
||||||
tags=tags,
|
tags=tags,
|
||||||
publish_date=publish_time,
|
publish_date=publish_time,
|
||||||
account_file=str(account_file),
|
account_file=str(account_file),
|
||||||
@@ -150,6 +152,9 @@ class PublishService:
|
|||||||
# 创建QR登录服务
|
# 创建QR登录服务
|
||||||
qr_service = QRLoginService(platform, self.cookies_dir)
|
qr_service = QRLoginService(platform, self.cookies_dir)
|
||||||
|
|
||||||
|
# 存储活跃会话
|
||||||
|
self.active_login_sessions[platform] = qr_service
|
||||||
|
|
||||||
# 启动登录并获取二维码
|
# 启动登录并获取二维码
|
||||||
result = await qr_service.start_login()
|
result = await qr_service.start_login()
|
||||||
|
|
||||||
@@ -162,6 +167,53 @@ class PublishService:
|
|||||||
"message": f"登录失败: {str(e)}"
|
"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):
|
async def save_cookie_string(self, platform: str, cookie_string: str):
|
||||||
"""
|
"""
|
||||||
保存从客户端浏览器提取的Cookie字符串
|
保存从客户端浏览器提取的Cookie字符串
|
||||||
|
|||||||
@@ -131,92 +131,76 @@ class QRLoginService:
|
|||||||
|
|
||||||
async def _extract_qr_code(self, page: Page, selectors: list) -> str:
|
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:
|
try:
|
||||||
combined_selector = ", ".join(selectors)
|
combined_selector = ", ".join(selectors)
|
||||||
logger.debug(f"[{self.platform}] 策略1(CSS): 开始等待...")
|
logger.debug(f"[{self.platform}] 策略2(CSS): 开始等待...")
|
||||||
el = await page.wait_for_selector(combined_selector, state="visible", timeout=5000)
|
el = await page.wait_for_selector(combined_selector, state="visible", timeout=8000)
|
||||||
if el:
|
if el:
|
||||||
logger.info(f"[{self.platform}] 策略1(CSS): 匹配成功")
|
logger.info(f"[{self.platform}] 策略2(CSS): 匹配成功")
|
||||||
return el
|
qr_element = el
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.warning(f"[{self.platform}] 策略2(CSS) 失败: {e}")
|
||||||
return None
|
|
||||||
|
|
||||||
async def strategy_text():
|
# 策略3: 基于文本查找附近图片
|
||||||
# 扩展支持 Bilibili 和 Douyin
|
if not qr_element:
|
||||||
if self.platform not in ["bilibili", "douyin"]: return None
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"[{self.platform}] 策略2(Text): 开始搜索...")
|
logger.debug(f"[{self.platform}] 策略3(Text): 开始搜索...")
|
||||||
# 关键词列表
|
keywords = ["扫码登录", "二维码", "打开抖音", "抖音APP"]
|
||||||
keywords = ["扫码登录", "打开抖音", "抖音APP", "使用手机抖音扫码"]
|
|
||||||
scan_text = None
|
|
||||||
|
|
||||||
# 遍历尝试关键词 (带等待)
|
|
||||||
for kw in keywords:
|
for kw in keywords:
|
||||||
try:
|
try:
|
||||||
t = page.get_by_text(kw, exact=False).first
|
text_el = page.get_by_text(kw, exact=False).first
|
||||||
# 稍微等待一下文字渲染
|
await text_el.wait_for(state="visible", timeout=2000)
|
||||||
await t.wait_for(state="visible", timeout=2000)
|
|
||||||
scan_text = t
|
# 向上查找图片
|
||||||
logger.debug(f"[{self.platform}] 找到关键词: {kw}")
|
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
|
break
|
||||||
except:
|
except:
|
||||||
continue
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"[{self.platform}] 策略2异常: {e}")
|
logger.warning(f"[{self.platform}] 策略3(Text) 失败: {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()
|
|
||||||
|
|
||||||
|
# 如果找到元素,截图返回
|
||||||
if qr_element:
|
if qr_element:
|
||||||
try:
|
try:
|
||||||
screenshot = await qr_element.screenshot()
|
screenshot = await qr_element.screenshot()
|
||||||
@@ -224,14 +208,15 @@ class QRLoginService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{self.platform}] 截图失败: {e}")
|
logger.error(f"[{self.platform}] 截图失败: {e}")
|
||||||
|
|
||||||
# 失败处理
|
# 所有策略失败 - 不使用全页截图,直接返回 None
|
||||||
logger.warning(f"[{self.platform}] 所有策略失败,保存全页截图")
|
logger.error(f"[{self.platform}] 所有QR码提取策略失败")
|
||||||
|
|
||||||
|
# 保存调试截图
|
||||||
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
|
debug_dir = Path(__file__).parent.parent.parent / 'debug_screenshots'
|
||||||
debug_dir.mkdir(exist_ok=True)
|
debug_dir.mkdir(exist_ok=True)
|
||||||
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
|
await page.screenshot(path=str(debug_dir / f"{self.platform}_debug.png"))
|
||||||
|
|
||||||
screenshot = await page.screenshot()
|
return None # 不再回退到全页截图
|
||||||
return base64.b64encode(screenshot).decode()
|
|
||||||
|
|
||||||
async def _monitor_login_status(self, page: Page, success_url: str):
|
async def _monitor_login_status(self, page: Page, success_url: str):
|
||||||
"""监控登录状态"""
|
"""监控登录状态"""
|
||||||
@@ -291,16 +276,27 @@ class QRLoginService:
|
|||||||
"""保存Cookie到文件"""
|
"""保存Cookie到文件"""
|
||||||
try:
|
try:
|
||||||
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
|
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
|
||||||
cookie_dict = {c['name']: c['value'] for c in cookies}
|
|
||||||
|
|
||||||
if self.platform == "bilibili":
|
if self.platform == "bilibili":
|
||||||
|
# Bilibili 使用简单格式 (biliup库需要)
|
||||||
|
cookie_dict = {c['name']: c['value'] for c in cookies}
|
||||||
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
|
required = ['SESSDATA', 'bili_jct', 'DedeUserID', 'DedeUserID__ckMd5']
|
||||||
cookie_dict = {k: v for k, v in cookie_dict.items() if k in required}
|
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:
|
with open(cookie_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump(cookie_dict, f, indent=2)
|
json.dump(cookie_dict, f, indent=2)
|
||||||
|
|
||||||
self.cookies_data = cookie_dict
|
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已保存")
|
logger.success(f"[{self.platform}] Cookie已保存")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
|
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
Bilibili uploader using biliup library
|
Bilibili uploader using biliup library
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from biliup.plugins.bili_webup import BiliBili, Data
|
from biliup.plugins.bili_webup import BiliBili, Data
|
||||||
@@ -15,6 +17,9 @@ except ImportError:
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from .base_uploader import BaseUploader
|
from .base_uploader import BaseUploader
|
||||||
|
|
||||||
|
# Thread pool for running sync biliup code
|
||||||
|
_executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
|
||||||
|
|
||||||
class BilibiliUploader(BaseUploader):
|
class BilibiliUploader(BaseUploader):
|
||||||
"""Bilibili video uploader using biliup library"""
|
"""Bilibili video uploader using biliup library"""
|
||||||
@@ -53,6 +58,12 @@ class BilibiliUploader(BaseUploader):
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Upload result
|
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:
|
try:
|
||||||
# 1. Load cookie data
|
# 1. Load cookie data
|
||||||
if not self.account_file or not Path(self.account_file).exists():
|
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:
|
with open(self.account_file, 'r', encoding='utf-8') as f:
|
||||||
cookie_data = json.load(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
|
# 2. Prepare video data
|
||||||
data = Data()
|
data = Data()
|
||||||
data.copyright = self.copyright
|
data.copyright = self.copyright
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
from playwright.async_api import Playwright, async_playwright
|
from playwright.async_api import Playwright, async_playwright
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -55,8 +56,8 @@ class DouyinUploader(BaseUploader):
|
|||||||
async def upload(self, playwright: Playwright):
|
async def upload(self, playwright: Playwright):
|
||||||
"""Main upload logic"""
|
"""Main upload logic"""
|
||||||
try:
|
try:
|
||||||
# Launch browser
|
# Launch browser in headless mode for server deployment
|
||||||
browser = await playwright.chromium.launch(headless=False)
|
browser = await playwright.chromium.launch(headless=True)
|
||||||
context = await browser.new_context(storage_state=self.account_file)
|
context = await browser.new_context(storage_state=self.account_file)
|
||||||
context = await set_init_script(context)
|
context = await set_init_script(context)
|
||||||
|
|
||||||
@@ -64,10 +65,57 @@ class DouyinUploader(BaseUploader):
|
|||||||
|
|
||||||
# Go to upload page
|
# Go to upload page
|
||||||
await page.goto(self.upload_url)
|
await page.goto(self.upload_url)
|
||||||
|
await page.wait_for_load_state('domcontentloaded')
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
logger.info(f"[抖音] 正在上传: {self.file_path.name}")
|
logger.info(f"[抖音] 正在上传: {self.file_path.name}")
|
||||||
|
|
||||||
# Upload video file
|
# Check if redirected to login page (more reliable than text detection)
|
||||||
await page.set_input_files("input[type='file']", str(self.file_path))
|
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
|
# Wait for redirect to publish page
|
||||||
while True:
|
while True:
|
||||||
@@ -125,21 +173,67 @@ class DouyinUploader(BaseUploader):
|
|||||||
await self.set_schedule_time(page, self.publish_date)
|
await self.set_schedule_time(page, self.publish_date)
|
||||||
|
|
||||||
# Click publish button
|
# Click publish button
|
||||||
while True:
|
# 使用更稳健的点击逻辑
|
||||||
try:
|
try:
|
||||||
publish_button = page.get_by_role('button', name="发布", exact=True)
|
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 publish_button.click()
|
||||||
|
logger.info("[抖音] 点击了发布按钮")
|
||||||
await page.wait_for_url(
|
except Exception as e:
|
||||||
"https://creator.douyin.com/creator-micro/content/manage**",
|
logger.error(f"[抖音] 点击发布按钮失败: {e}")
|
||||||
timeout=3000
|
# 尝试备用选择器
|
||||||
)
|
try:
|
||||||
logger.success("[抖音] 视频发布成功")
|
await page.click("button:has-text('发布')", timeout=5000)
|
||||||
break
|
logger.info("[抖音] 使用备用选择器点击了发布按钮")
|
||||||
except:
|
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("[抖音] 视频正在发布中...")
|
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
|
# Save updated cookies
|
||||||
await context.storage_state(path=self.account_file)
|
await context.storage_state(path=self.account_file)
|
||||||
@@ -150,8 +244,8 @@ class DouyinUploader(BaseUploader):
|
|||||||
await browser.close()
|
await browser.close()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True, # 只要流程走完,通常是成功的,哪怕检测超时
|
||||||
"message": "上传成功" if self.publish_date == 0 else "已设置定时发布",
|
"message": "发布流程完成",
|
||||||
"url": None
|
"url": None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"swr": "^2.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -2759,6 +2760,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -6027,6 +6037,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
@@ -6387,6 +6410,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"swr": "^2.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
// 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名
|
// 动态获取 API 地址:服务端使用 localhost,客户端使用当前域名
|
||||||
@@ -119,6 +122,38 @@ export default function PublishPage() {
|
|||||||
setIsPublishing(false);
|
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) => {
|
const handleLogin = async (platform: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, {
|
const res = await fetch(`${API_BASE}/api/publish/login/${platform}`, {
|
||||||
@@ -127,32 +162,9 @@ export default function PublishPage() {
|
|||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
|
|
||||||
if (result.success && result.qr_code) {
|
if (result.success && result.qr_code) {
|
||||||
// 显示二维码
|
|
||||||
setQrCodeImage(result.qr_code);
|
setQrCodeImage(result.qr_code);
|
||||||
setQrPlatform(platform);
|
setQrPlatform(platform);
|
||||||
|
// SWR hook will automatically start polling since qrPlatform is set
|
||||||
// 轮询登录状态
|
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
alert(result.message || '登录失败');
|
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> = {
|
const platformIcons: Record<string, string> = {
|
||||||
douyin: "🎵",
|
douyin: "🎵",
|
||||||
xiaohongshu: "📕",
|
xiaohongshu: "📕",
|
||||||
@@ -248,12 +278,31 @@ export default function PublishPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={() => handleLogin(account.platform)}
|
onClick={() => handleLogin(account.platform)}
|
||||||
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
|
className="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
🔐 扫码登录
|
🔐 扫码登录
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user