diff --git a/backend/app/api/publish.py b/backend/app/api/publish.py index 9bd103b..c1dcc2d 100644 --- a/backend/app/api/publish.py +++ b/backend/app/api/publish.py @@ -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"): diff --git a/backend/app/services/publish_service.py b/backend/app/services/publish_service.py index bcdb693..72fc950 100644 --- a/backend/app/services/publish_service.py +++ b/backend/app/services/publish_service.py @@ -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) diff --git a/backend/app/services/qr_login_service.py b/backend/app/services/qr_login_service.py index 4826d04..7ccb010 100644 --- a/backend/app/services/qr_login_service.py +++ b/backend/app/services/qr_login_service.py @@ -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, diff --git a/backend/app/services/uploader/base_uploader.py b/backend/app/services/uploader/base_uploader.py index ba90e4c..9c7df4b 100644 --- a/backend/app/services/uploader/base_uploader.py +++ b/backend/app/services/uploader/base_uploader.py @@ -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 diff --git a/backend/app/services/uploader/bilibili_uploader.py b/backend/app/services/uploader/bilibili_uploader.py index e190001..5214931 100644 --- a/backend/app/services/uploader/bilibili_uploader.py +++ b/backend/app/services/uploader/bilibili_uploader.py @@ -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}", diff --git a/backend/app/services/uploader/douyin_uploader.py b/backend/app/services/uploader/douyin_uploader.py index 61e0b2a..5c9f840 100644 --- a/backend/app/services/uploader/douyin_uploader.py +++ b/backend/app/services/uploader/douyin_uploader.py @@ -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) diff --git a/backend/app/services/uploader/xiaohongshu_uploader.py b/backend/app/services/uploader/xiaohongshu_uploader.py index b5e0248..a68cabe 100644 --- a/backend/app/services/uploader/xiaohongshu_uploader.py +++ b/backend/app/services/uploader/xiaohongshu_uploader.py @@ -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)