This commit is contained in:
Kevin Wong
2026-01-23 09:42:10 +08:00
parent 3a76f9d0cf
commit cfe21d8337
7 changed files with 753 additions and 333 deletions

View File

@@ -12,6 +12,7 @@ router = APIRouter()
publish_service = PublishService()
class PublishRequest(BaseModel):
"""Video publish request model"""
video_path: str
platform: str
title: str
@@ -20,13 +21,25 @@ class PublishRequest(BaseModel):
publish_time: Optional[datetime] = None
class PublishResponse(BaseModel):
"""Video publish response model"""
success: bool
message: str
platform: str
url: Optional[str] = None
# Supported platforms for validation
SUPPORTED_PLATFORMS = {"bilibili", "douyin", "xiaohongshu"}
@router.post("/", response_model=PublishResponse)
async def publish_video(request: PublishRequest, background_tasks: BackgroundTasks):
"""发布视频到指定平台"""
# Validate platform
if request.platform not in SUPPORTED_PLATFORMS:
raise HTTPException(
status_code=400,
detail=f"不支持的平台: {request.platform}。支持的平台: {', '.join(SUPPORTED_PLATFORMS)}"
)
try:
result = await publish_service.publish(
video_path=request.video_path,
@@ -56,6 +69,10 @@ async def list_accounts():
@router.post("/login/{platform}")
async def login_platform(platform: str):
"""触发平台QR码登录"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
result = await publish_service.login(platform)
if result.get("success"):
return result
@@ -65,12 +82,18 @@ async def login_platform(platform: str):
@router.post("/logout/{platform}")
async def logout_platform(platform: str):
"""注销平台登录"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
result = publish_service.logout(platform)
return result
@router.get("/login/status/{platform}")
async def get_login_status(platform: str):
"""检查登录状态 (优先检查活跃的扫码会话)"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
return publish_service.get_login_session_status(platform)
@router.post("/cookies/save/{platform}")
@@ -82,7 +105,13 @@ async def save_platform_cookie(platform: str, cookie_data: dict):
platform: 平台ID
cookie_data: {"cookie_string": "document.cookie的内容"}
"""
if platform not in SUPPORTED_PLATFORMS:
raise HTTPException(status_code=400, detail=f"不支持的平台: {platform}")
cookie_string = cookie_data.get("cookie_string", "")
if not cookie_string:
raise HTTPException(status_code=400, detail="cookie_string 不能为空")
result = await publish_service.save_cookie_string(platform, cookie_string)
if result.get("success"):

View File

@@ -1,9 +1,10 @@
"""
发布服务 (基于 social-auto-upload 架构)
"""
import json
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from typing import Optional, List, Dict, Any
from loguru import logger
from app.core.config import settings
@@ -16,21 +17,22 @@ from .uploader.xiaohongshu_uploader import XiaohongshuUploader
class PublishService:
"""Social media publishing service"""
PLATFORMS = {
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame"},
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/"},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/"},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/"},
"kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/"},
# 支持的平台配置
PLATFORMS: Dict[str, Dict[str, Any]] = {
"bilibili": {"name": "B站", "url": "https://member.bilibili.com/platform/upload/video/frame", "enabled": True},
"douyin": {"name": "抖音", "url": "https://creator.douyin.com/", "enabled": True},
"xiaohongshu": {"name": "小红书", "url": "https://creator.xiaohongshu.com/", "enabled": True},
"weixin": {"name": "微信视频号", "url": "https://channels.weixin.qq.com/", "enabled": False},
"kuaishou": {"name": "快手", "url": "https://cp.kuaishou.com/", "enabled": False},
}
def __init__(self):
def __init__(self) -> None:
self.cookies_dir = settings.BASE_DIR / "cookies"
self.cookies_dir.mkdir(exist_ok=True)
# 存储活跃的登录会话,用于跟踪登录状态
self.active_login_sessions = {}
self.active_login_sessions: Dict[str, Any] = {}
def get_accounts(self):
def get_accounts(self) -> List[Dict[str, Any]]:
"""Get list of platform accounts with login status"""
accounts = []
for pid, pinfo in self.PLATFORMS.items():
@@ -39,7 +41,7 @@ class PublishService:
"platform": pid,
"name": pinfo["name"],
"logged_in": cookie_file.exists(),
"enabled": True
"enabled": pinfo.get("enabled", True)
})
return accounts
@@ -51,8 +53,8 @@ class PublishService:
tags: List[str],
description: str = "",
publish_time: Optional[datetime] = None,
**kwargs
):
**kwargs: Any
) -> Dict[str, Any]:
"""
Publish video to specified platform
@@ -136,7 +138,7 @@ class PublishService:
"platform": platform
}
async def login(self, platform: str):
async def login(self, platform: str) -> Dict[str, Any]:
"""
启动QR码登录流程
@@ -167,7 +169,7 @@ class PublishService:
"message": f"登录失败: {str(e)}"
}
def get_login_session_status(self, platform: str):
def get_login_session_status(self, platform: str) -> Dict[str, Any]:
"""获取活跃登录会话的状态"""
# 1. 如果有活跃的扫码会话,优先检查它
if platform in self.active_login_sessions:
@@ -190,7 +192,7 @@ class PublishService:
return {"success": False, "message": "未登录"}
def logout(self, platform: str):
def logout(self, platform: str) -> Dict[str, Any]:
"""
Logout from platform (delete cookie file)
"""
@@ -214,7 +216,7 @@ class PublishService:
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) -> Dict[str, Any]:
"""
保存从客户端浏览器提取的Cookie字符串
@@ -250,7 +252,6 @@ class PublishService:
cookie_dict = bilibili_cookies
# 保存Cookie
import json
with open(account_file, 'w', encoding='utf-8') as f:
json.dump(cookie_dict, f, indent=2)

View File

@@ -4,22 +4,30 @@ QR码自动登录服务
"""
import asyncio
import base64
from pathlib import Path
from playwright.async_api import async_playwright, Page
from loguru import logger
import json
import os
from pathlib import Path
from typing import Optional, Dict, Any, List
from playwright.async_api import async_playwright, Page, BrowserContext, Browser, Playwright as PW
from loguru import logger
class QRLoginService:
"""QR码登录服务"""
def __init__(self, platform: str, cookies_dir: Path):
# 登录监控超时 (秒)
LOGIN_TIMEOUT = 120
def __init__(self, platform: str, cookies_dir: Path) -> None:
self.platform = platform
self.cookies_dir = cookies_dir
self.qr_code_image = None
self.login_success = False
self.cookies_data = None
self.qr_code_image: Optional[str] = None
self.login_success: bool = False
self.cookies_data: Optional[Dict[str, Any]] = None
# Playwright 资源 (手动管理生命周期)
self.playwright: Optional[PW] = None
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
# 每个平台使用多个选择器 (使用逗号分隔Playwright会同时等待它们)
self.platform_configs = {
@@ -56,7 +64,7 @@ class QRLoginService:
}
}
async def start_login(self):
async def start_login(self) -> Dict[str, Any]:
"""
启动登录流程
@@ -129,7 +137,7 @@ class QRLoginService:
await self._cleanup()
return {"success": False, "message": f"启动失败: {str(e)}"}
async def _extract_qr_code(self, page: Page, selectors: list) -> str:
async def _extract_qr_code(self, page: Page, selectors: List[str]) -> Optional[str]:
"""
提取二维码图片 (借鉴 SuperIPAgent 的方式)
"""
@@ -195,7 +203,7 @@ class QRLoginService:
break
if qr_element:
break
except:
except Exception:
continue
except Exception as e:
logger.warning(f"[{self.platform}] 策略3(Text) 失败: {e}")
@@ -225,7 +233,7 @@ class QRLoginService:
key_cookies = {"bilibili": "SESSDATA", "douyin": "sessionid", "xiaohongshu": "web_session"}
target_cookie = key_cookies.get(self.platform, "")
for i in range(120):
for i in range(self.LOGIN_TIMEOUT):
await asyncio.sleep(1)
try:
@@ -260,19 +268,28 @@ class QRLoginService:
finally:
await self._cleanup()
async def _cleanup(self):
async def _cleanup(self) -> None:
"""清理资源"""
if hasattr(self, 'context') and self.context:
try: await self.context.close()
except: pass
if hasattr(self, 'browser') and self.browser:
try: await self.browser.close()
except: pass
if hasattr(self, 'playwright') and self.playwright:
try: await self.playwright.stop()
except: pass
if self.context:
try:
await self.context.close()
except Exception:
pass
self.context = None
if self.browser:
try:
await self.browser.close()
except Exception:
pass
self.browser = None
if self.playwright:
try:
await self.playwright.stop()
except Exception:
pass
self.playwright = None
async def _save_cookies(self, cookies: list):
async def _save_cookies(self, cookies: List[Dict[str, Any]]) -> None:
"""保存Cookie到文件"""
try:
cookie_file = self.cookies_dir / f"{self.platform}_cookies.json"
@@ -301,7 +318,7 @@ class QRLoginService:
except Exception as e:
logger.error(f"[{self.platform}] 保存Cookie失败: {e}")
def get_login_status(self):
def get_login_status(self) -> Dict[str, Any]:
"""获取登录状态"""
return {
"success": self.login_success,

View File

@@ -3,7 +3,7 @@ Base uploader class for all social media platforms
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
@@ -38,7 +38,7 @@ class BaseUploader(ABC):
self.description = description
@abstractmethod
async def main(self):
async def main(self) -> Dict[str, Any]:
"""
Main upload method - must be implemented by subclasses
@@ -50,7 +50,7 @@ class BaseUploader(ABC):
"""
pass
def _get_timestamp(self, dt):
def _get_timestamp(self, dt: Union[datetime, int]) -> int:
"""
Convert datetime to Unix timestamp

