202 lines
8.1 KiB
Python
202 lines
8.1 KiB
Python
"""
|
|
Xiaohongshu (小红书) 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
|
|
|
|
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 XiaohongshuUploader(BaseUploader):
|
|
"""Xiaohongshu video uploader using Playwright"""
|
|
|
|
# 超时配置 (秒)
|
|
UPLOAD_TIMEOUT = 300 # 视频上传超时
|
|
PUBLISH_TIMEOUT = 120 # 发布检测超时
|
|
POLL_INTERVAL = 1 # 轮询间隔
|
|
|
|
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.xiaohongshu.com/publish/publish?from=homepage&target=video"
|
|
|
|
async def set_schedule_time(self, page, publish_date):
|
|
"""Set scheduled publish time"""
|
|
try:
|
|
logger.info("[小红书] 正在设置定时发布时间...")
|
|
|
|
# Click "定时发布" label
|
|
label_element = page.locator("label: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('.el-input__inner[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 (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
|
|
)
|
|
context = await set_init_script(context)
|
|
|
|
page = await context.new_page()
|
|
|
|
# Go to upload page
|
|
await page.goto(self.upload_url)
|
|
logger.info(f"[小红书] 正在上传: {self.file_path.name}")
|
|
|
|
# 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 (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(
|
|
'xpath=following-sibling::div[contains(@class, "preview-new")]'
|
|
)
|
|
|
|
if preview_new:
|
|
stage_elements = await preview_new.query_selector_all('div.stage')
|
|
upload_success = False
|
|
|
|
for stage in stage_elements:
|
|
text_content = await page.evaluate('(element) => element.textContent', stage)
|
|
if '上传成功' in text_content:
|
|
upload_success = True
|
|
break
|
|
|
|
if upload_success:
|
|
logger.info("[小红书] 检测到上传成功标识")
|
|
break
|
|
else:
|
|
logger.info("[小红书] 未找到上传成功标识,继续等待...")
|
|
else:
|
|
logger.info("[小红书] 未找到预览元素,继续等待...")
|
|
|
|
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)
|
|
logger.info("[小红书] 正在填充标题和话题...")
|
|
|
|
title_container = page.locator('div.plugin.title-container').locator('input.d-text')
|
|
if await title_container.count():
|
|
await title_container.fill(self.title[:30])
|
|
|
|
# Add tags
|
|
css_selector = ".tiptap"
|
|
for tag in self.tags:
|
|
await page.type(css_selector, "#" + tag)
|
|
await page.press(css_selector, "Space")
|
|
|
|
logger.info(f"[小红书] 总共添加 {len(self.tags)} 个话题")
|
|
|
|
# Set scheduled publish time if needed
|
|
if self.publish_date != 0:
|
|
await self.set_schedule_time(page, self.publish_date)
|
|
|
|
# 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()
|
|
else:
|
|
await page.locator('button:has-text("发布")').click()
|
|
|
|
await page.wait_for_url(
|
|
"https://creator.xiaohongshu.com/publish/success?**",
|
|
timeout=3000
|
|
)
|
|
logger.success("[小红书] 视频发布成功")
|
|
break
|
|
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)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "发布成功,待审核" if self.publish_date == 0 else "已设置定时发布",
|
|
"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)
|