2025-11-12 00:28:07 +08:00

628 lines
22 KiB
Python

"""
小红书适配器实现
"""
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,
ImageNote,
VideoContent,
PublishResult,
UploadStatus,
PublishStatus
)
from ...auth.xiaohongshu_auth import XiaoHongShuAuth
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("xiaohongshu")
class XiaoHongShuAdapter(BaseAdapter):
"""小红书适配器"""
def __init__(self):
super().__init__(PlatformType.XIAOHONGSHU)
self.auth = XiaoHongShuAuth()
self.human_behavior = HumanBehaviorSimulator()
self.config = get_platform_config(PlatformType.XIAOHONGSHU)
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"xhs_{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 isinstance(content, ImageNote):
result = await self._publish_image_note(page, content, account_info, task_logger)
elif isinstance(content, VideoContent):
result = await self._publish_video_note(page, content, account_info, task_logger)
else:
raise ValidationError(f"不支持的内容类型: {type(content)}")
# 计算耗时
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 isinstance(content, ImageNote):
if not content.images:
return False, "图文笔记必须包含图片"
if len(content.images) > self.config.extra_config.get("max_images", 9):
return False, f"图片数量不能超过{self.config.extra_config.get('max_images', 9)}"
# 检查图片文件
for img_path in content.images:
path = Path(img_path)
if not path.exists():
return False, f"图片文件不存在: {img_path}"
file_size = path.stat().st_size
if file_size > self.config.max_file_size:
return False, f"图片文件过大: {img_path}"
elif isinstance(content, VideoContent):
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}"
return True, ""
async def _publish_image_note(
self,
page: Page,
content: ImageNote,
account_info: AccountInfo,
task_logger
) -> PublishResult:
"""发布图文笔记"""
try:
task_logger.progress("开始发布图文笔记")
# 导航到图文笔记发布页面
image_note_url = self.config.extra_config.get("image_note_url")
if image_note_url:
if not await self.auth.safe_goto(page, image_note_url):
raise ElementNotFoundError("无法访问图文笔记发布页面")
await asyncio.sleep(3)
# 上传图片
task_logger.progress("开始上传图片")
upload_success = await self._upload_images(page, content.images, task_logger)
if not upload_success:
raise UploadFailedError("图片上传失败")
# 填写标题
task_logger.progress("填写标题")
selectors = get_selectors(PlatformType.XIAOHONGSHU, "publish", "image_note")
title_selector = selectors.get("title_input")
if title_selector:
await self.human_behavior.human_type(page, title_selector, content.title)
await asyncio.sleep(1)
# 填写内容描述
task_logger.progress("填写内容描述")
content_selector = selectors.get("content_input")
if content_selector:
await self.human_behavior.human_type(page, content_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_note(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 _publish_video_note(
self,
page: Page,
content: VideoContent,
account_info: AccountInfo,
task_logger
) -> PublishResult:
"""发布视频笔记"""
try:
task_logger.progress("开始发布视频笔记")
# 导航到视频笔记发布页面
video_note_url = self.config.extra_config.get("video_note_url")
if video_note_url:
if not await self.auth.safe_goto(page, video_note_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.XIAOHONGSHU, "publish", "video_note")
title_selector = selectors.get("title_input")
if title_selector:
await self.human_behavior.human_type(page, title_selector, content.title)
await asyncio.sleep(1)
# 填写内容描述
task_logger.progress("填写内容描述")
content_selector = selectors.get("content_input")
if content_selector:
await self.human_behavior.human_type(page, content_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_note(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_images(
self,
page: Page,
image_paths: List[str],
task_logger
) -> bool:
"""上传图片"""
try:
selectors = get_selectors(PlatformType.XIAOHONGSHU, "publish", "image_note")
upload_selector = selectors.get("image_upload")
if not upload_selector:
# 尝试其他可能的上传选择器
alternative_selectors = [
"input[type='file']",
"input[accept*='image']",
"[data-testid='image-upload']",
".upload-btn input",
"[class*='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(image_paths)
task_logger.progress(f"已选择 {len(image_paths)} 张图片")
# 等待上传完成
wait_times = get_wait_times(PlatformType.XIAOHONGSHU, "publish")
upload_timeout = wait_times.get("image_upload", 30)
task_logger.progress("等待图片上传完成")
upload_complete = await self._wait_for_image_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 _upload_video(
self,
page: Page,
video_path: str,
task_logger
) -> bool:
"""上传视频"""
try:
selectors = get_selectors(PlatformType.XIAOHONGSHU, "publish", "video_note")
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"
]
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.XIAOHONGSHU, "publish")
upload_timeout = wait_times.get("video_upload", 120)
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_image_upload(self, page: Page, timeout: int = 30) -> bool:
"""等待图片上传完成"""
start_time = asyncio.get_event_loop().time()
# 可能的上传完成指示器
success_indicators = [
".upload-success",
"[data-testid='upload-success']",
".image-uploaded",
"[class*='success']",
".upload-complete"
]
while asyncio.get_event_loop().time() - start_time < timeout:
try:
# 检查成功指示器
for indicator in success_indicators:
try:
element = await page.wait_for_selector(indicator, timeout=2000)
if element and await element.is_visible():
return True
except:
continue
# 检查是否有错误提示
error_indicators = [
".upload-error",
"[data-testid='upload-error']",
".error-message",
"[class*='error']"
]
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(2)
except Exception:
await asyncio.sleep(2)
return False
async def _wait_for_video_upload(self, page: Page, timeout: int = 120) -> bool:
"""等待视频上传完成"""
start_time = asyncio.get_event_loop().time()
# 可能的上传完成指示器
success_indicators = [
".upload-success",
"[data-testid='upload-success']",
".video-uploaded",
"[class*='success']",
".upload-complete",
".progress-100"
]
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"
]
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:
return True
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)
# 输入标签
await self.human_behavior.human_type(page, tag_selector, f"#{tag}")
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_note(self, page: Page, task_logger) -> bool:
"""发布笔记"""
try:
selectors = get_selectors(PlatformType.XIAOHONGSHU, "publish", "image_note")
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('提交')",
"[class*='publish'] button"
]
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 self.human_behavior.human_click(page, publish_button_selector)
task_logger.progress("已点击发布按钮")
# 等待发布完成
wait_times = get_wait_times(PlatformType.XIAOHONGSHU, "publish")
publish_timeout = wait_times.get("publish_success", 10)
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 = 10) -> bool:
"""等待发布成功"""
start_time = asyncio.get_event_loop().time()
# 可能的成功指示器
success_indicators = [
".publish-success",
"[data-testid='publish-success']",
".success-message",
"[class*='success']",
"发布成功",
"提交成功"
]
while asyncio.get_event_loop().time() - start_time < timeout:
try:
# 检查成功指示器
for indicator in success_indicators:
if indicator.startswith('.') or indicator.startswith('['):
# CSS选择器
try:
element = await page.wait_for_selector(indicator, timeout=2000)
if element and await element.is_visible():
return True
except:
continue
else:
# 文本内容
if await page.locator(f"text={indicator}").count() > 0:
return True
await asyncio.sleep(1)
except Exception:
await asyncio.sleep(1)
return False