View File

@@ -4,7 +4,7 @@ Bilibili uploader using biliup library
import json
import asyncio
from pathlib import Path
from typing import Optional, List
from typing import Optional, List, Dict, Any
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
@@ -51,7 +51,7 @@ class BilibiliUploader(BaseUploader):
"biliup library not installed. Please run: pip install biliup"
)
async def main(self):
async def main(self) -> Dict[str, Any]:
"""
Upload video to Bilibili
@@ -62,7 +62,7 @@ class BilibiliUploader(BaseUploader):
loop = asyncio.get_event_loop()
return await loop.run_in_executor(_executor, self._upload_sync)
def _upload_sync(self):
def _upload_sync(self) -> Dict[str, Any]:
"""Synchronous upload logic (runs in thread pool)"""
try:
# 1. Load cookie data
@@ -124,17 +124,39 @@ class BilibiliUploader(BaseUploader):
# Submit
ret = bili.submit()
# Debug: log full response
logger.debug(f"[B站] API响应: {ret}")
if ret.get('code') == 0:
bvid = ret.get('bvid', '')
logger.success(f"[B站] 上传成功: {bvid}")
return {
"success": True,
"message": "上传成功" if data.dtime == 0 else "已设置定时发布",
"url": f"https://www.bilibili.com/video/{bvid}" if bvid else None
}
# Try multiple keys for bvid (API may vary)
bvid = ret.get('data', {}).get('bvid') or ret.get('bvid', '')
aid = ret.get('data', {}).get('aid') or ret.get('aid', '')
if bvid:
logger.success(f"[B站] 上传成功: {bvid}")
return {
"success": True,
"message": "上传成功" if data.dtime == 0 else "已设置定时发布",
"url": f"https://www.bilibili.com/video/{bvid}"
}
elif aid:
logger.success(f"[B站] 上传成功: av{aid}")
return {
"success": True,
"message": "上传成功" if data.dtime == 0 else "已设置定时发布",
"url": f"https://www.bilibili.com/video/av{aid}"
}
else:
# No bvid/aid but code=0, still consider success
logger.warning(f"[B站] 上传返回code=0但无bvid/aid: {ret}")
return {
"success": True,
"message": "上传成功(无法获取视频链接)",
"url": None
}
else:
error_msg = ret.get('message', '未知错误')
logger.error(f"[B站] 上传失败: {error_msg}")
logger.error(f"[B站] 上传失败: {error_msg} (完整响应: {ret})")
return {
"success": False,
"message": f"上传失败: {error_msg}",

View File

@@ -1,263 +1,585 @@
"""
Douyin (抖音) uploader using Playwright
Based on social-auto-upload implementation
"""
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
from .base_uploader import BaseUploader
from .cookie_utils import set_init_script
class DouyinUploader(BaseUploader):
"""Douyin video uploader using Playwright"""
def __init__(
self,
title: str,
file_path: str,
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
description: str = ""
):
super().__init__(title, file_path, tags, publish_date, account_file, description)
self.upload_url = "https://creator.douyin.com/creator-micro/content/upload"
async def set_schedule_time(self, page, publish_date):
"""Set scheduled publish time"""
try:
# Click "定时发布" radio button
label_element = page.locator("[class^='radio']:has-text('定时发布')")
await label_element.click()
await asyncio.sleep(1)
# Format time
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
# Fill datetime input
await page.locator('.semi-input[placeholder="日期和时间"]').click()
await page.keyboard.press("Control+KeyA")
await page.keyboard.type(str(publish_date_hour))
await page.keyboard.press("Enter")
await asyncio.sleep(1)
logger.info(f"[抖音] 已设置定时发布: {publish_date_hour}")
except Exception as e:
logger.error(f"[抖音] 设置定时发布失败: {e}")
async def upload(self, playwright: Playwright):
"""Main upload logic"""
try:
# 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)
page = await context.new_page()
# 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}")
# 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:
try:
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/publish?enter_from=publish_page",
timeout=3000
)
logger.info("[抖音] 成功进入发布页面")
break
except:
try:
await page.wait_for_url(
"https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page",
timeout=3000
)
logger.info("[抖音] 成功进入发布页面 (版本2)")
break
except:
await asyncio.sleep(0.5)
# Fill title
await asyncio.sleep(1)
logger.info("[抖音] 正在填充标题和话题...")
title_container = page.get_by_text('作品描述').locator("..").locator("..").locator(
"xpath=following-sibling::div[1]").locator("input")
if await title_container.count():
await title_container.fill(self.title[:30])
# Add tags
css_selector = ".zone-container"
for tag in self.tags:
await page.type(css_selector, "#" + tag)
await page.press(css_selector, "Space")
logger.info(f"[抖音] 总共添加 {len(self.tags)} 个话题")
# Wait for upload to complete
while True:
try:
number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
if number > 0:
logger.success("[抖音] 视频上传完毕")
break
else:
logger.info("[抖音] 正在上传视频中...")
await asyncio.sleep(2)
except:
await asyncio.sleep(2)
# Set scheduled publish time if needed
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
# Click publish button
# 使用更稳健的点击逻辑
try:
publish_button = page.get_by_role('button', name="发布", exact=True)
# 等待按钮出现
await publish_button.wait_for(state="visible", timeout=10000)
await asyncio.sleep(1) # 额外等待以确保可交互
await publish_button.click()
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(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)
logger.success("[抖音] Cookie 更新完毕")
await asyncio.sleep(2)
await context.close()
await browser.close()
return {
"success": True, # 只要流程走完,通常是成功的,哪怕检测超时
"message": "发布流程完成",
"url": None
}
except Exception as e:
logger.exception(f"[抖音] 上传失败: {e}")
return {
"success": False,
"message": f"上传失败: {str(e)}",
"url": None
}
async def main(self):
"""Execute upload"""
async with async_playwright() as playwright:
return await self.upload(playwright)
"""
Douyin (抖音) uploader using Playwright
Based on social-auto-upload implementation
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List, Dict, Any
import asyncio
import time
from playwright.async_api import Playwright, async_playwright
from loguru import logger
from .base_uploader import BaseUploader
from .cookie_utils import set_init_script
class DouyinUploader(BaseUploader):
"""Douyin video uploader using Playwright"""
# 超时配置 (秒)
UPLOAD_TIMEOUT = 300 # 视频上传超时
PUBLISH_TIMEOUT = 180 # 发布检测超时
PAGE_REDIRECT_TIMEOUT = 60 # 页面跳转超时
POLL_INTERVAL = 2 # 轮询间隔
MAX_CLICK_RETRIES = 3 # 按钮点击重试次数
def __init__(
self,
title: str,
file_path: str,
tags: List[str],
publish_date: Optional[datetime] = None,
account_file: Optional[str] = None,
description: str = ""
):
super().__init__(title, file_path, tags, publish_date, account_file, description)
self.upload_url = "https://creator.douyin.com/creator-micro/content/upload"
async def _is_text_visible(self, page, text: str, exact: bool = False) -> bool:
try:
return await page.get_by_text(text, exact=exact).first.is_visible()
except Exception:
return False
async def _first_visible_locator(self, locator, timeout: int = 1000):
try:
if await locator.count() == 0:
return None
candidate = locator.first
if await candidate.is_visible(timeout=timeout):
return candidate
except Exception:
return None
return None
async def _wait_for_publish_result(self, page, max_wait_time: int = 180):
success_texts = ["发布成功", "作品已发布", "再发一条", "查看作品", "审核中", "待审核"]
weak_texts = ["发布完成"]
failure_texts = ["发布失败", "发布异常", "发布出错", "请完善", "请补充", "请先上传"]
start_time = time.time()
poll_interval = 2
weak_reason = None
while time.time() - start_time < max_wait_time:
if page.is_closed():
return False, "页面已关闭", False
current_url = page.url
if "content/manage" in current_url:
return True, f"已跳转到管理页面 (URL: {current_url})", False
for text in success_texts:
if await self._is_text_visible(page, text, exact=False):
return True, f"检测到成功提示: {text}", False
for text in failure_texts:
if await self._is_text_visible(page, text, exact=False):
return False, f"检测到失败提示: {text}", False
for text in weak_texts:
if await self._is_text_visible(page, text, exact=False):
weak_reason = text
logger.info("[抖音] 视频正在发布中...")
await asyncio.sleep(poll_interval)
if weak_reason:
return False, f"检测到提示: {weak_reason}", True
return False, "发布检测超时", True
async def _fill_title(self, page, title: str) -> bool:
title_text = title[:30]
locator_candidates = []
try:
label_locator = page.get_by_text("作品描述").locator("..").locator("..").locator(
"xpath=following-sibling::div[1]"
).locator("textarea, input, div[contenteditable='true']")
locator_candidates.append(label_locator)
except Exception:
pass
locator_candidates.extend([
page.locator("textarea[placeholder*='作品描述']"),
page.locator("textarea[placeholder*='描述']"),
page.locator("input[placeholder*='作品描述']"),
page.locator("input[placeholder*='描述']"),
page.locator("div[contenteditable='true']"),
])
for locator in locator_candidates:
try:
if await locator.count() > 0:
target = locator.first
await target.fill(title_text)
return True
except Exception:
continue
return False
async def _select_cover_if_needed(self, page) -> bool:
try:
cover_button = page.get_by_text("选择封面", exact=False).first
if await cover_button.is_visible():
await cover_button.click()
logger.info("[抖音] 尝试选择封面")
await asyncio.sleep(0.5)
dialog = page.locator(
"div.dy-creator-content-modal-wrap, div[role='dialog'], "
"div[class*='modal'], div[class*='dialog']"
).last
scopes = [dialog] if await dialog.count() > 0 else [page]
switched = False
for scope in scopes:
for selector in [
"button:has-text('设置横封面')",
"div:has-text('设置横封面')",
"span:has-text('设置横封面')",
]:
try:
button = await self._first_visible_locator(scope.locator(selector))
if button:
await button.click()
logger.info("[抖音] 已切换到横封面设置")
await asyncio.sleep(0.5)
switched = True
break
except Exception:
continue
if switched:
break
selected = False
for scope in scopes:
for selector in [
"div[class*='cover'] img",
"div[class*='cover']",
"div[class*='frame'] img",
"div[class*='frame']",
"div[class*='preset']",
"img",
]:
try:
candidate = await self._first_visible_locator(scope.locator(selector))
if candidate:
await candidate.click()
logger.info("[抖音] 已选择封面帧")
selected = True
break
except Exception:
continue
if selected:
break
confirm_selectors = [
"button:has-text('完成')",
"button:has-text('确定')",
"button:has-text('保存')",
"button:has-text('确认')",
]
for selector in confirm_selectors:
try:
button = await self._first_visible_locator(page.locator(selector))
if button:
if not await button.is_enabled():
for _ in range(8):
if await button.is_enabled():
break
await asyncio.sleep(0.5)
await button.click()
logger.info(f"[抖音] 封面已确认: {selector}")
await asyncio.sleep(0.5)
if await dialog.count() > 0:
try:
await dialog.wait_for(state="hidden", timeout=5000)
except Exception:
pass
return True
except Exception:
continue
return selected
except Exception as e:
logger.warning(f"[抖音] 选择封面失败: {e}")
return False
async def _click_publish_confirm_modal(self, page):
confirm_selectors = [
"button:has-text('确认发布')",
"button:has-text('继续发布')",
"button:has-text('确定发布')",
"button:has-text('发布确认')",
]
for selector in confirm_selectors:
try:
button = page.locator(selector).first
if await button.is_visible():
await button.click()
logger.info(f"[抖音] 点击了发布确认按钮: {selector}")
await asyncio.sleep(1)
return True
except Exception:
continue
return False
async def _dismiss_blocking_modal(self, page) -> bool:
modal_locator = page.locator(
"div.dy-creator-content-modal-wrap, div[role='dialog'], "
"div[class*='modal'], div[class*='dialog']"
)
try:
count = await modal_locator.count()
except Exception:
return False
if count == 0:
return False
button_texts = [
"我知道了",
"知道了",
"确定",
"继续",
"继续发布",
"确认",
"同意并继续",
"完成",
"好的",
"明白了",
]
close_selectors = [
"button[class*='close']",
"span[class*='close']",
"i[class*='close']",
]
for index in range(count):
modal = modal_locator.nth(index)
try:
if not await modal.is_visible():
continue
for text in button_texts:
try:
button = modal.get_by_role("button", name=text).first
if await button.is_visible():
await button.click()
logger.info(f"[抖音] 关闭弹窗: {text}")
await asyncio.sleep(0.5)
return True
except Exception:
continue
for selector in close_selectors:
try:
close_button = modal.locator(selector).first
if await close_button.is_visible():
await close_button.click()
logger.info("[抖音] 关闭弹窗: close")
await asyncio.sleep(0.5)
return True
except Exception:
continue
except Exception:
continue
return False
async def _verify_publish_in_manage(self, page):
manage_url = "https://creator.douyin.com/creator-micro/content/manage"
try:
await page.goto(manage_url)
await page.wait_for_load_state("domcontentloaded")
await asyncio.sleep(2)
title_text = self.title[:30]
title_locator = page.get_by_text(title_text, exact=False).first
if await title_locator.is_visible():
return True, "内容管理中检测到新作品"
if await self._is_text_visible(page, "审核中", exact=False):
return True, "内容管理显示审核中"
except Exception as e:
return False, f"无法验证内容管理: {e}"
return False, "内容管理中未找到视频"
async def set_schedule_time(self, page, publish_date):
"""Set scheduled publish time"""
try:
# Click "定时发布" radio button
label_element = page.locator("[class^='radio']:has-text('定时发布')")
await label_element.click()
await asyncio.sleep(1)
# Format time
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
# Fill datetime input
await page.locator('.semi-input[placeholder="日期和时间"]').click()
await page.keyboard.press("Control+KeyA")
await page.keyboard.type(str(publish_date_hour))
await page.keyboard.press("Enter")
await asyncio.sleep(1)
logger.info(f"[抖音] 已设置定时发布: {publish_date_hour}")
except Exception as e:
logger.error(f"[抖音] 设置定时发布失败: {e}")
async def upload(self, playwright: Playwright) -> dict:
"""Main upload logic with guaranteed resource cleanup"""
browser = None
context = None
try:
# 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)
page = await context.new_page()
# 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}")
# 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 已失效,被重定向到登录页")
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 (with timeout)
redirect_start = time.time()
while time.time() - redirect_start < self.PAGE_REDIRECT_TIMEOUT:
current_url = page.url
if "content/publish" in current_url or "content/post/video" in current_url:
logger.info("[抖音] 成功进入发布页面")
break
await asyncio.sleep(0.5)
else:
logger.error("[抖音] 等待发布页面超时")
return {
"success": False,
"message": "等待发布页面超时",
"url": None
}
# Fill title
await asyncio.sleep(1)
logger.info("[抖音] 正在填充标题和话题...")
if not await self._fill_title(page, self.title):
logger.warning("[抖音] 未找到作品描述输入框")
# Add tags
css_selector = ".zone-container"
for tag in self.tags:
await page.type(css_selector, "#" + tag)
await page.press(css_selector, "Space")
logger.info(f"[抖音] 总共添加 {len(self.tags)} 个话题")
cover_selected = await self._select_cover_if_needed(page)
if not cover_selected:
logger.warning("[抖音] 未确认封面选择,可能影响发布")
# Wait for upload to complete (with timeout)
upload_start = time.time()
while time.time() - upload_start < self.UPLOAD_TIMEOUT:
try:
number = await page.locator('[class^="long-card"] div:has-text("重新上传")').count()
if number > 0:
logger.success("[抖音] 视频上传完毕")
break
else:
logger.info("[抖音] 正在上传视频中...")
await asyncio.sleep(self.POLL_INTERVAL)
except Exception:
await asyncio.sleep(self.POLL_INTERVAL)
else:
logger.error("[抖音] 视频上传超时")
return {
"success": False,
"message": "视频上传超时",
"url": None
}
# Set scheduled publish time if needed
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
# Click publish button
# 使用更稳健的点击逻辑
try:
publish_label = "定时发布" if self.publish_date != 0 else "发布"
publish_button = page.get_by_role('button', name=publish_label, exact=True)
# 等待按钮出现
await publish_button.wait_for(state="visible", timeout=10000)
if not await publish_button.is_enabled():
logger.error("[抖音] 发布按钮不可点击,可能需要补充封面或确认信息")
return {
"success": False,
"message": "发布按钮不可点击,请检查封面/声明等必填项",
"url": None
}
await asyncio.sleep(1) # 额外等待以确保可交互
clicked = False
for attempt in range(self.MAX_CLICK_RETRIES):
await self._dismiss_blocking_modal(page)
try:
await publish_button.click(timeout=5000)
logger.info(f"[抖音] 点击了{publish_label}按钮")
clicked = True
break
except Exception as click_error:
logger.warning(f"[抖音] 点击发布按钮失败,重试 {attempt + 1}/{self.MAX_CLICK_RETRIES}: {click_error}")
try:
await page.keyboard.press("Escape")
except Exception:
pass
await asyncio.sleep(1)
if not clicked:
raise RuntimeError("点击发布按钮失败")
except Exception as e:
logger.error(f"[抖音] 点击发布按钮失败: {e}")
# 尝试备用选择器
try:
fallback_selectors = ["button:has-text('发布')", "button:has-text('定时发布')"]
clicked = False
for selector in fallback_selectors:
try:
await page.click(selector, timeout=5000)
logger.info(f"[抖音] 使用备用选择器点击了按钮: {selector}")
clicked = True
break
except Exception:
continue
if not clicked:
return {
"success": False,
"message": "无法点击发布按钮,请检查页面状态",
"url": None
}
except Exception:
return {
"success": False,
"message": "无法点击发布按钮,请检查页面状态",
"url": None
}
await self._click_publish_confirm_modal(page)
# 4. 检测发布完成
publish_success, publish_reason, is_timeout = await self._wait_for_publish_result(page)
if not publish_success and is_timeout:
verify_success, verify_reason = await self._verify_publish_in_manage(page)
if verify_success:
publish_success = True
publish_reason = verify_reason
else:
publish_reason = f"{publish_reason}; {verify_reason}"
if publish_success:
logger.success(f"[抖音] 发布成功: {publish_reason}")
else:
if is_timeout:
logger.warning("[抖音] 发布检测超时,但这不一定代表失败")
else:
logger.warning(f"[抖音] 发布未成功: {publish_reason}")
# Save updated cookies
await context.storage_state(path=self.account_file)
logger.success("[抖音] Cookie 更新完毕")
await asyncio.sleep(2)
if publish_success:
return {
"success": True,
"message": "发布成功",
"url": None
}
if is_timeout:
return {
"success": True,
"message": "发布检测超时,请到抖音后台确认",
"url": None
}
return {
"success": False,
"message": f"发布失败: {publish_reason}",
"url": None
}
except Exception as e:
logger.exception(f"[抖音] 上传失败: {e}")
return {
"success": False,
"message": f"上传失败: {str(e)}",
"url": None
}
finally:
# 确保资源释放
if context:
try:
await context.close()
except Exception:
pass
if browser:
try:
await browser.close()
except Exception:
pass
async def main(self) -> Dict[str, Any]:
"""Execute upload"""
async with async_playwright() as playwright:
return await self.upload(playwright)

