小红书重构

1. 图文上传
2. 视频上传(封面,定时)
3. cli
4. skill
5. 无头模式
This commit is contained in:
home-dev-pookz
2026-03-25 15:19:40 +08:00
parent ce9a9f102e
commit 90d03dc15a
32 changed files with 1325 additions and 98 deletions
+17 -8
View File
@@ -26,7 +26,7 @@
| --- | --- | --- | --- | --- | --- | --- | --- |
| 抖音 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 当前主线重构最完整 |
| Bilibili | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | 运行时自动准备 `biliup` |
| 小红书(浏览器版) | ✅ | ✅ | | ✅ | ❌ | ❌ | 当前仓库有浏览器 uploader |
| 小红书(浏览器版) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 浏览器自动化,CLI/Skill 已接入 |
| 快手 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 浏览器自动化,CLI/Skill 初版已接入 |
| 视频号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 对应 `tencent_uploader` |
| 百家号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 浏览器自动化 |
@@ -49,6 +49,7 @@
- CLI 使用请看:[CLI 使用说明](./docs/CLI.md)
- agent / skill 请看:[Douyin Upload Skill](./skills/douyin-upload/SKILL.md)
- agent / skill 请看:[Kuaishou Upload Skill](./skills/kuaishou-upload/SKILL.md)
- agent / skill 请看:[Xiaohongshu Upload Skill](./skills/xiaohongshu-upload/SKILL.md)
- agent / skill 请看:[Bilibili Upload Skill](./skills/bilibili-upload/SKILL.md)
- 历史 Web 说明请看:[历史 Web 版本说明](./docs/legacy-web.md)
- 其他单平台 skill 与整合型 skill 仍在开发中
@@ -93,18 +94,23 @@ Web 端相关代码仍然保留,但已经不是当前主线,不保证可直
### 方式 1:使用 CLI
当前抖音、快手、Bilibili 已经接入 CLI
当前抖音、快手、小红书、Bilibili 已经接入 CLI
```bash
sau douyin login --account <account_name>
sau douyin check --account <account_name>
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题"
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --note "图文示例"
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介"
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --title "图文标题" --note "图文正文"
sau kuaishou login --account <account_name>
sau kuaishou check --account <account_name>
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题"
sau kuaishou upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --note "图文示例"
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介"
sau kuaishou upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文正文"
sau xiaohongshu login --account <account_name>
sau xiaohongshu check --account <account_name>
sau xiaohongshu upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介"
sau xiaohongshu upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文正文"
sau bilibili login --account <account_name>
sau bilibili check --account <account_name>
@@ -115,6 +121,9 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
- `creator` 之类的名字只是示例值,真正含义是 `account_name`
- 一个 `account_name` 对应一个账号文件,可以准备多个账号,也可以按账号名并发执行任务
- 浏览器平台统一约定:
- 视频使用 `title + desc + tags`
- 图文使用 `title + note + tags`
- Bilibili CLI 不要求用户手动安装 `biliup`
- 首次运行相关命令时,程序会自动下载 `biliup`
- 后续运行会自动检查上游 release 并更新
@@ -127,8 +136,8 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
- 当前主线 CLI 包装示例
- 历史直连 uploader 示例
对抖音、快手、Bilibili 来说,当前主线优先使用上面的 `sau ...` CLI。
下面这些脚本里,`upload_to_douyin.py``upload_to_kuaishou.py` 属于历史直连 uploader 示例:
对抖音、快手、小红书、Bilibili 来说,当前主线优先使用上面的 `sau ...` CLI。
下面这些脚本主要是历史直连 uploader 示例或调试入口
- `examples/upload_to_douyin.py`
- `examples/upload_video_to_bilibili.py`
+30 -11
View File
@@ -4,6 +4,7 @@
- `douyin`
- `kuaishou`
- `xiaohongshu`
- `bilibili`
实现说明:
@@ -13,6 +14,7 @@
- 如果需要给 OpenClaw、Codex 等 agent 使用,可参考仓库内 skill:
- `skills/douyin-upload/`
- `skills/kuaishou-upload/`
- `skills/xiaohongshu-upload/`
- `skills/bilibili-upload/`
## 安装 CLI 入口
@@ -28,6 +30,7 @@ uv pip install -e .
```bash
sau douyin --help
sau kuaishou --help
sau xiaohongshu --help
sau bilibili --help
```
@@ -45,8 +48,8 @@ $env:PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright"; patchr
sau douyin login --account <account_name>
sau douyin login --account <account_name> --headless
sau douyin check --account <account_name>
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --tags 运动,训练
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --note "图文示例" --tags 图文,测试
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --tags 运动,训练
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --title "图文标题" --note "图文示例" --tags 图文,测试
```
## 快手 CLI 子命令
@@ -54,8 +57,17 @@ sau douyin upload-note --account <account_name> --images videos/1.png videos/2.p
```bash
sau kuaishou login --account <account_name>
sau kuaishou check --account <account_name>
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --tags 运动,训练
sau kuaishou upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --note "图文示例" --tags 图文,测试
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --tags 运动,训练
sau kuaishou upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文示例" --tags 图文,测试
```
## 小红书 CLI 子命令
```bash
sau xiaohongshu login --account <account_name>
sau xiaohongshu check --account <account_name>
sau xiaohongshu upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --tags 小红书,视频
sau xiaohongshu upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文示例" --tags 图文,测试
```
## Bilibili CLI 子命令
@@ -70,6 +82,9 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
- `creator` 之类的名字只是示例值,真正传的是用户自定义的 `account_name`
- 一个 `account_name` 对应一个账号文件,可以准备多个账号并发使用
- 浏览器平台统一元数据约定:
- 视频使用 `title + desc + tags`
- 图文使用 `title + note + tags`
- `sau bilibili ...` 会自动准备 `biliup`
- 如果本地没有 `biliup`,第一次运行会自动下载
- 如果上游 GitHub Release 有更新,运行时会先自动更新
@@ -77,7 +92,7 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
## 登录二维码说明
- 抖音快手登录过程中,CLI / uploader 可能会生成临时二维码图片
- 抖音快手、小红书登录过程中,CLI / uploader 可能会生成临时二维码图片
- 对普通用户来说,可以直接打开该图片扫码
- 对可操作本地文件的 agent 来说,不要只把图片路径告诉用户
- 这类二维码图片本身就是给用户扫码的,agent 应优先直接展示/发送本地图片给用户
@@ -85,13 +100,15 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
## 定时发布
抖音、快手、Bilibili 的视频上传都支持 `--schedule`。只要传了 `--schedule`,CLI 就会自动切换到对应平台的定时发布策略;不传则默认立即发布。
抖音、快手、小红书的图文和视频上传,以及 Bilibili 的视频上传都支持 `--schedule`。只要传了 `--schedule`,CLI 就会自动切换到对应平台的定时发布策略;不传则默认立即发布。
```bash
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --schedule "2026-03-24 21:30"
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --note "图文示例" --schedule "2026-03-24 21:30"
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --schedule "2026-03-24 21:30"
sau kuaishou upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --note "图文示例" --schedule "2026-03-24 21:30"
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --schedule "2026-03-24 21:30"
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --title "图文标题" --note "图文示例" --schedule "2026-03-24 21:30"
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --schedule "2026-03-24 21:30"
sau kuaishou upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文示例" --schedule "2026-03-24 21:30"
sau xiaohongshu upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --schedule "2026-03-24 21:30"
sau xiaohongshu upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文示例" --schedule "2026-03-24 21:30"
sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --tid 249 --schedule "2026-03-24 21:30"
```
@@ -121,6 +138,7 @@ CLI 将 `debug` 和 `headless` 拆成了两个独立维度:
```bash
--file videos/demo.mp4
--title "示例标题"
--desc "示例简介"
--tags 运动,训练
--thumbnail videos/demo.png
```
@@ -135,7 +153,6 @@ CLI 将 `debug` 和 `headless` 拆成了两个独立维度:
Bilibili 额外要求:
```bash
--desc "视频简介"
--tid 249
```
@@ -147,6 +164,7 @@ Bilibili 额外要求:
```bash
--images videos/1.png videos/2.png videos/3.png
--title "图文标题"
--note "图文内容"
--tags 图文,测试
```
@@ -155,5 +173,6 @@ Bilibili 额外要求:
- 抖音:最多 35 张图片,不支持 GIF
- 快手:支持多张图片,建议传真实不同文件,不要把同一路径重复多次
- 小红书:支持多张图片,正文 `--note` 可选,但 `--title` 建议始终显式传入
后续维护 CLI 时,优先看 `sau_cli.py``uploader/``skills/`
+26 -4
View File
@@ -84,6 +84,7 @@ Windows 也可以直接手动复制并重命名。
sau --help
sau douyin --help
sau kuaishou --help
sau xiaohongshu --help
sau bilibili --help
```
@@ -97,7 +98,8 @@ sau bilibili --help
```bash
sau douyin login --account <account_name>
sau douyin check --account <account_name>
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题"
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介"
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --title "图文标题" --note "图文正文"
```
### 8. 快手主线示例
@@ -105,11 +107,20 @@ sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title
```bash
sau kuaishou login --account <account_name>
sau kuaishou check --account <account_name>
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题"
sau kuaishou upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --note "图文示例"
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介"
sau kuaishou upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文正文"
```
### 9. Bilibili 主线示例
### 9. 小红书主线示例
```bash
sau xiaohongshu login --account <account_name>
sau xiaohongshu check --account <account_name>
sau xiaohongshu upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介"
sau xiaohongshu upload-note --account <account_name> --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文正文"
```
### 10. Bilibili 主线示例
```bash
sau bilibili login --account <account_name>
@@ -121,6 +132,9 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
- `creator` 之类的名字只是示例值,真正传的是用户自定义的 `account_name`
- 一个 `account_name` 对应一个账号文件,可以准备多个账号并发使用
- 浏览器平台统一元数据约定:
- 视频使用 `title + desc + tags`
- 图文使用 `title + note + tags`
- 用户不需要手动安装 `biliup`
- 首次运行 Bilibili 相关命令时,程序会自动下载 `biliup`
- 后续运行会自动检查上游 release 并自动更新
@@ -161,6 +175,7 @@ PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright i
sau --help
sau douyin --help
sau kuaishou --help
sau xiaohongshu --help
sau bilibili --help
```
@@ -177,6 +192,11 @@ sau kuaishou check
sau kuaishou upload-video
sau kuaishou upload-note
sau xiaohongshu login
sau xiaohongshu check
sau xiaohongshu upload-video
sau xiaohongshu upload-note
sau bilibili login
sau bilibili check
sau bilibili upload-video
@@ -188,6 +208,8 @@ sau bilibili upload-video
- `skills/douyin-upload/references/cli-contract.md`
- `skills/kuaishou-upload/SKILL.md`
- `skills/kuaishou-upload/references/cli-contract.md`
- `skills/xiaohongshu-upload/SKILL.md`
- `skills/xiaohongshu-upload/references/cli-contract.md`
- `skills/bilibili-upload/SKILL.md`
- `skills/bilibili-upload/references/cli-contract.md`
@@ -0,0 +1,47 @@
# Xiaohongshu Shallow Alignment Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Align the browser-based Xiaohongshu uploader with the current Douyin/Kuaishou style for login QR flow, patchright usage, and formal video/note entry points without expanding into CLI or docs work.
**Architecture:** Keep the work inside `uploader/xiaohongshu_uploader/main.py` as a shallow refactor. Introduce a thin login flow with QR extraction, cookie validation, and result payloads, then add a shared base uploader plus separate video and note browser flows that reuse common setup.
**Tech Stack:** Python, patchright, async Playwright-compatible browser automation, unittest
---
### Task 1: Lock expected login/setup behavior
**Files:**
- Create: `tests/test_xiaohongshu_uploader.py`
- Modify: `uploader/xiaohongshu_uploader/main.py`
- [ ] Add tests for `xiaohongshu_setup(..., return_detail=True)` invalid-cookie behavior and uploader validation behavior.
- [ ] Run the targeted test file and confirm it fails for the expected missing behavior.
- [ ] Implement the minimal production changes to satisfy those tests.
### Task 2: Refactor Xiaohongshu uploader module
**Files:**
- Modify: `uploader/xiaohongshu_uploader/main.py`
- [ ] Switch the module from `playwright.async_api` to `patchright.async_api`.
- [ ] Replace `page.pause()` login with QR extraction, terminal printing, QR file saving, polling, and cookie persistence.
- [ ] Add a shared base uploader plus formal video and note entry points while keeping legacy public names callable.
### Task 3: Refresh local debug examples
**Files:**
- Modify: `examples/get_xiaohongshu_cookie.py`
- Modify: `examples/upload_video_to_xiaohongshu.py`
- [ ] Update the login example to use the aligned setup entry.
- [ ] Update the upload example to expose direct video/note debug functions.
### Task 4: Verify
**Files:**
- Test: `tests/test_xiaohongshu_uploader.py`
- [ ] Run the targeted unittest file.
- [ ] Run a quick Python syntax/import check for the modified uploader and examples.
+9 -1
View File
@@ -47,9 +47,11 @@ PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright i
sau --help
sau douyin --help
sau kuaishou --help
sau xiaohongshu --help
sau bilibili --help
sau douyin check --account your_account
sau kuaishou check --account your_account
sau xiaohongshu check --account your_account
sau bilibili check --account your_account
```
@@ -57,6 +59,7 @@ sau bilibili check --account your_account
- `skills/douyin-upload/SKILL.md`
- `skills/kuaishou-upload/SKILL.md`
- `skills/xiaohongshu-upload/SKILL.md`
- `skills/bilibili-upload/SKILL.md`
- `docs/CLI.md`
@@ -84,14 +87,16 @@ uv pip install -e .
sau --help
sau douyin --help
sau kuaishou --help
sau xiaohongshu --help
sau bilibili --help
```
5. 如果任务是抖音或快手链路,再继续验证:
5. 如果任务是浏览器平台链路,再继续验证:
```bash
sau douyin check --account test
sau kuaishou check --account test
sau xiaohongshu check --account test
sau bilibili check --account test
```
@@ -103,6 +108,9 @@ sau bilibili check --account test
- `skills/kuaishou-upload/SKILL.md`
- `skills/kuaishou-upload/references/cli-contract.md`
- `skills/kuaishou-upload/references/runtime-requirements.md`
- `skills/xiaohongshu-upload/SKILL.md`
- `skills/xiaohongshu-upload/references/cli-contract.md`
- `skills/xiaohongshu-upload/references/runtime-requirements.md`
- `skills/bilibili-upload/SKILL.md`
- `skills/bilibili-upload/references/cli-contract.md`
- `skills/bilibili-upload/references/runtime-requirements.md`
+8
View File
@@ -1,3 +1,11 @@
"""
当前主线优先使用 CLI
sau xiaohongshu login --account <account_name>
这个脚本保留为小红书 uploader 的调试入口 / 历史直连路径。
"""
import asyncio
from pathlib import Path
+10
View File
@@ -1,3 +1,13 @@
"""
当前主线优先使用 CLI
sau xiaohongshu login --account <account_name>
sau xiaohongshu upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --desc "示例简介"
sau xiaohongshu upload-note --account <account_name> --images videos/1.png videos/2.png --title "图文标题" --note "图文正文"
这个脚本保留为小红书 uploader 的调试入口 / 历史直连路径。
"""
import asyncio
from datetime import datetime, timedelta
from pathlib import Path
+193 -2
View File
@@ -26,6 +26,14 @@ from uploader.ks_uploader.main import (
cookie_auth as kuaishou_cookie_auth,
ks_setup,
)
from uploader.xiaohongshu_uploader.main import (
XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE,
XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED,
XiaoHongShuNote,
XiaoHongShuVideo,
cookie_auth as xiaohongshu_cookie_auth,
xiaohongshu_setup,
)
SCHEDULE_FORMAT = "%Y-%m-%d %H:%M"
@@ -35,6 +43,7 @@ class DouyinVideoUploadRequest:
account_name: str
video_file: Path
title: str
description: str
tags: list[str]
publish_date: datetime | int
thumbnail_file: Path | None = None
@@ -49,6 +58,7 @@ class DouyinVideoUploadRequest:
class DouyinNoteUploadRequest:
account_name: str
image_files: list[Path]
title: str
note: str
tags: list[str]
publish_date: datetime | int
@@ -62,6 +72,7 @@ class KuaishouVideoUploadRequest:
account_name: str
video_file: Path
title: str
description: str
tags: list[str]
publish_date: datetime | int
thumbnail_file: Path | None = None
@@ -74,6 +85,7 @@ class KuaishouVideoUploadRequest:
class KuaishouNoteUploadRequest:
account_name: str
image_files: list[Path]
title: str
note: str
tags: list[str]
publish_date: datetime | int
@@ -82,6 +94,33 @@ class KuaishouNoteUploadRequest:
headless: bool = True
@dataclass(slots=True)
class XiaohongshuVideoUploadRequest:
account_name: str
video_file: Path
title: str
description: str
tags: list[str]
publish_date: datetime | int
thumbnail_file: Path | None = None
publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE
debug: bool = True
headless: bool = True
@dataclass(slots=True)
class XiaohongshuNoteUploadRequest:
account_name: str
image_files: list[Path]
title: str
note: str
tags: list[str]
publish_date: datetime | int
publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE
debug: bool = True
headless: bool = True
@dataclass(slots=True)
class BilibiliVideoUploadRequest:
account_name: str
@@ -153,6 +192,18 @@ async def check_kuaishou_account(account_name: str) -> bool:
return await kuaishou_cookie_auth(str(account_file))
async def login_xiaohongshu_account(account_name: str, headless: bool = True) -> dict:
account_file = resolve_account_file("xiaohongshu", account_name)
return await xiaohongshu_setup(str(account_file), handle=True, return_detail=True, headless=headless)
async def check_xiaohongshu_account(account_name: str) -> bool:
account_file = resolve_account_file("xiaohongshu", account_name)
if not account_file.exists():
return False
return await xiaohongshu_cookie_auth(str(account_file))
async def login_bilibili_account(account_name: str) -> dict:
account_file = resolve_account_file("bilibili", account_name)
if not has_interactive_terminal():
@@ -197,6 +248,7 @@ async def upload_video(request: DouyinVideoUploadRequest) -> Path:
request.tags,
request.publish_date,
str(account_file),
desc=request.description,
thumbnail_portrait_path=str(request.thumbnail_file) if request.thumbnail_file else None,
productLink=request.product_link,
productTitle=request.product_title,
@@ -218,6 +270,7 @@ async def upload_note(request: DouyinNoteUploadRequest) -> Path:
app = DouYinNote(
image_paths=[str(path) for path in request.image_files],
title=request.title,
note=request.note,
tags=request.tags,
publish_date=request.publish_date,
@@ -241,6 +294,7 @@ async def upload_kuaishou_video(request: KuaishouVideoUploadRequest) -> Path:
app = KSVideo(
title=request.title,
file_path=str(request.video_file),
desc=request.description,
tags=request.tags,
publish_date=request.publish_date,
account_file=str(account_file),
@@ -263,6 +317,55 @@ async def upload_kuaishou_note(request: KuaishouNoteUploadRequest) -> Path:
app = KSNote(
image_paths=[str(path) for path in request.image_files],
title=request.title,
note=request.note,
tags=request.tags,
publish_date=request.publish_date,
account_file=str(account_file),
publish_strategy=request.publish_strategy,
debug=request.debug,
headless=request.headless,
)
await app.main()
return account_file
async def upload_xiaohongshu_video(request: XiaohongshuVideoUploadRequest) -> Path:
account_file = resolve_account_file("xiaohongshu", request.account_name)
is_ready = await xiaohongshu_setup(str(account_file), handle=False)
if not is_ready:
raise RuntimeError(
f"Xiaohongshu cookie is missing or expired: {account_file}. Run `sau xiaohongshu login --account {request.account_name}` first."
)
app = XiaoHongShuVideo(
title=request.title,
file_path=str(request.video_file),
desc=request.description,
tags=request.tags,
publish_date=request.publish_date,
account_file=str(account_file),
thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None,
publish_strategy=request.publish_strategy,
debug=request.debug,
headless=request.headless,
)
await app.main()
return account_file
async def upload_xiaohongshu_note(request: XiaohongshuNoteUploadRequest) -> Path:
account_file = resolve_account_file("xiaohongshu", request.account_name)
is_ready = await xiaohongshu_setup(str(account_file), handle=False)
if not is_ready:
raise RuntimeError(
f"Xiaohongshu cookie is missing or expired: {account_file}. Run `sau xiaohongshu login --account {request.account_name}` first."
)
app = XiaoHongShuNote(
image_paths=[str(path) for path in request.image_files],
title=request.title,
desc=request.note,
note=request.note,
tags=request.tags,
publish_date=request.publish_date,
@@ -350,6 +453,7 @@ def build_parser() -> argparse.ArgumentParser:
upload_video_parser.add_argument("--account", required=True, help="Douyin user-defined account_name")
upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
upload_video_parser.add_argument("--title", required=True, help="Video title")
upload_video_parser.add_argument("--desc", default="", help="Optional video description")
upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path")
@@ -360,7 +464,8 @@ def build_parser() -> argparse.ArgumentParser:
upload_note_parser = douyin_actions.add_parser("upload-note", help="Upload one note to Douyin")
upload_note_parser.add_argument("--account", required=True, help="Douyin user-defined account_name")
upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths")
upload_note_parser.add_argument("--note", required=True, help="Note content")
upload_note_parser.add_argument("--title", required=True, help="Note title")
upload_note_parser.add_argument("--note", default="", help="Optional note content")
upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
add_runtime_flags(upload_note_parser)
@@ -378,6 +483,7 @@ def build_parser() -> argparse.ArgumentParser:
kuaishou_upload_video_parser.add_argument("--account", required=True, help="Kuaishou user-defined account_name")
kuaishou_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
kuaishou_upload_video_parser.add_argument("--title", required=True, help="Video title")
kuaishou_upload_video_parser.add_argument("--desc", default="", help="Optional video description")
kuaishou_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
kuaishou_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
kuaishou_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path")
@@ -386,11 +492,40 @@ def build_parser() -> argparse.ArgumentParser:
kuaishou_upload_note_parser = kuaishou_actions.add_parser("upload-note", help="Upload one note to Kuaishou")
kuaishou_upload_note_parser.add_argument("--account", required=True, help="Kuaishou user-defined account_name")
kuaishou_upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths")
kuaishou_upload_note_parser.add_argument("--note", required=True, help="Note content")
kuaishou_upload_note_parser.add_argument("--title", required=True, help="Note title")
kuaishou_upload_note_parser.add_argument("--note", default="", help="Optional note content")
kuaishou_upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
kuaishou_upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
add_runtime_flags(kuaishou_upload_note_parser)
xiaohongshu_parser = platform_parsers.add_parser("xiaohongshu", help="Xiaohongshu operations")
xiaohongshu_actions = xiaohongshu_parser.add_subparsers(dest="action", required=True)
for action_name in ("login", "check"):
action_parser = xiaohongshu_actions.add_parser(action_name, help=f"Xiaohongshu {action_name}")
action_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name")
if action_name == "login":
add_runtime_flags(action_parser)
xiaohongshu_upload_video_parser = xiaohongshu_actions.add_parser("upload-video", help="Upload one video to Xiaohongshu")
xiaohongshu_upload_video_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name")
xiaohongshu_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path")
xiaohongshu_upload_video_parser.add_argument("--title", required=True, help="Video title")
xiaohongshu_upload_video_parser.add_argument("--desc", default="", help="Optional video description")
xiaohongshu_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
xiaohongshu_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
xiaohongshu_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path")
add_runtime_flags(xiaohongshu_upload_video_parser)
xiaohongshu_upload_note_parser = xiaohongshu_actions.add_parser("upload-note", help="Upload one note to Xiaohongshu")
xiaohongshu_upload_note_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name")
xiaohongshu_upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths")
xiaohongshu_upload_note_parser.add_argument("--title", required=True, help="Note title")
xiaohongshu_upload_note_parser.add_argument("--note", default="", help="Optional note content")
xiaohongshu_upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2")
xiaohongshu_upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
add_runtime_flags(xiaohongshu_upload_note_parser)
bilibili_parser = platform_parsers.add_parser("bilibili", help="Bilibili operations")
bilibili_actions = bilibili_parser.add_subparsers(dest="action", required=True)
@@ -430,6 +565,7 @@ async def dispatch(args: argparse.Namespace) -> int:
account_name=args.account,
video_file=args.file,
title=args.title,
description=args.desc,
tags=parse_tags(args.tags),
publish_date=args.schedule or 0,
thumbnail_file=args.thumbnail,
@@ -447,6 +583,7 @@ async def dispatch(args: argparse.Namespace) -> int:
request = DouyinNoteUploadRequest(
account_name=args.account,
image_files=parse_image_files(args.images),
title=args.title,
note=args.note,
tags=parse_tags(args.tags),
publish_date=args.schedule or 0,
@@ -480,6 +617,7 @@ async def dispatch(args: argparse.Namespace) -> int:
account_name=args.account,
video_file=args.file,
title=args.title,
description=args.desc,
tags=parse_tags(args.tags),
publish_date=args.schedule or 0,
thumbnail_file=args.thumbnail,
@@ -495,6 +633,7 @@ async def dispatch(args: argparse.Namespace) -> int:
request = KuaishouNoteUploadRequest(
account_name=args.account,
image_files=parse_image_files(args.images),
title=args.title,
note=args.note,
tags=parse_tags(args.tags),
publish_date=args.schedule or 0,
@@ -508,6 +647,58 @@ async def dispatch(args: argparse.Namespace) -> int:
raise RuntimeError(f"Unsupported Kuaishou action: {args.action}")
if args.platform == "xiaohongshu":
if args.action == "login":
result = await login_xiaohongshu_account(args.account, headless=args.headless)
if not result["success"]:
raise RuntimeError(result["message"])
print(f"Xiaohongshu login flow completed: {result['account_file']}")
return 0
if args.action == "check":
is_valid = await check_xiaohongshu_account(args.account)
print("valid" if is_valid else "invalid")
return 0 if is_valid else 1
publish_strategy = (
XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED if args.schedule else XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE
)
if args.action == "upload-video":
request = XiaohongshuVideoUploadRequest(
account_name=args.account,
video_file=args.file,
title=args.title,
description=args.desc,
tags=parse_tags(args.tags),
publish_date=args.schedule or 0,
thumbnail_file=args.thumbnail,
publish_strategy=publish_strategy,
debug=args.debug,
headless=args.headless,
)
await upload_xiaohongshu_video(request)
print(f"Xiaohongshu video upload submitted: {request.video_file}")
return 0
if args.action == "upload-note":
request = XiaohongshuNoteUploadRequest(
account_name=args.account,
image_files=parse_image_files(args.images),
title=args.title,
note=args.note,
tags=parse_tags(args.tags),
publish_date=args.schedule or 0,
publish_strategy=publish_strategy,
debug=args.debug,
headless=args.headless,
)
await upload_xiaohongshu_note(request)
print(f"Xiaohongshu note upload submitted: {len(request.image_files)} images")
return 0
raise RuntimeError(f"Unsupported Xiaohongshu action: {args.action}")
if args.platform == "bilibili":
if args.action == "login":
result = await login_bilibili_account(args.account)
+5
View File
@@ -20,6 +20,11 @@ description: 当 agent 需要通过已安装的 `sau` CLI 完成抖音登录、c
| 视频上传 | `sau douyin upload-video ...` | 上传并发布抖音视频 |
| 图文上传 | `sau douyin upload-note ...` | 上传并发布抖音图文 |
元数据约定:
- 视频使用 `title + desc + tags`
- 图文使用 `title + note + tags`
## 默认工作流
1. 先确认 `references/runtime-requirements.md` 里的运行前提。
@@ -38,6 +38,7 @@ sau douyin upload-video \
--account <account> \
--file <video-path> \
--title "<title>" \
[--desc "<description>"] \
[--tags tag1,tag2] \
[--schedule "YYYY-MM-DD HH:MM"] \
[--thumbnail <image-path>] \
@@ -52,6 +53,7 @@ sau douyin upload-video \
- `--file`
- `--title`
- 可选参数:
- `--desc`
- `--tags`
- `--schedule`
- `--thumbnail`
@@ -67,7 +69,8 @@ sau douyin upload-video \
sau douyin upload-note \
--account <account> \
--images <image-1> [image-2 ...] \
--note "<content>" \
--title "<title>" \
[--note "<content>"] \
[--tags tag1,tag2] \
[--schedule "YYYY-MM-DD HH:MM"] \
[--debug] \
@@ -77,8 +80,9 @@ sau douyin upload-note \
- 必填参数:
- `--account`
- `--images`
- `--note`
- `--title`
- 可选参数:
- `--note`
- `--tags`
- `--schedule`
- `--debug`
@@ -99,5 +103,7 @@ YYYY-MM-DD HH:MM
- `upload-video` 每次命令只支持一个视频文件
- `upload-note` 每次命令支持多张图片
- 视频描述字段统一使用 `--desc`
- 图文正文统一使用 `--note`
- `upload-note` 当前不支持 GIF
- `upload-note` 当前最多支持 35 张图片
@@ -63,7 +63,9 @@ sau douyin login --account <account>
- `--account`
- `--images`
- `--note`
- `--title`
`--note` 当前是可选图文正文。
## 图片限制
@@ -27,6 +27,8 @@ def main() -> None:
"videos/demo.mp4",
"--title",
"Douyin video from Python",
"--desc",
"Douyin video description from Python",
"--tags",
"cli,video",
"--thumbnail",
@@ -42,6 +44,8 @@ def main() -> None:
"--images",
"videos/1.png",
"videos/2.png",
"--title",
"Douyin note title from Python",
"--note",
"Douyin note from Python",
"--tags",
@@ -14,6 +14,7 @@ sau douyin upload-video `
--account $account `
--file $video `
--title "Douyin video from PowerShell" `
--desc "Douyin video description from PowerShell" `
--tags "cli,video" `
--thumbnail $thumbnail `
--headless
@@ -21,6 +22,7 @@ sau douyin upload-video `
sau douyin upload-note `
--account $account `
--images $noteImages `
--title "Douyin note title from PowerShell" `
--note "Douyin note from PowerShell" `
--tags "cli,note" `
--headless
@@ -15,6 +15,7 @@ sau douyin upload-video \
--account "$account" \
--file "$video" \
--title "Douyin video from bash" \
--desc "Douyin video description from bash" \
--tags "cli,video" \
--thumbnail "$thumbnail" \
--headless
@@ -22,6 +23,7 @@ sau douyin upload-video \
sau douyin upload-note \
--account "$account" \
--images "videos/1.png" "videos/2.png" \
--title "Douyin note title from bash" \
--note "Douyin note from bash" \
--tags "cli,note" \
--headless
+5
View File
@@ -20,6 +20,11 @@ description: 当 agent 需要通过已安装的 `sau` CLI 完成快手登录、c
| 视频上传 | `sau kuaishou upload-video ...` | 上传并发布快手视频 |
| 图文上传 | `sau kuaishou upload-note ...` | 上传并发布快手图文 |
元数据约定:
- 视频使用 `title + desc + tags`
- 图文使用 `title + note + tags`
## 默认工作流
1. 先确认 `references/runtime-requirements.md` 里的运行前提。
@@ -38,6 +38,7 @@ sau kuaishou upload-video \
--account <account> \
--file <video-path> \
--title "<title>" \
[--desc "<description>"] \
[--tags tag1,tag2] \
[--schedule "YYYY-MM-DD HH:MM"] \
[--thumbnail <image-path>] \
@@ -50,6 +51,7 @@ sau kuaishou upload-video \
- `--file`
- `--title`
- 可选参数:
- `--desc`
- `--tags`
- `--schedule`
- `--thumbnail`
@@ -63,7 +65,8 @@ sau kuaishou upload-video \
sau kuaishou upload-note \
--account <account> \
--images <image-1> [image-2 ...] \
--note "<content>" \
--title "<title>" \
[--note "<content>"] \
[--tags tag1,tag2] \
[--schedule "YYYY-MM-DD HH:MM"] \
[--debug] \
@@ -73,8 +76,9 @@ sau kuaishou upload-note \
- 必填参数:
- `--account`
- `--images`
- `--note`
- `--title`
- 可选参数:
- `--note`
- `--tags`
- `--schedule`
- `--debug`
@@ -95,4 +99,6 @@ YYYY-MM-DD HH:MM
- `upload-video` 每次命令只支持一个视频文件
- `upload-note` 每次命令支持多张图片
- 视频描述字段统一使用 `--desc`
- 图文正文统一使用 `--note`
- `upload-note` 当前要求传入真实的多张图片文件,而不是同一路径重复多次
@@ -63,7 +63,9 @@ sau kuaishou login --account <account>
- `--account`
- `--images`
- `--note`
- `--title`
`--note` 当前是可选图文正文。
## 图文上传只有一张生效
@@ -27,6 +27,8 @@ def main() -> None:
"videos/demo.mp4",
"--title",
"Kuaishou video from Python",
"--desc",
"Kuaishou video description from Python",
"--tags",
"cli,video",
"--thumbnail",
@@ -43,6 +45,8 @@ def main() -> None:
"videos/1.png",
"videos/2.png",
"videos/3.png",
"--title",
"Kuaishou note title from Python",
"--note",
"Kuaishou note from Python",
"--tags",
@@ -14,6 +14,7 @@ sau kuaishou upload-video `
--account $account `
--file $video `
--title "Kuaishou video from PowerShell" `
--desc "Kuaishou video description from PowerShell" `
--tags "cli,video" `
--thumbnail $thumbnail `
--headless
@@ -21,6 +22,7 @@ sau kuaishou upload-video `
sau kuaishou upload-note `
--account $account `
--images $noteImages `
--title "Kuaishou note title from PowerShell" `
--note "Kuaishou note from PowerShell" `
--tags "cli,note" `
--headless
@@ -15,6 +15,7 @@ sau kuaishou upload-video \
--account "$account" \
--file "$video" \
--title "Kuaishou video from bash" \
--desc "Kuaishou video description from bash" \
--tags "cli,video" \
--thumbnail "$thumbnail" \
--headless
@@ -22,6 +23,7 @@ sau kuaishou upload-video \
sau kuaishou upload-note \
--account "$account" \
--images "videos/1.png" "videos/2.png" "videos/3.png" \
--title "Kuaishou note title from bash" \
--note "Kuaishou note from bash" \
--tags "cli,note" \
--headless
+70
View File
@@ -0,0 +1,70 @@
---
name: xiaohongshu-upload
description: 当 agent 需要通过已安装的 `sau` CLI 完成小红书登录、cookie 校验、视频上传或图文发布时使用这个 skill。该 skill 适用于已经安装 `social-auto-upload` 且可调用 `sau` 命令的环境。优先使用这个 skill 进行稳定的命令式小红书工作流,而不是一开始就阅读 uploader 源码。
---
# 小红书上传 Skill
优先把 `sau` 作为主接口。
不要假设当前环境一定能读取仓库源码。
不要一开始就去读 `uploader/`
只有在命令不可用或 CLI 执行失败时,才回退到故障排查说明。
## 功能概览
| 功能 | 命令入口 | 说明 |
| --- | --- | --- |
| 小红书登录 | `sau xiaohongshu login --account <name>` | 生成或刷新指定账号的 cookie |
| cookie 校验 | `sau xiaohongshu check --account <name>` | 检查指定账号 cookie 是否有效 |
| 视频上传 | `sau xiaohongshu upload-video ...` | 上传并发布小红书视频 |
| 图文上传 | `sau xiaohongshu upload-note ...` | 上传并发布小红书图文 |
元数据约定:
- 视频使用 `title + desc + tags`
- 图文使用 `title + note + tags`
## 默认工作流
1. 先确认 `references/runtime-requirements.md` 里的运行前提。
2. 再确认 `references/cli-contract.md` 里的命令契约。
3. 执行匹配的 `sau xiaohongshu ...` 命令。
4. 如果命令失败,再看 `references/troubleshooting.md`
## 支持动作
- 使用 `sau xiaohongshu login --account <name>` 登录小红书
- 使用 `sau xiaohongshu check --account <name>` 校验 cookie 是否有效
- 使用 `sau xiaohongshu upload-video ...` 上传小红书视频
- 使用 `sau xiaohongshu upload-note ...` 上传小红书图文
## 命令选择建议
- 当用户需要新的 cookie,或现有 cookie 已失效时,使用 `login`
- 当用户只需要确认 cookie 状态时,使用 `check`
- 当用户要发布视频时,使用 `upload-video`
- 当用户要发布图文时,使用 `upload-note`
## 执行前检查
- 先确认当前 shell 里是否可以调用 `sau`
- 如果 `sau` 不可用,按 `references/runtime-requirements.md` 里的回退方式处理
- 当用户明确指定无头或有头模式时,显式传 `--headless``--headed`
- 只有用户明确要求定时发布时,才使用 `--schedule`
- 如果登录流程生成了本地二维码图片,不要只把图片路径告诉用户
- 二维码图片本身就是给用户扫码的,优先直接把本地图片展示/发送给用户
## 模板文件
当你需要稳定的命令模板时,使用 `scripts/examples/` 下的文件:
- `xiaohongshu_commands.ps1`
- `xiaohongshu_commands.sh`
- `xiaohongshu_cli_template.py`
## 参考文档
- 运行前提:`references/runtime-requirements.md`
- CLI 契约:`references/cli-contract.md`
- 故障排查:`references/troubleshooting.md`
@@ -0,0 +1,103 @@
# 小红书 CLI 契约
这个 skill 默认假设当前环境已经安装并可调用 `sau` 命令。
## 命令列表
### 登录
```bash
sau xiaohongshu login --account <account>
```
- 必填参数:
- `--account`
- 作用:
- 启动小红书登录流程,为指定账号生成或刷新 cookie 文件
- 如果登录过程中生成本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,而不是只回传路径
- 账号说明:
- `--account` 传的是用户自定义的 `account_name`,不是固定只能叫 `creator`
- 一个 `account_name` 对应一个账号文件,可用于多账号隔离和并发任务
### 校验 cookie
```bash
sau xiaohongshu check --account <account>
```
- 必填参数:
- `--account`
- 预期输出:
- `valid`cookie 可用
- `invalid`cookie 缺失或已失效
### 上传视频
```bash
sau xiaohongshu upload-video \
--account <account> \
--file <video-path> \
--title "<title>" \
[--desc "<description>"] \
[--tags tag1,tag2] \
[--schedule "YYYY-MM-DD HH:MM"] \
[--thumbnail <image-path>] \
[--debug] \
[--headless | --headed]
```
- 必填参数:
- `--account`
- `--file`
- `--title`
- 可选参数:
- `--desc`
- `--tags`
- `--schedule`
- `--thumbnail`
- `--debug`
- `--headless`
- `--headed`
### 上传图文
```bash
sau xiaohongshu upload-note \
--account <account> \
--images <image-1> [image-2 ...] \
--title "<title>" \
[--note "<content>"] \
[--tags tag1,tag2] \
[--schedule "YYYY-MM-DD HH:MM"] \
[--debug] \
[--headless | --headed]
```
- 必填参数:
- `--account`
- `--images`
- `--title`
- 可选参数:
- `--note`
- `--tags`
- `--schedule`
- `--debug`
- `--headless`
- `--headed`
## 发布策略
- 如果不传 `--schedule`CLI 使用立即发布
- 如果传了 `--schedule`CLI 自动切换为定时发布
- 时间格式为:
```text
YYYY-MM-DD HH:MM
```
## 额外说明
- `upload-video` 每次命令只支持一个视频文件
- `upload-note` 每次命令支持多张图片
- 视频描述字段统一使用 `--desc`
- 图文正文统一使用 `--note`
@@ -0,0 +1,67 @@
# 运行前提
这个 skill 默认假设当前环境已经具备:
- 已安装 `social-auto-upload`
- 可以调用 `sau` 命令,或至少有等效调用方式
- 已为 `patchright` 安装 Chromium
## 推荐安装方式
在项目根目录执行:
```bash
uv pip install -e .
```
## 安装 patchright 浏览器
Windows PowerShell
```powershell
$env:PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright"; patchright install chromium
```
Linux / macOSbash / zsh):
```bash
PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright install chromium
```
## 常见调用方式
### 如果 `sau` 已经在 PATH 中
```bash
sau xiaohongshu --help
```
### 如果虚拟环境存在,但还没有激活
PowerShell
```powershell
.\.venv\Scripts\Activate.ps1
sau xiaohongshu --help
```
### 如果你想直接调用可执行文件
PowerShell
```powershell
.\.venv\Scripts\sau.exe xiaohongshu --help
```
### 如果你更倾向于使用 uv
```bash
uv run sau xiaohongshu --help
```
## 无头和有头模式
- 使用 `--headless` 表示无头模式
- 使用 `--headed` 表示有头模式
- 如果用户明确要求无头登录,也要预期 CLI 会通过控制台输出或临时图片路径提供二维码相关提示
- 如果登录过程中已经生成了本地二维码图片,agent 应优先直接把图片展示/发送给用户扫码,不要只告诉用户图片路径
@@ -0,0 +1,78 @@
# 故障排查
## 找不到 `sau` 命令
可以尝试以下方式:
```powershell
.\.venv\Scripts\Activate.ps1
sau xiaohongshu --help
```
```powershell
.\.venv\Scripts\sau.exe xiaohongshu --help
```
```bash
uv run sau xiaohongshu --help
```
如果当前环境还没有安装项目:
```bash
uv pip install -e .
```
## cookie 无效或已过期
先检查 cookie 状态:
```bash
sau xiaohongshu check --account <account>
```
如果无效,就重新登录:
```bash
sau xiaohongshu login --account <account>
```
## 无头登录二维码处理
如果用户无法使用终端二维码输出:
- 查找 CLI 打印出来的临时二维码图片
- agent 不要只把图片路径回给用户
- agent 应优先直接把本地二维码图片展示/发送给用户扫码
如果终端二维码显示不正常,优先使用保存下来的图片路径,而不是反复尝试随机的终端设置。
## 上传参数缺失
### 视频上传
最少需要:
- `--account`
- `--file`
- `--title`
### 图文上传
最少需要:
- `--account`
- `--images`
- `--title`
`--note` 当前是可选图文正文。
## 定时发布
时间格式使用:
```text
YYYY-MM-DD HH:MM
```
如果不需要定时发布,去掉 `--schedule` 即可改为立即发布。
@@ -0,0 +1,62 @@
from __future__ import annotations
import shlex
import subprocess
def run_command(command: list[str]) -> None:
print("Running:", " ".join(shlex.quote(part) for part in command))
subprocess.run(command, check=True)
def main() -> None:
account = "account_a"
# account_name is user-defined. One account_name maps to one account file.
# You can prepare multiple account names and run them in parallel.
commands = [
["sau", "xiaohongshu", "login", "--account", account, "--headless"],
["sau", "xiaohongshu", "check", "--account", account],
[
"sau",
"xiaohongshu",
"upload-video",
"--account",
account,
"--file",
"videos/demo.mp4",
"--title",
"Xiaohongshu video from Python",
"--desc",
"Xiaohongshu video description from Python",
"--tags",
"cli,video",
"--thumbnail",
"videos/demo.png",
"--headless",
],
[
"sau",
"xiaohongshu",
"upload-note",
"--account",
account,
"--images",
"videos/1.png",
"videos/2.png",
"--title",
"Xiaohongshu note title from Python",
"--note",
"Xiaohongshu note from Python",
"--tags",
"cli,note",
"--headless",
],
]
for command in commands:
run_command(command)
if __name__ == "__main__":
main()
@@ -0,0 +1,28 @@
# PowerShell examples for the installed sau CLI.
# account_name is user-defined. One account_name maps to one account file.
# You can prepare multiple account names and run them in parallel.
$account = "account_a"
$video = "videos/demo.mp4"
$thumbnail = "videos/demo.png"
$noteImages = @("videos/1.png", "videos/2.png")
sau xiaohongshu login --account $account --headless
sau xiaohongshu check --account $account
sau xiaohongshu upload-video `
--account $account `
--file $video `
--title "Xiaohongshu video from PowerShell" `
--desc "Xiaohongshu video description from PowerShell" `
--tags "cli,video" `
--thumbnail $thumbnail `
--headless
sau xiaohongshu upload-note `
--account $account `
--images $noteImages `
--title "Xiaohongshu note title from PowerShell" `
--note "Xiaohongshu note from PowerShell" `
--tags "cli,note" `
--headless
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
# account_name is user-defined. One account_name maps to one account file.
# You can prepare multiple account names and run them in parallel.
account="account_a"
video="videos/demo.mp4"
thumbnail="videos/demo.png"
sau xiaohongshu login --account "$account" --headless
sau xiaohongshu check --account "$account"
sau xiaohongshu upload-video \
--account "$account" \
--file "$video" \
--title "Xiaohongshu video from bash" \
--desc "Xiaohongshu video description from bash" \
--tags "cli,video" \
--thumbnail "$thumbnail" \
--headless
sau xiaohongshu upload-note \
--account "$account" \
--images "videos/1.png" "videos/2.png" \
--title "Xiaohongshu note title from bash" \
--note "Xiaohongshu note from bash" \
--tags "cli,note" \
--headless
+184
View File
@@ -0,0 +1,184 @@
import asyncio
import tempfile
import unittest
from argparse import Namespace
from pathlib import Path
from unittest.mock import AsyncMock, patch
import sau_cli
class BrowserCliParserTests(unittest.TestCase):
def test_build_parser_accepts_xiaohongshu_login(self):
parser = sau_cli.build_parser()
args = parser.parse_args(["xiaohongshu", "login", "--account", "creator"])
self.assertEqual(args.platform, "xiaohongshu")
self.assertEqual(args.action, "login")
def test_douyin_upload_video_accepts_desc(self):
with tempfile.TemporaryDirectory() as tmp_dir:
video_path = Path(tmp_dir) / "demo.mp4"
video_path.write_bytes(b"video")
parser = sau_cli.build_parser()
args = parser.parse_args(
[
"douyin",
"upload-video",
"--account",
"creator",
"--file",
str(video_path),
"--title",
"标题",
"--desc",
"视频简介",
]
)
self.assertEqual(args.desc, "视频简介")
def test_kuaishou_upload_note_accepts_title_and_note(self):
with tempfile.TemporaryDirectory() as tmp_dir:
image_path = Path(tmp_dir) / "1.png"
image_path.write_bytes(b"image")
parser = sau_cli.build_parser()
args = parser.parse_args(
[
"kuaishou",
"upload-note",
"--account",
"creator",
"--images",
str(image_path),
"--title",
"图文标题",
"--note",
"图文正文",
]
)
self.assertEqual(args.title, "图文标题")
self.assertEqual(args.note, "图文正文")
def test_xiaohongshu_upload_video_defaults_to_headless(self):
with tempfile.TemporaryDirectory() as tmp_dir:
video_path = Path(tmp_dir) / "demo.mp4"
video_path.write_bytes(b"video")
parser = sau_cli.build_parser()
args = parser.parse_args(
[
"xiaohongshu",
"upload-video",
"--account",
"creator",
"--file",
str(video_path),
"--title",
"视频标题",
]
)
self.assertTrue(args.headless)
def test_xiaohongshu_upload_note_accepts_headed(self):
with tempfile.TemporaryDirectory() as tmp_dir:
image_path = Path(tmp_dir) / "1.png"
image_path.write_bytes(b"image")
parser = sau_cli.build_parser()
args = parser.parse_args(
[
"xiaohongshu",
"upload-note",
"--account",
"creator",
"--images",
str(image_path),
"--title",
"图文标题",
"--note",
"图文正文",
"--headed",
]
)
self.assertFalse(args.headless)
class BrowserCliDispatchTests(unittest.TestCase):
def test_dispatch_xiaohongshu_check_prints_valid(self):
args = Namespace(platform="xiaohongshu", action="check", account="creator")
with patch("sau_cli.check_xiaohongshu_account", new=AsyncMock(return_value=True)):
code = asyncio.run(sau_cli.dispatch(args))
self.assertEqual(code, 0)
def test_dispatch_douyin_upload_note_uses_new_request_fields(self):
args = Namespace(
platform="douyin",
action="upload-note",
account="creator",
images=[Path("1.png")],
title="图文标题",
note="图文正文",
tags="测试,图文",
schedule=0,
debug=False,
headless=True,
)
with patch("sau_cli.upload_note", new=AsyncMock()) as mock_upload:
asyncio.run(sau_cli.dispatch(args))
request = mock_upload.await_args.args[0]
self.assertEqual(request.title, "图文标题")
self.assertEqual(request.note, "图文正文")
def test_dispatch_xiaohongshu_upload_video_uses_headed_request(self):
args = Namespace(
platform="xiaohongshu",
action="upload-video",
account="creator",
file=Path("demo.mp4"),
title="视频标题",
desc="视频简介",
tags="测试,视频",
schedule=0,
thumbnail=None,
debug=False,
headless=False,
)
with patch("sau_cli.upload_xiaohongshu_video", new=AsyncMock()) as mock_upload:
asyncio.run(sau_cli.dispatch(args))
request = mock_upload.await_args.args[0]
self.assertEqual(request.title, "视频标题")
self.assertEqual(request.description, "视频简介")
self.assertFalse(request.headless)
def test_dispatch_xiaohongshu_upload_note_uses_headless_request(self):
args = Namespace(
platform="xiaohongshu",
action="upload-note",
account="creator",
images=[Path("1.png"), Path("2.png")],
title="图文标题",
note="图文正文",
tags="测试,图文",
schedule=0,
debug=False,
headless=True,
)
with patch("sau_cli.upload_xiaohongshu_note", new=AsyncMock()) as mock_upload:
asyncio.run(sau_cli.dispatch(args))
request = mock_upload.await_args.args[0]
self.assertEqual(request.title, "图文标题")
self.assertEqual(request.note, "图文正文")
self.assertTrue(request.headless)
self.assertEqual(len(request.image_files), 2)
if __name__ == "__main__":
unittest.main()
+260
View File
@@ -0,0 +1,260 @@
import asyncio
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
import uploader.xiaohongshu_uploader.main as xhs_main
class FakeLocator:
def __init__(self, name, count=0, src=None, children=None):
self.name = name
self._count = count
self._src = src
self._children = children or {}
@property
def first(self):
return self
def locator(self, selector):
return self._children.get(selector, FakeLocator(selector))
def get_by_text(self, text, exact=False):
return self._children.get(f"text:{text}", FakeLocator(text))
def filter(self, **kwargs):
return self
def nth(self, index):
return self
async def count(self):
return self._count
async def wait_for(self, **kwargs):
return None
async def get_attribute(self, name):
if name == "src":
return self._src
return None
async def fill(self, value):
return None
async def click(self):
return None
class RecordingKeyboard:
def __init__(self):
self.actions = []
async def press(self, key):
self.actions.append(("press", key))
async def type(self, text, delay=None):
self.actions.append(("type", text, delay))
class RecordingLocator(FakeLocator):
def __init__(self, name):
super().__init__(name, count=1)
self.actions = []
async def fill(self, value):
self.actions.append(("fill", value))
async def click(self):
self.actions.append(("click",))
async def wait_for(self, **kwargs):
self.actions.append(("wait_for", kwargs))
class RecordingPage:
def __init__(self):
self.keyboard = RecordingKeyboard()
self.locators = {
'input[placeholder*="填写标题"]': RecordingLocator("title"),
'p[data-placeholder*="输入正文描述"]': RecordingLocator("desc"),
'#creator-editor-topic-container': RecordingLocator("topic-container"),
'#creator-editor-topic-container .item': RecordingLocator("topic-item"),
}
def locator(self, selector):
return self.locators[selector]
class XiaohongshuUploaderTests(unittest.TestCase):
def test_find_xhs_qrcode_locator_prefers_scan_sibling_inside_login_box(self):
qrcode_locator = FakeLocator("qrcode", count=1, src="data:image/png;base64,abc")
scan_text_locator = FakeLocator(
"scan-text",
count=1,
children={
"xpath=..//following-sibling::div//img": qrcode_locator,
},
)
login_box_locator = FakeLocator(
"login-box",
count=1,
children={
"div:has-text('扫一扫')": scan_text_locator,
"text:APP扫一扫登录": scan_text_locator,
},
)
page = FakeLocator(
"page",
children={
"div[class*='login-box']": login_box_locator,
".login-box-container": login_box_locator,
},
)
locator = asyncio.run(xhs_main._find_xhs_qrcode_locator(page))
self.assertIs(locator, qrcode_locator)
def test_setup_returns_detail_when_cookie_invalid_without_handle(self):
with patch("uploader.xiaohongshu_uploader.main.os.path.exists", return_value=False):
result = asyncio.run(
xhs_main.xiaohongshu_setup(
"missing.json",
handle=False,
return_detail=True,
)
)
self.assertFalse(result["success"])
self.assertEqual(result["status"], "cookie_invalid")
def test_setup_uses_login_flow_when_handle_is_true(self):
login_result = {
"success": True,
"status": "success",
"message": "ok",
"account_file": "account.json",
"qrcode": {"image_path": "qrcode.png"},
"current_url": "https://creator.xiaohongshu.com/",
}
with patch("uploader.xiaohongshu_uploader.main.os.path.exists", return_value=False):
with patch(
"uploader.xiaohongshu_uploader.main.xiaohongshu_cookie_gen",
new=AsyncMock(return_value=login_result),
) as mock_login:
result = asyncio.run(
xhs_main.xiaohongshu_setup(
"account.json",
handle=True,
return_detail=True,
)
)
self.assertTrue(result["success"])
mock_login.assert_awaited_once()
def test_video_validate_upload_args_normalizes_video_and_thumbnail(self):
with tempfile.TemporaryDirectory() as tmp_dir:
video_path = Path(tmp_dir) / "demo.mp4"
thumbnail_path = Path(tmp_dir) / "demo.png"
cookie_path = Path(tmp_dir) / "account.json"
video_path.write_bytes(b"video")
thumbnail_path.write_bytes(b"image")
cookie_path.write_text("{}")
app = xhs_main.XiaoHongShuVideo(
title="demo",
file_path=str(video_path),
tags=["xhs"],
publish_date=0,
account_file=str(cookie_path),
thumbnail_path=str(thumbnail_path),
)
with patch(
"uploader.xiaohongshu_uploader.main.cookie_auth",
new=AsyncMock(return_value=True),
):
asyncio.run(app.validate_upload_args())
self.assertTrue(app.file_path.endswith("demo.mp4"))
self.assertTrue(app.thumbnail_path.endswith("demo.png"))
def test_note_uploader_exists_and_validates_required_fields(self):
note_cls = getattr(xhs_main, "XiaoHongShuNote")
app = note_cls(
image_paths=[],
note="",
tags=[],
publish_date=0,
account_file="account.json",
)
with patch.object(app, "validate_base_args", new=AsyncMock(return_value=None)):
with self.assertRaises(ValueError):
asyncio.run(app.validate_upload_args())
def test_video_fill_meta_uses_desc_then_first_tag(self):
app = xhs_main.XiaoHongShuVideo(
title="标题内容",
file_path="demo.mp4",
tags=["话题1"],
publish_date=0,
account_file="account.json",
desc="描述内容",
)
page = RecordingPage()
asyncio.run(app.fill_meta(page))
self.assertEqual(
page.locators['input[placeholder*="填写标题"]'].actions,
[("fill", "标题内容")],
)
self.assertEqual(
page.locators['p[data-placeholder*="输入正文描述"]'].actions,
[("click",)],
)
self.assertIn(("type", "描述内容", None), page.keyboard.actions)
self.assertIn(("type", "#话题1", 30), page.keyboard.actions)
self.assertEqual(
page.locators['#creator-editor-topic-container .item'].actions,
[("wait_for", {"state": "visible", "timeout": 2000}), ("click",)],
)
def test_video_fill_meta_can_fill_first_tag_without_desc(self):
app = xhs_main.XiaoHongShuVideo(
title="标题内容",
file_path="demo.mp4",
tags=["话题1"],
publish_date=0,
account_file="account.json",
)
page = RecordingPage()
asyncio.run(app.fill_meta(page))
self.assertEqual(
page.locators['p[data-placeholder*="输入正文描述"]'].actions,
[("click",)],
)
self.assertNotIn(("type", "", None), page.keyboard.actions)
self.assertIn(("type", "#话题1", 30), page.keyboard.actions)
def test_note_title_defaults_do_not_override_explicit_title(self):
app = xhs_main.XiaoHongShuNote(
image_paths=["a.png"],
note="正文",
tags=[],
publish_date=0,
account_file="account.json",
title="显式标题",
desc="图文正文",
)
self.assertEqual(app.title, "显式标题")
self.assertEqual(app.desc, "图文正文")
if __name__ == "__main__":
unittest.main()
+9 -5
View File
@@ -380,6 +380,7 @@ class DouYinVideo(DouYinBaseUploader):
productLink="",
productTitle="",
thumbnail_portrait_path=None,
desc: str | None = None,
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
debug: bool = DEBUG_MODE,
headless: bool = LOCAL_CHROME_HEADLESS,
@@ -398,6 +399,7 @@ class DouYinVideo(DouYinBaseUploader):
self.thumbnail_portrait_path = thumbnail_portrait_path
self.productLink = productLink
self.productTitle = productTitle
self.desc = desc or ""
async def validate_upload_args(self):
await self.validate_base_args()
@@ -505,7 +507,7 @@ class DouYinVideo(DouYinBaseUploader):
await asyncio.sleep(1)
douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
await self.fill_title_and_description(page, self.title, self.title, self.tags)
await self.fill_title_and_description(page, self.title, self.desc or self.title, self.tags)
douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题"))
while True:
@@ -578,6 +580,7 @@ class DouYinNote(DouYinBaseUploader):
tags,
publish_date: datetime | int,
account_file,
title: str | None = None,
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
debug: bool = DEBUG_MODE,
headless: bool = LOCAL_CHROME_HEADLESS,
@@ -590,13 +593,14 @@ class DouYinNote(DouYinBaseUploader):
headless=headless,
)
self.image_paths = image_paths
self.note = note
self.note = note or ""
self.title = title or (self.note[:30] if self.note else "")
self.tags = tags or []
async def validate_upload_args(self):
await self.validate_base_args()
if not self.note or not str(self.note).strip():
raise ValueError("图文模式下,note 是必须的")
if not self.title or not str(self.title).strip():
raise ValueError("图文模式下,title 是必须的")
if not self.image_paths:
raise ValueError("图文模式下,图片是必须的")
@@ -634,7 +638,7 @@ class DouYinNote(DouYinBaseUploader):
await asyncio.sleep(1)
douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
await self.fill_title_and_description(page, self.note, self.note, self.tags)
await self.fill_title_and_description(page, self.title, self.note, self.tags)
douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题"))
if self.publish_strategy == DOUYIN_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
+8 -4
View File
@@ -358,6 +358,7 @@ class KSVideo(KSBaseUploader):
debug: bool = DEBUG_MODE,
headless: bool = LOCAL_CHROME_HEADLESS,
thumbnail_path=None,
desc: str | None = None,
):
super().__init__(
publish_date=publish_date,
@@ -370,6 +371,7 @@ class KSVideo(KSBaseUploader):
self.file_path = file_path
self.tags = tags or []
self.thumbnail_path = thumbnail_path
self.desc = desc or ""
async def validate_upload_args(self):
await self.validate_base_args()
@@ -462,7 +464,7 @@ class KSVideo(KSBaseUploader):
await page.keyboard.press("Backspace")
await page.keyboard.press("Control+KeyA")
await page.keyboard.press("Delete")
await page.keyboard.type(self.title)
await page.keyboard.type(self.desc or self.title)
await page.keyboard.press("Enter")
for index, tag in enumerate(self.tags[:3], start=1):
@@ -541,6 +543,7 @@ class KSNote(KSBaseUploader):
tags,
publish_date: datetime | int,
account_file,
title: str | None = None,
publish_strategy: str | None = None,
debug: bool = DEBUG_MODE,
headless: bool = LOCAL_CHROME_HEADLESS,
@@ -553,13 +556,14 @@ class KSNote(KSBaseUploader):
headless=headless,
)
self.image_paths = image_paths
self.note = note
self.note = note or ""
self.title = title or (self.note[:20] if self.note else "")
self.tags = tags or []
async def validate_upload_args(self):
await self.validate_base_args()
if not self.note or not str(self.note).strip():
raise ValueError("快手图文上传时,note 是必须的")
if not self.title or not str(self.title).strip():
raise ValueError("快手图文上传时,title 是必须的")
if not self.image_paths:
raise ValueError("快手图文上传时,图片是必须的")
+39 -57
View File
@@ -308,39 +308,30 @@ class XiaoHongShuBaseUploader(BaseVideoUploader):
self.publish_date = 0
async def set_schedule_time_xiaohongshu(self, page: Page, publish_date: datetime):
print(" [-] 正在设置定时发布时间...")
print(f"publish_date: {publish_date}")
xiaohongshu_logger.info(_msg("🕒", f"小人准备设置定时发布时间: {publish_date.strftime(self.date_format)}"))
await page.locator('.custom-switch-card').filter(has_text="定时发布").locator('.d-switch').click()
await asyncio.sleep(1)
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M")
print(f"publish_date_hour: {publish_date_hour}")
await asyncio.sleep(1)
time_input = page.locator('.d-datepicker-input-filter input.d-text')
await time_input.fill(str(publish_date_hour))
await asyncio.sleep(1)
async def set_location(self, page: Page, location: str = "青岛市"):
print(f"开始设置位置: {location}")
print("等待地点输入框加载...")
if not location:
return True
xiaohongshu_logger.info(_msg("📍", f"小人准备设置位置: {location}"))
loc_ele = await page.wait_for_selector('div.d-text.d-select-placeholder.d-text-ellipsis.d-text-nowrap')
print(f"已定位到地点输入框: {loc_ele}")
await loc_ele.click()
print("点击地点输入框完成")
print(f"等待1秒后输入位置名称: {location}")
await page.wait_for_timeout(1000)
await page.keyboard.type(location)
print(f"位置名称输入完成: {location}")
print("等待下拉列表加载...")
dropdown_selector = 'div.d-popover.d-popover-default.d-dropdown.--size-min-width-large'
await page.wait_for_timeout(2000)
try:
await page.wait_for_selector(dropdown_selector, timeout=3000)
print("下拉列表已加载")
except Exception:
print("下拉列表按预期显示,可能结构已变化")
print("额外等待1秒确保内容渲染完成...")
xiaohongshu_logger.warning(_msg("😵", "位置下拉列表按预期出现,小人继续按旧逻辑查找"))
await page.wait_for_timeout(1000)
print("尝试使用更灵活的XPath选择器...")
flexible_xpath = (
f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
f'//div[contains(@class, "d-options-wrapper")]'
@@ -348,17 +339,13 @@ class XiaoHongShuBaseUploader(BaseVideoUploader):
f'//div[contains(@class, "name") and text()="{location}"]'
)
await page.wait_for_timeout(3000)
print(f"尝试定位包含'{location}'的选项...")
try:
location_option = await page.wait_for_selector(
flexible_xpath,
timeout=3000
)
if location_option:
print(f"使用灵活选择器定位成功: {location_option}")
else:
print("灵活选择器未找到元素,尝试原始选择器...")
if not location_option:
location_option = await page.wait_for_selector(
f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
f'//div[contains(@class, "d-options-wrapper")]'
@@ -367,18 +354,12 @@ class XiaoHongShuBaseUploader(BaseVideoUploader):
timeout=2000
)
print("滚动到目标选项...")
await location_option.scroll_into_view_if_needed()
print("元素已滚动到视图内")
is_visible = await location_option.is_visible()
print(f"目标选项是否可见: {is_visible}")
print("准备点击目标选项...")
await location_option.click()
print(f"成功选择位置: {location}")
xiaohongshu_logger.success(_msg("🥳", f"位置已经设置成 {location}"))
return True
except Exception as e:
print(f"定位位置失败: {e}")
print("尝试获取下拉列表中的所有选项...")
xiaohongshu_logger.error(_msg("😢", f"设置位置失败: {e}"))
try:
all_options = await page.query_selector_all(
'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
@@ -386,12 +367,12 @@ class XiaoHongShuBaseUploader(BaseVideoUploader):
'//div[contains(@class, "d-grid") and contains(@class, "d-options")]'
'/div'
)
print(f"找到 {len(all_options)} 个选项")
xiaohongshu_logger.debug(_msg("🧍", f"位置下拉里一共找到 {len(all_options)} 个选项"))
for i, option in enumerate(all_options[:3]):
option_text = await option.inner_text()
print(f"选项 {i + 1}: {option_text.strip()[:50]}...")
xiaohongshu_logger.debug(_msg("🧾", f"候选位置 {i + 1}: {option_text.strip()[:50]}"))
except Exception as inner_e:
print(f"获取选项列表失败: {inner_e}")
xiaohongshu_logger.debug(_msg("😵", f"读取位置候选列表失败: {inner_e}"))
return False
async def fill_title(self, page: Page) -> None:
@@ -470,7 +451,7 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path))
async def handle_upload_error(self, page: Page):
xiaohongshu_logger.info('视频出错了,重新上传中')
xiaohongshu_logger.warning(_msg("😵", "视频上传摔了一跤,小人马上重新上传"))
await page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(self.file_path)
async def set_thumbnail(self, page: Page, thumbnail_path: str):
@@ -503,8 +484,8 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
xiaohongshu_logger.success(_msg("🥳", "封面已经设置完成"))
async def upload_video_content(self, page: Page) -> None:
xiaohongshu_logger.info(f'[+]正在上传-------{self.title}.mp4')
xiaohongshu_logger.info(f'[-] 正在打开主页...')
xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4"))
xiaohongshu_logger.info(_msg("🧭", "小人正在赶往视频发布页"))
await page.goto(XHS_PUBLISH_VIDEO_URL)
await page.wait_for_url(XHS_PUBLISH_VIDEO_URL)
await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(self.file_path)
@@ -519,21 +500,20 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
upload_success = False
for stage in stage_elements:
text_content = await page.evaluate('(element) => element.textContent', stage)
if '上传成功' in text_content or '分辨率' in text_content :
if '上传成功' in text_content or '分辨率' in text_content:
upload_success = True
break
if upload_success:
xiaohongshu_logger.info("[+] 检测到上传成功标识!")
xiaohongshu_logger.success(_msg("🥳", "视频已经传完啦"))
break
print(" [-] 未找到上传成功标识,继续等待...")
xiaohongshu_logger.debug(_msg("🧍", "还没看到上传成功标识,小人继续等一会"))
else:
print(" [-] 未找到预览元素,继续等待...")
await asyncio.sleep(3)
xiaohongshu_logger.debug(_msg("🧍", "还没拿到预览区域,小人继续等一会"))
except Exception as e:
print(f" [-] 检测过程出错: {str(e)},重新尝试...")
await asyncio.sleep(3)
xiaohongshu_logger.debug(_msg("😵", f"上传状态还没稳定下来,小人继续观察: {e}"))
await asyncio.sleep(2)
xiaohongshu_logger.info(' [-] 正在填写标题、描述和话题...')
xiaohongshu_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
await self.fill_meta(page)
await self.set_thumbnail(page, self.thumbnail_path)
@@ -553,19 +533,19 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
"https://creator.xiaohongshu.com/publish/success?**",
timeout=3000
)
xiaohongshu_logger.success(" [-]视频发布成功")
xiaohongshu_logger.success(_msg("🥳", "视频发布成功,小人开心收工"))
break
except Exception:
xiaohongshu_logger.info(" [-] 视频正在发布中...")
await page.screenshot(full_page=True)
xiaohongshu_logger.info(_msg("🏃", "小人正在冲刺发布视频"))
if self.debug:
await page.screenshot(full_page=True)
await asyncio.sleep(0.5)
async def upload(self, playwright: Playwright) -> None:
xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间"))
await self.validate_upload_args()
xiaohongshu_logger.info(_msg("🥳", "上传前检查通过"))
browser = await playwright.chromium.launch(headless=self.headless)
browser = await playwright.chromium.launch(headless=self.headless, channel="chrome")
context = await browser.new_context(
permissions=["geolocation"],
storage_state=self.account_file,
@@ -632,8 +612,8 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader):
self.image_paths = normalized_image_paths
async def upload_note_content(self, page: Page) -> None:
xiaohongshu_logger.info(f'[+]正在上传图文,共 {len(self.image_paths)} 张图片')
xiaohongshu_logger.info('[-] 正在打开图文发布页...')
xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片"))
xiaohongshu_logger.info(_msg("🧭", "小人正在赶往图文发布页"))
await page.goto(XHS_PUBLISH_NOTE_URL)
await page.wait_for_url(XHS_PUBLISH_NOTE_URL)
@@ -642,19 +622,20 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader):
upload_input = page.locator("div[class^='upload-content'] input[class='upload-input']").first
await upload_input.wait_for(state="attached", timeout=30000)
xiaohongshu_logger.info(_msg("📤", "小人正在上传图片"))
await upload_input.set_input_files(self.image_paths)
while True:
try:
title_container = page.locator('input[placeholder*="填写标题"]').first
await title_container.wait_for(state="visible", timeout=3000)
xiaohongshu_logger.info("[+] 检测到图文素材上传完成,已经可以填写内容")
xiaohongshu_logger.success(_msg("🥳", "图文素材已经传完,可以开始填写内容"))
break
except Exception:
xiaohongshu_logger.info(" [-] 图文素材在上传中,继续等待...")
xiaohongshu_logger.debug(_msg("🧍", "图文素材在上传,小人继续等一会"))
await asyncio.sleep(1)
xiaohongshu_logger.info(' [-] 正在填写标题、描述和话题...')
xiaohongshu_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
await self.fill_meta(page)
if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
@@ -670,20 +651,21 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader):
XHS_PUBLISH_SUCCESS_URL_PATTERN,
timeout=3000
)
xiaohongshu_logger.success(" [-]图文发布成功")
xiaohongshu_logger.success(_msg("🥳", "图文发布成功,小人开心收工"))
break
except Exception:
xiaohongshu_logger.info(" [-] 图文正在发布中...")
await page.screenshot(full_page=True)
xiaohongshu_logger.info(_msg("🏃", "小人正在冲刺发布图文"))
if self.debug:
await page.screenshot(full_page=True)
await asyncio.sleep(0.5)
async def upload(self, playwright: Playwright) -> None:
xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间"))
await self.validate_upload_args()
xiaohongshu_logger.info(_msg("🥳", "图文上传前检查通过"))
browser = await playwright.chromium.launch(headless=self.headless, channel="chrome")
context = await browser.new_context(
permissions=["geolocation"],
storage_state=self.account_file,
)
context = await set_init_script(context)