""" 抖音适配器实现 """ import asyncio from typing import Optional, List, Dict, Any from pathlib import Path from datetime import datetime from playwright.async_api import Page from ..base_adapter import BaseAdapter from ...core.models import ( PlatformType, AccountInfo, Content, VideoContent, PublishResult, UploadStatus, PublishStatus ) from ...auth.douyin_auth import DouyinAuth from ...config.platform_config import get_platform_url, get_selectors, get_wait_times, get_platform_config from ...utils.browser import browser_manager from ...utils.human_behavior import HumanBehaviorSimulator from ...utils.logger import get_platform_logger, get_task_logger from ...utils.exceptions import ( UploadFailedError, ContentRejectedError, ElementNotFoundError, TimeoutError, ValidationError ) logger = get_platform_logger("douyin") class DouyinAdapter(BaseAdapter): """抖音适配器""" def __init__(self): super().__init__(PlatformType.DOUYIN) self.auth = DouyinAuth() self.human_behavior = HumanBehaviorSimulator() self.config = get_platform_config(PlatformType.DOUYIN) def get_authenticator(self): """获取认证器实例""" return self.auth async def login(self, account_info: AccountInfo, headless: bool = False) -> bool: """登录抖音""" return await self.auth.login(account_info, headless) async def check_login_status(self, page: Page) -> bool: """检查抖音登录状态""" return await self.auth.check_login_status(page) async def publish_content( self, page: Page, content: Content, account_info: AccountInfo ) -> PublishResult: """ 发布内容到抖音 Args: page: Playwright页面对象 content: 要发布的内容 account_info: 账号信息 Returns: 发布结果 """ task_logger = get_task_logger( f"douyin_{datetime.now().strftime('%Y%m%d_%H%M%S')}", self.platform_name, account_info.username ) start_time = asyncio.get_event_loop().time() task_logger.start(f"开始发布内容: {content.title}") try: # 验证内容 is_valid, error_msg = await self.validate_content(content) if not is_valid: raise ValidationError(error_msg) # 抖音目前只支持视频内容 if not isinstance(content, VideoContent): raise ValidationError(f"抖音只支持视频内容,不支持: {type(content)}") result = await self._publish_video(page, content, account_info, task_logger) # 计算耗时 duration = asyncio.get_event_loop().time() - start_time result.duration = duration if result.success: task_logger.success(f"视频发布成功,耗时: {duration:.2f}秒") else: task_logger.failure(f"视频发布失败: {result.message}") return result except Exception as e: duration = asyncio.get_event_loop().time() - start_time error_msg = f"发布过程异常: {str(e)}" task_logger.failure(error_msg) return self.create_publish_result( success=False, message=error_msg, task_id=task_logger.task_id, account=account_info.username, error_details={"exception": str(e), "type": type(e).__name__}, duration=duration ) async def validate_content(self, content: Content) -> tuple[bool, str]: """验证抖音内容""" # 基础验证 is_valid, error_msg = await super().validate_content(content) if not is_valid: return False, error_msg # 抖音特定验证 - 只支持视频内容 if not isinstance(content, VideoContent): return False, "抖音只支持视频内容" # 检查视频文件 path = Path(content.video_path) if not path.exists(): return False, f"视频文件不存在: {content.video_path}" file_size = path.stat().st_size if file_size > self.config.max_file_size: return False, f"视频文件过大: {content.video_path} (最大支持 {self.config.max_file_size // (1024*1024*1024)}GB)" # 检查视频格式 file_extension = path.suffix.lower().lstrip('.') if file_extension not in self.config.supported_formats: return False, f"不支持的视频格式: {file_extension},支持的格式: {', '.join(self.config.supported_formats)}" # 检查视频时长(如果可用) if content.duration and content.duration > self.config.max_duration: return False, f"视频时长过长: {content.duration}秒 (最大支持 {self.config.max_duration}秒)" return True, "" async def _publish_video( self, page: Page, content: VideoContent, account_info: AccountInfo, task_logger ) -> PublishResult: """发布视频到抖音""" try: task_logger.progress("开始发布视频") # 导航到视频上传页面 upload_url = self.config.extra_config.get("upload_url") if upload_url: if not await self.auth.safe_goto(page, upload_url): raise ElementNotFoundError("无法访问视频上传页面") await asyncio.sleep(3) # 上传视频 task_logger.progress("开始上传视频") upload_success = await self._upload_video(page, content.video_path, task_logger) if not upload_success: raise UploadFailedError("视频上传失败") # 填写视频标题 task_logger.progress("填写视频标题") selectors = get_selectors(PlatformType.DOUYIN, "publish", "video") title_selector = selectors.get("title_input") if title_selector: await self.human_behavior.human_type(page, title_selector, content.title) await asyncio.sleep(1) # 填写视频描述 if content.description: task_logger.progress("填写视频描述") desc_selector = selectors.get("description_input") if desc_selector: await self.human_behavior.human_type(page, desc_selector, content.description) await asyncio.sleep(1) # 添加标签 if content.tags: task_logger.progress("添加标签") await self._add_tags(page, content.tags, selectors.get("tag_input")) # 发布视频 task_logger.progress("发布视频") publish_success = await self._publish_video_final(page, task_logger) if publish_success: return self.create_publish_result( success=True, message="视频发布成功", task_id=task_logger.task_id, account=account_info.username ) else: return self.create_publish_result( success=False, message="视频发布失败", task_id=task_logger.task_id, account=account_info.username ) except Exception as e: task_logger.failure(f"视频发布异常: {e}") return self.create_publish_result( success=False, message=f"视频发布失败: {str(e)}", task_id=task_logger.task_id, account=account_info.username, error_details={"exception": str(e)} ) async def _upload_video( self, page: Page, video_path: str, task_logger ) -> bool: """上传视频""" try: selectors = get_selectors(PlatformType.DOUYIN, "publish", "video") upload_selector = selectors.get("video_upload") if not upload_selector: # 尝试其他可能的上传选择器 alternative_selectors = [ "input[type='file']", "input[accept*='video']", "[data-testid='video-upload']", ".upload-btn input", "[class*='upload'] input", ".drag-upload-area input", "[data-testid='upload-input']" ] for selector in alternative_selectors: try: element = await page.wait_for_selector(selector, timeout=5000) if element: upload_selector = selector break except: continue if not upload_selector: raise ElementNotFoundError("未找到视频上传元素") # 找到file input元素 upload_input = await page.wait_for_selector(upload_selector, timeout=10000) if not upload_input: raise ElementNotFoundError("视频上传元素不可用") # 上传视频文件 await upload_input.set_input_files(video_path) task_logger.progress("已选择视频文件") # 等待上传完成 wait_times = get_wait_times(PlatformType.DOUYIN, "publish") upload_timeout = wait_times.get("video_upload", 180) task_logger.progress("等待视频上传完成") upload_complete = await self._wait_for_video_upload(page, upload_timeout) if upload_complete: task_logger.progress("视频上传完成") return True else: task_logger.warning("视频上传超时") return False except Exception as e: task_logger.error(f"视频上传异常: {e}") return False async def _wait_for_video_upload(self, page: Page, timeout: int = 180) -> bool: """等待视频上传完成""" start_time = asyncio.get_event_loop().time() # 可能的上传完成指示器 success_indicators = [ ".upload-success", "[data-testid='upload-success']", ".video-uploaded", "[class*='success']", ".upload-complete", ".progress-100", ".upload-finish" ] while asyncio.get_event_loop().time() - start_time < timeout: try: # 检查成功指示器 for indicator in success_indicators: try: element = await page.wait_for_selector(indicator, timeout=3000) if element and await element.is_visible(): return True except: continue # 检查进度条是否达到100% progress_selectors = [ ".upload-progress", "[data-testid='upload-progress']", ".progress-bar", ".upload-progress-bar", "[class*='progress']" ] for selector in progress_selectors: try: element = await page.wait_for_selector(selector, timeout=2000) if element: # 检查进度是否完成 text = await element.inner_text() if "100%" in text or "完成" in text or "上传完成" in text: return True # 检查进度条属性 style = await element.get_attribute("style") if style and "width: 100%" in style: return True except: continue # 检查是否有错误提示 error_indicators = [ ".upload-error", "[data-testid='upload-error']", ".error-message", "[class*='error']", ".upload-failed" ] for indicator in error_indicators: try: element = await page.wait_for_selector(indicator, timeout=2000) if element and await element.is_visible(): logger.warning(f"检测到上传错误: {indicator}") return False except: continue await asyncio.sleep(3) except Exception: await asyncio.sleep(3) return False async def _add_tags(self, page: Page, tags: List[str], tag_selector: Optional[str]): """添加标签""" if not tags or not tag_selector: return try: for tag in tags: # 点击标签输入框 await self.human_behavior.human_click(page, tag_selector) await asyncio.sleep(0.5) # 输入标签(抖音使用#开头) tag_text = tag if tag.startswith('#') else f"#{tag}" await self.human_behavior.human_type(page, tag_selector, tag_text) await asyncio.sleep(0.5) # 按回车或空格确认标签 await page.keyboard.press("Enter") await asyncio.sleep(0.5) except Exception as e: logger.warning(f"添加标签失败: {e}") async def _publish_video_final(self, page: Page, task_logger) -> bool: """发布视频""" try: selectors = get_selectors(PlatformType.DOUYIN, "publish", "video") publish_button_selector = selectors.get("publish_button") if not publish_button_selector: # 尝试其他可能的发布按钮选择器 alternative_selectors = [ "button[type='submit']", "[data-testid='publish-btn']", ".publish-btn", "button:has-text('发布')", "button:has-text('提交')", "button:has-text('立即发布')", "[class*='publish'] button", ".submit-btn" ] for selector in alternative_selectors: try: element = await page.wait_for_selector(selector, timeout=5000) if element and await element.is_visible(): publish_button_selector = selector break except: continue if not publish_button_selector: raise ElementNotFoundError("未找到发布按钮") # 滚动到发布按钮 await page.scroll_into_view_if_needed(publish_button_selector) await asyncio.sleep(1) # 点击发布按钮 await self.human_behavior.human_click(page, publish_button_selector) task_logger.progress("已点击发布按钮") # 等待发布完成 wait_times = get_wait_times(PlatformType.DOUYIN, "publish") publish_timeout = wait_times.get("publish_success", 15) task_logger.progress("等待发布完成") return await self._wait_for_publish_success(page, publish_timeout) except Exception as e: task_logger.error(f"发布视频失败: {e}") return False async def _wait_for_publish_success(self, page: Page, timeout: int = 15) -> bool: """等待发布成功""" start_time = asyncio.get_event_loop().time() # 可能的成功指示器 success_indicators = [ ".publish-success", "[data-testid='publish-success']", ".success-message", "[class*='success']", ".upload-success", "发布成功", "提交成功", "上传成功" ] # 可能的页面跳转指示器 url_indicators = [ "manage", "content", "success" ] while asyncio.get_event_loop().time() - start_time < timeout: try: # 检查成功指示器 for indicator in success_indicators: if indicator.startswith('.') or indicator.startswith('[') or indicator.startswith('text='): # CSS选择器或文本 try: if indicator.startswith('text='): if await page.locator(indicator).count() > 0: return True else: element = await page.wait_for_selector(indicator, timeout=2000) if element and await element.is_visible(): return True except: continue # 检查URL变化 current_url = page.url.lower() for indicator in url_indicators: if indicator in current_url: return True await asyncio.sleep(1) except Exception: await asyncio.sleep(1) return False async def get_upload_progress(self, page: Page) -> float: """ 获取上传进度 Args: page: Playwright页面对象 Returns: 上传进度 (0-100) """ try: progress_selectors = [ ".upload-progress", "[data-testid='upload-progress']", ".progress-bar", ".upload-progress-bar" ] for selector in progress_selectors: try: element = await page.wait_for_selector(selector, timeout=2000) if element: # 尝试获取文本内容 text = await element.inner_text() if '%' in text: # 提取百分比数字 import re match = re.search(r'(\d+)%', text) if match: return float(match.group(1)) # 尝试获取样式属性 style = await element.get_attribute("style") if style and "width:" in style: import re match = re.search(r'width:\s*(\d+(?:\.\d+)?)%', style) if match: return float(match.group(1)) except: continue except Exception as e: logger.debug(f"获取上传进度失败: {e}") return 0.0 async def cancel_upload(self, page: Page) -> bool: """ 取消上传 Args: page: Playwright页面对象 Returns: 取消是否成功 """ try: cancel_selectors = [ ".cancel-upload", "[data-testid='cancel-upload']", ".upload-cancel", "button:has-text('取消')", "button:has-text('停止')" ] for selector in cancel_selectors: try: element = await page.wait_for_selector(selector, timeout=3000) if element and await element.is_visible(): await element.click() logger.info("上传已取消") return True except: continue return False except Exception as e: logger.error(f"取消上传失败: {e}") return False