View File

@@ -4,7 +4,7 @@ Based on social-auto-upload implementation
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from typing import Optional, List, Dict, Any
import asyncio
from playwright.async_api import Playwright, async_playwright
@@ -17,6 +17,11 @@ from .cookie_utils import set_init_script
class XiaohongshuUploader(BaseUploader):
"""Xiaohongshu video uploader using Playwright"""
# 超时配置 (秒)
UPLOAD_TIMEOUT = 300 # 视频上传超时
PUBLISH_TIMEOUT = 120 # 发布检测超时
POLL_INTERVAL = 1 # 轮询间隔
def __init__(
self,
title: str,
@@ -54,11 +59,13 @@ class XiaohongshuUploader(BaseUploader):
except Exception as e:
logger.error(f"[小红书] 设置定时发布失败: {e}")
async def upload(self, playwright: Playwright):
"""Main upload logic"""
async def upload(self, playwright: Playwright) -> dict:
"""Main upload logic with guaranteed resource cleanup"""
browser = None
context = None
try:
# Launch browser
browser = await playwright.chromium.launch(headless=False)
# Launch browser (headless for server deployment)
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context(
viewport={"width": 1600, "height": 900},
storage_state=self.account_file
@@ -74,8 +81,10 @@ class XiaohongshuUploader(BaseUploader):
# Upload video file
await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(str(self.file_path))
# Wait for upload to complete
while True:
# Wait for upload to complete (with timeout)
import time
upload_start = time.time()
while time.time() - upload_start < self.UPLOAD_TIMEOUT:
try:
upload_input = await page.wait_for_selector('input.upload-input', timeout=3000)
preview_new = await upload_input.query_selector(
@@ -100,11 +109,18 @@ class XiaohongshuUploader(BaseUploader):
else:
logger.info("[小红书] 未找到预览元素,继续等待...")
await asyncio.sleep(1)
await asyncio.sleep(self.POLL_INTERVAL)
except Exception as e:
logger.info(f"[小红书] 检测过程: {str(e)},重新尝试...")
await asyncio.sleep(0.5)
else:
logger.error("[小红书] 视频上传超时")
return {
"success": False,
"message": "视频上传超时",
"url": None
}
# Fill title and tags
await asyncio.sleep(1)
@@ -126,8 +142,9 @@ class XiaohongshuUploader(BaseUploader):
if self.publish_date != 0:
await self.set_schedule_time(page, self.publish_date)
# Click publish button
while True:
# Click publish button (with timeout)
publish_start = time.time()
while time.time() - publish_start < self.PUBLISH_TIMEOUT:
try:
if self.publish_date != 0:
await page.locator('button:has-text("定时发布")').click()
@@ -140,17 +157,17 @@ class XiaohongshuUploader(BaseUploader):
)
logger.success("[小红书] 视频发布成功")
break
except:
except Exception:
logger.info("[小红书] 视频正在发布中...")
await asyncio.sleep(0.5)
else:
logger.warning("[小红书] 发布检测超时,请手动确认")
# Save updated cookies
await context.storage_state(path=self.account_file)
logger.success("[小红书] Cookie 更新完毕")
await asyncio.sleep(2)
await context.close()
await browser.close()
return {
"success": True,
@@ -165,8 +182,20 @@ class XiaohongshuUploader(BaseUploader):
"message": f"上传失败: {str(e)}",
"url": None
}
finally:
# 确保资源释放
if context:
try:
await context.close()
except Exception:
pass
if browser:
try:
await browser.close()
except Exception:
pass
async def main(self):
async def main(self) -> Dict[str, Any]:
"""Execute upload"""
async with async_playwright() as playwright:
return await self.upload(playwright)