小红书重构
1. 图文上传 2. 视频上传(封面,定时) 3. cli 4. skill 5. 无头模式
This commit is contained in:
@@ -26,7 +26,7 @@
|
|||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| 抖音 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 当前主线重构最完整 |
|
| 抖音 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 当前主线重构最完整 |
|
||||||
| Bilibili | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | 运行时自动准备 `biliup` |
|
| Bilibili | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | 运行时自动准备 `biliup` |
|
||||||
| 小红书(浏览器版) | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 当前仓库有浏览器 uploader |
|
| 小红书(浏览器版) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 浏览器自动化,CLI/Skill 已接入 |
|
||||||
| 快手 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 浏览器自动化,CLI/Skill 初版已接入 |
|
| 快手 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 浏览器自动化,CLI/Skill 初版已接入 |
|
||||||
| 视频号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 对应 `tencent_uploader` |
|
| 视频号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 对应 `tencent_uploader` |
|
||||||
| 百家号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 浏览器自动化 |
|
| 百家号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 浏览器自动化 |
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
- CLI 使用请看:[CLI 使用说明](./docs/CLI.md)
|
- CLI 使用请看:[CLI 使用说明](./docs/CLI.md)
|
||||||
- agent / skill 请看:[Douyin Upload Skill](./skills/douyin-upload/SKILL.md)
|
- agent / skill 请看:[Douyin Upload Skill](./skills/douyin-upload/SKILL.md)
|
||||||
- agent / skill 请看:[Kuaishou Upload Skill](./skills/kuaishou-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)
|
- agent / skill 请看:[Bilibili Upload Skill](./skills/bilibili-upload/SKILL.md)
|
||||||
- 历史 Web 说明请看:[历史 Web 版本说明](./docs/legacy-web.md)
|
- 历史 Web 说明请看:[历史 Web 版本说明](./docs/legacy-web.md)
|
||||||
- 其他单平台 skill 与整合型 skill 仍在开发中
|
- 其他单平台 skill 与整合型 skill 仍在开发中
|
||||||
@@ -93,18 +94,23 @@ Web 端相关代码仍然保留,但已经不是当前主线,不保证可直
|
|||||||
|
|
||||||
### 方式 1:使用 CLI
|
### 方式 1:使用 CLI
|
||||||
|
|
||||||
当前抖音、快手、Bilibili 已经接入 CLI:
|
当前抖音、快手、小红书、Bilibili 已经接入 CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sau douyin login --account <account_name>
|
sau douyin login --account <account_name>
|
||||||
sau douyin check --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 --note "图文示例"
|
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --title "图文标题" --note "图文正文"
|
||||||
|
|
||||||
sau kuaishou login --account <account_name>
|
sau kuaishou login --account <account_name>
|
||||||
sau kuaishou check --account <account_name>
|
sau kuaishou check --account <account_name>
|
||||||
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题"
|
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 --note "图文示例"
|
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 login --account <account_name>
|
||||||
sau bilibili check --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`
|
- `creator` 之类的名字只是示例值,真正含义是 `account_name`
|
||||||
- 一个 `account_name` 对应一个账号文件,可以准备多个账号,也可以按账号名并发执行任务
|
- 一个 `account_name` 对应一个账号文件,可以准备多个账号,也可以按账号名并发执行任务
|
||||||
|
- 浏览器平台统一约定:
|
||||||
|
- 视频使用 `title + desc + tags`
|
||||||
|
- 图文使用 `title + note + tags`
|
||||||
- Bilibili CLI 不要求用户手动安装 `biliup`
|
- Bilibili CLI 不要求用户手动安装 `biliup`
|
||||||
- 首次运行相关命令时,程序会自动下载 `biliup`
|
- 首次运行相关命令时,程序会自动下载 `biliup`
|
||||||
- 后续运行会自动检查上游 release 并更新
|
- 后续运行会自动检查上游 release 并更新
|
||||||
@@ -127,8 +136,8 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
|
|||||||
- 当前主线 CLI 包装示例
|
- 当前主线 CLI 包装示例
|
||||||
- 历史直连 uploader 示例
|
- 历史直连 uploader 示例
|
||||||
|
|
||||||
对抖音、快手、Bilibili 来说,当前主线优先使用上面的 `sau ...` CLI。
|
对抖音、快手、小红书、Bilibili 来说,当前主线优先使用上面的 `sau ...` CLI。
|
||||||
下面这些脚本里,`upload_to_douyin.py` 和 `upload_to_kuaishou.py` 属于历史直连 uploader 示例:
|
下面这些脚本主要是历史直连 uploader 示例或调试入口:
|
||||||
|
|
||||||
- `examples/upload_to_douyin.py`
|
- `examples/upload_to_douyin.py`
|
||||||
- `examples/upload_video_to_bilibili.py`
|
- `examples/upload_video_to_bilibili.py`
|
||||||
|
|||||||
+30
-11
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- `douyin`
|
- `douyin`
|
||||||
- `kuaishou`
|
- `kuaishou`
|
||||||
|
- `xiaohongshu`
|
||||||
- `bilibili`
|
- `bilibili`
|
||||||
|
|
||||||
实现说明:
|
实现说明:
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
- 如果需要给 OpenClaw、Codex 等 agent 使用,可参考仓库内 skill:
|
- 如果需要给 OpenClaw、Codex 等 agent 使用,可参考仓库内 skill:
|
||||||
- `skills/douyin-upload/`
|
- `skills/douyin-upload/`
|
||||||
- `skills/kuaishou-upload/`
|
- `skills/kuaishou-upload/`
|
||||||
|
- `skills/xiaohongshu-upload/`
|
||||||
- `skills/bilibili-upload/`
|
- `skills/bilibili-upload/`
|
||||||
|
|
||||||
## 安装 CLI 入口
|
## 安装 CLI 入口
|
||||||
@@ -28,6 +30,7 @@ uv pip install -e .
|
|||||||
```bash
|
```bash
|
||||||
sau douyin --help
|
sau douyin --help
|
||||||
sau kuaishou --help
|
sau kuaishou --help
|
||||||
|
sau xiaohongshu --help
|
||||||
sau bilibili --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>
|
||||||
sau douyin login --account <account_name> --headless
|
sau douyin login --account <account_name> --headless
|
||||||
sau douyin check --account <account_name>
|
sau douyin check --account <account_name>
|
||||||
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --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 --note "图文示例" --tags 图文,测试
|
sau douyin upload-note --account <account_name> --images videos/1.png videos/2.png --title "图文标题" --note "图文示例" --tags 图文,测试
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快手 CLI 子命令
|
## 快手 CLI 子命令
|
||||||
@@ -54,8 +57,17 @@ sau douyin upload-note --account <account_name> --images videos/1.png videos/2.p
|
|||||||
```bash
|
```bash
|
||||||
sau kuaishou login --account <account_name>
|
sau kuaishou login --account <account_name>
|
||||||
sau kuaishou check --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-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 --note "图文示例" --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 子命令
|
## Bilibili CLI 子命令
|
||||||
@@ -70,6 +82,9 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
|
|||||||
|
|
||||||
- `creator` 之类的名字只是示例值,真正传的是用户自定义的 `account_name`
|
- `creator` 之类的名字只是示例值,真正传的是用户自定义的 `account_name`
|
||||||
- 一个 `account_name` 对应一个账号文件,可以准备多个账号并发使用
|
- 一个 `account_name` 对应一个账号文件,可以准备多个账号并发使用
|
||||||
|
- 浏览器平台统一元数据约定:
|
||||||
|
- 视频使用 `title + desc + tags`
|
||||||
|
- 图文使用 `title + note + tags`
|
||||||
- `sau bilibili ...` 会自动准备 `biliup`
|
- `sau bilibili ...` 会自动准备 `biliup`
|
||||||
- 如果本地没有 `biliup`,第一次运行会自动下载
|
- 如果本地没有 `biliup`,第一次运行会自动下载
|
||||||
- 如果上游 GitHub Release 有更新,运行时会先自动更新
|
- 如果上游 GitHub Release 有更新,运行时会先自动更新
|
||||||
@@ -77,7 +92,7 @@ sau bilibili upload-video --account <account_name> --file videos/demo.mp4 --titl
|
|||||||
|
|
||||||
## 登录二维码说明
|
## 登录二维码说明
|
||||||
|
|
||||||
- 抖音和快手登录过程中,CLI / uploader 可能会生成临时二维码图片
|
- 抖音、快手、小红书登录过程中,CLI / uploader 可能会生成临时二维码图片
|
||||||
- 对普通用户来说,可以直接打开该图片扫码
|
- 对普通用户来说,可以直接打开该图片扫码
|
||||||
- 对可操作本地文件的 agent 来说,不要只把图片路径告诉用户
|
- 对可操作本地文件的 agent 来说,不要只把图片路径告诉用户
|
||||||
- 这类二维码图片本身就是给用户扫码的,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
|
```bash
|
||||||
sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题" --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 --note "图文示例" --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 "示例标题" --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 --note "图文示例" --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"
|
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
|
```bash
|
||||||
--file videos/demo.mp4
|
--file videos/demo.mp4
|
||||||
--title "示例标题"
|
--title "示例标题"
|
||||||
|
--desc "示例简介"
|
||||||
--tags 运动,训练
|
--tags 运动,训练
|
||||||
--thumbnail videos/demo.png
|
--thumbnail videos/demo.png
|
||||||
```
|
```
|
||||||
@@ -135,7 +153,6 @@ CLI 将 `debug` 和 `headless` 拆成了两个独立维度:
|
|||||||
Bilibili 额外要求:
|
Bilibili 额外要求:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
--desc "视频简介"
|
|
||||||
--tid 249
|
--tid 249
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -147,6 +164,7 @@ Bilibili 额外要求:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
--images videos/1.png videos/2.png videos/3.png
|
--images videos/1.png videos/2.png videos/3.png
|
||||||
|
--title "图文标题"
|
||||||
--note "图文内容"
|
--note "图文内容"
|
||||||
--tags 图文,测试
|
--tags 图文,测试
|
||||||
```
|
```
|
||||||
@@ -155,5 +173,6 @@ Bilibili 额外要求:
|
|||||||
|
|
||||||
- 抖音:最多 35 张图片,不支持 GIF
|
- 抖音:最多 35 张图片,不支持 GIF
|
||||||
- 快手:支持多张图片,建议传真实不同文件,不要把同一路径重复多次
|
- 快手:支持多张图片,建议传真实不同文件,不要把同一路径重复多次
|
||||||
|
- 小红书:支持多张图片,正文 `--note` 可选,但 `--title` 建议始终显式传入
|
||||||
|
|
||||||
后续维护 CLI 时,优先看 `sau_cli.py`、`uploader/` 和 `skills/`。
|
后续维护 CLI 时,优先看 `sau_cli.py`、`uploader/` 和 `skills/`。
|
||||||
|
|||||||
+26
-4
@@ -84,6 +84,7 @@ Windows 也可以直接手动复制并重命名。
|
|||||||
sau --help
|
sau --help
|
||||||
sau douyin --help
|
sau douyin --help
|
||||||
sau kuaishou --help
|
sau kuaishou --help
|
||||||
|
sau xiaohongshu --help
|
||||||
sau bilibili --help
|
sau bilibili --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,7 +98,8 @@ sau bilibili --help
|
|||||||
```bash
|
```bash
|
||||||
sau douyin login --account <account_name>
|
sau douyin login --account <account_name>
|
||||||
sau douyin check --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. 快手主线示例
|
### 8. 快手主线示例
|
||||||
@@ -105,11 +107,20 @@ sau douyin upload-video --account <account_name> --file videos/demo.mp4 --title
|
|||||||
```bash
|
```bash
|
||||||
sau kuaishou login --account <account_name>
|
sau kuaishou login --account <account_name>
|
||||||
sau kuaishou check --account <account_name>
|
sau kuaishou check --account <account_name>
|
||||||
sau kuaishou upload-video --account <account_name> --file videos/demo.mp4 --title "示例标题"
|
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 --note "图文示例"
|
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
|
```bash
|
||||||
sau bilibili login --account <account_name>
|
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`
|
- `creator` 之类的名字只是示例值,真正传的是用户自定义的 `account_name`
|
||||||
- 一个 `account_name` 对应一个账号文件,可以准备多个账号并发使用
|
- 一个 `account_name` 对应一个账号文件,可以准备多个账号并发使用
|
||||||
|
- 浏览器平台统一元数据约定:
|
||||||
|
- 视频使用 `title + desc + tags`
|
||||||
|
- 图文使用 `title + note + tags`
|
||||||
- 用户不需要手动安装 `biliup`
|
- 用户不需要手动安装 `biliup`
|
||||||
- 首次运行 Bilibili 相关命令时,程序会自动下载 `biliup`
|
- 首次运行 Bilibili 相关命令时,程序会自动下载 `biliup`
|
||||||
- 后续运行会自动检查上游 release 并自动更新
|
- 后续运行会自动检查上游 release 并自动更新
|
||||||
@@ -161,6 +175,7 @@ PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright i
|
|||||||
sau --help
|
sau --help
|
||||||
sau douyin --help
|
sau douyin --help
|
||||||
sau kuaishou --help
|
sau kuaishou --help
|
||||||
|
sau xiaohongshu --help
|
||||||
sau bilibili --help
|
sau bilibili --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,6 +192,11 @@ sau kuaishou check
|
|||||||
sau kuaishou upload-video
|
sau kuaishou upload-video
|
||||||
sau kuaishou upload-note
|
sau kuaishou upload-note
|
||||||
|
|
||||||
|
sau xiaohongshu login
|
||||||
|
sau xiaohongshu check
|
||||||
|
sau xiaohongshu upload-video
|
||||||
|
sau xiaohongshu upload-note
|
||||||
|
|
||||||
sau bilibili login
|
sau bilibili login
|
||||||
sau bilibili check
|
sau bilibili check
|
||||||
sau bilibili upload-video
|
sau bilibili upload-video
|
||||||
@@ -188,6 +208,8 @@ sau bilibili upload-video
|
|||||||
- `skills/douyin-upload/references/cli-contract.md`
|
- `skills/douyin-upload/references/cli-contract.md`
|
||||||
- `skills/kuaishou-upload/SKILL.md`
|
- `skills/kuaishou-upload/SKILL.md`
|
||||||
- `skills/kuaishou-upload/references/cli-contract.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/SKILL.md`
|
||||||
- `skills/bilibili-upload/references/cli-contract.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
@@ -47,9 +47,11 @@ PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright i
|
|||||||
sau --help
|
sau --help
|
||||||
sau douyin --help
|
sau douyin --help
|
||||||
sau kuaishou --help
|
sau kuaishou --help
|
||||||
|
sau xiaohongshu --help
|
||||||
sau bilibili --help
|
sau bilibili --help
|
||||||
sau douyin check --account your_account
|
sau douyin check --account your_account
|
||||||
sau kuaishou check --account your_account
|
sau kuaishou check --account your_account
|
||||||
|
sau xiaohongshu check --account your_account
|
||||||
sau bilibili 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/douyin-upload/SKILL.md`
|
||||||
- `skills/kuaishou-upload/SKILL.md`
|
- `skills/kuaishou-upload/SKILL.md`
|
||||||
|
- `skills/xiaohongshu-upload/SKILL.md`
|
||||||
- `skills/bilibili-upload/SKILL.md`
|
- `skills/bilibili-upload/SKILL.md`
|
||||||
- `docs/CLI.md`
|
- `docs/CLI.md`
|
||||||
|
|
||||||
@@ -84,14 +87,16 @@ uv pip install -e .
|
|||||||
sau --help
|
sau --help
|
||||||
sau douyin --help
|
sau douyin --help
|
||||||
sau kuaishou --help
|
sau kuaishou --help
|
||||||
|
sau xiaohongshu --help
|
||||||
sau bilibili --help
|
sau bilibili --help
|
||||||
```
|
```
|
||||||
|
|
||||||
5. 如果任务是抖音或快手链路,再继续验证:
|
5. 如果任务是浏览器平台链路,再继续验证:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sau douyin check --account test
|
sau douyin check --account test
|
||||||
sau kuaishou check --account test
|
sau kuaishou check --account test
|
||||||
|
sau xiaohongshu check --account test
|
||||||
sau bilibili 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/SKILL.md`
|
||||||
- `skills/kuaishou-upload/references/cli-contract.md`
|
- `skills/kuaishou-upload/references/cli-contract.md`
|
||||||
- `skills/kuaishou-upload/references/runtime-requirements.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/SKILL.md`
|
||||||
- `skills/bilibili-upload/references/cli-contract.md`
|
- `skills/bilibili-upload/references/cli-contract.md`
|
||||||
- `skills/bilibili-upload/references/runtime-requirements.md`
|
- `skills/bilibili-upload/references/runtime-requirements.md`
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
"""
|
||||||
|
当前主线优先使用 CLI:
|
||||||
|
|
||||||
|
sau xiaohongshu login --account <account_name>
|
||||||
|
|
||||||
|
这个脚本保留为小红书 uploader 的调试入口 / 历史直连路径。
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
+193
-2
@@ -26,6 +26,14 @@ from uploader.ks_uploader.main import (
|
|||||||
cookie_auth as kuaishou_cookie_auth,
|
cookie_auth as kuaishou_cookie_auth,
|
||||||
ks_setup,
|
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"
|
SCHEDULE_FORMAT = "%Y-%m-%d %H:%M"
|
||||||
|
|
||||||
@@ -35,6 +43,7 @@ class DouyinVideoUploadRequest:
|
|||||||
account_name: str
|
account_name: str
|
||||||
video_file: Path
|
video_file: Path
|
||||||
title: str
|
title: str
|
||||||
|
description: str
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
publish_date: datetime | int
|
publish_date: datetime | int
|
||||||
thumbnail_file: Path | None = None
|
thumbnail_file: Path | None = None
|
||||||
@@ -49,6 +58,7 @@ class DouyinVideoUploadRequest:
|
|||||||
class DouyinNoteUploadRequest:
|
class DouyinNoteUploadRequest:
|
||||||
account_name: str
|
account_name: str
|
||||||
image_files: list[Path]
|
image_files: list[Path]
|
||||||
|
title: str
|
||||||
note: str
|
note: str
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
publish_date: datetime | int
|
publish_date: datetime | int
|
||||||
@@ -62,6 +72,7 @@ class KuaishouVideoUploadRequest:
|
|||||||
account_name: str
|
account_name: str
|
||||||
video_file: Path
|
video_file: Path
|
||||||
title: str
|
title: str
|
||||||
|
description: str
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
publish_date: datetime | int
|
publish_date: datetime | int
|
||||||
thumbnail_file: Path | None = None
|
thumbnail_file: Path | None = None
|
||||||
@@ -74,6 +85,7 @@ class KuaishouVideoUploadRequest:
|
|||||||
class KuaishouNoteUploadRequest:
|
class KuaishouNoteUploadRequest:
|
||||||
account_name: str
|
account_name: str
|
||||||
image_files: list[Path]
|
image_files: list[Path]
|
||||||
|
title: str
|
||||||
note: str
|
note: str
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
publish_date: datetime | int
|
publish_date: datetime | int
|
||||||
@@ -82,6 +94,33 @@ class KuaishouNoteUploadRequest:
|
|||||||
headless: bool = True
|
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)
|
@dataclass(slots=True)
|
||||||
class BilibiliVideoUploadRequest:
|
class BilibiliVideoUploadRequest:
|
||||||
account_name: str
|
account_name: str
|
||||||
@@ -153,6 +192,18 @@ async def check_kuaishou_account(account_name: str) -> bool:
|
|||||||
return await kuaishou_cookie_auth(str(account_file))
|
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:
|
async def login_bilibili_account(account_name: str) -> dict:
|
||||||
account_file = resolve_account_file("bilibili", account_name)
|
account_file = resolve_account_file("bilibili", account_name)
|
||||||
if not has_interactive_terminal():
|
if not has_interactive_terminal():
|
||||||
@@ -197,6 +248,7 @@ async def upload_video(request: DouyinVideoUploadRequest) -> Path:
|
|||||||
request.tags,
|
request.tags,
|
||||||
request.publish_date,
|
request.publish_date,
|
||||||
str(account_file),
|
str(account_file),
|
||||||
|
desc=request.description,
|
||||||
thumbnail_portrait_path=str(request.thumbnail_file) if request.thumbnail_file else None,
|
thumbnail_portrait_path=str(request.thumbnail_file) if request.thumbnail_file else None,
|
||||||
productLink=request.product_link,
|
productLink=request.product_link,
|
||||||
productTitle=request.product_title,
|
productTitle=request.product_title,
|
||||||
@@ -218,6 +270,7 @@ async def upload_note(request: DouyinNoteUploadRequest) -> Path:
|
|||||||
|
|
||||||
app = DouYinNote(
|
app = DouYinNote(
|
||||||
image_paths=[str(path) for path in request.image_files],
|
image_paths=[str(path) for path in request.image_files],
|
||||||
|
title=request.title,
|
||||||
note=request.note,
|
note=request.note,
|
||||||
tags=request.tags,
|
tags=request.tags,
|
||||||
publish_date=request.publish_date,
|
publish_date=request.publish_date,
|
||||||
@@ -241,6 +294,7 @@ async def upload_kuaishou_video(request: KuaishouVideoUploadRequest) -> Path:
|
|||||||
app = KSVideo(
|
app = KSVideo(
|
||||||
title=request.title,
|
title=request.title,
|
||||||
file_path=str(request.video_file),
|
file_path=str(request.video_file),
|
||||||
|
desc=request.description,
|
||||||
tags=request.tags,
|
tags=request.tags,
|
||||||
publish_date=request.publish_date,
|
publish_date=request.publish_date,
|
||||||
account_file=str(account_file),
|
account_file=str(account_file),
|
||||||
@@ -263,6 +317,55 @@ async def upload_kuaishou_note(request: KuaishouNoteUploadRequest) -> Path:
|
|||||||
|
|
||||||
app = KSNote(
|
app = KSNote(
|
||||||
image_paths=[str(path) for path in request.image_files],
|
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,
|
note=request.note,
|
||||||
tags=request.tags,
|
tags=request.tags,
|
||||||
publish_date=request.publish_date,
|
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("--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("--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("--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("--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("--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")
|
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 = 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("--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("--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("--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}")
|
upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
|
||||||
add_runtime_flags(upload_note_parser)
|
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("--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("--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("--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("--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("--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")
|
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 = 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("--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("--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("--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}")
|
kuaishou_upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}")
|
||||||
add_runtime_flags(kuaishou_upload_note_parser)
|
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_parser = platform_parsers.add_parser("bilibili", help="Bilibili operations")
|
||||||
bilibili_actions = bilibili_parser.add_subparsers(dest="action", required=True)
|
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,
|
account_name=args.account,
|
||||||
video_file=args.file,
|
video_file=args.file,
|
||||||
title=args.title,
|
title=args.title,
|
||||||
|
description=args.desc,
|
||||||
tags=parse_tags(args.tags),
|
tags=parse_tags(args.tags),
|
||||||
publish_date=args.schedule or 0,
|
publish_date=args.schedule or 0,
|
||||||
thumbnail_file=args.thumbnail,
|
thumbnail_file=args.thumbnail,
|
||||||
@@ -447,6 +583,7 @@ async def dispatch(args: argparse.Namespace) -> int:
|
|||||||
request = DouyinNoteUploadRequest(
|
request = DouyinNoteUploadRequest(
|
||||||
account_name=args.account,
|
account_name=args.account,
|
||||||
image_files=parse_image_files(args.images),
|
image_files=parse_image_files(args.images),
|
||||||
|
title=args.title,
|
||||||
note=args.note,
|
note=args.note,
|
||||||
tags=parse_tags(args.tags),
|
tags=parse_tags(args.tags),
|
||||||
publish_date=args.schedule or 0,
|
publish_date=args.schedule or 0,
|
||||||
@@ -480,6 +617,7 @@ async def dispatch(args: argparse.Namespace) -> int:
|
|||||||
account_name=args.account,
|
account_name=args.account,
|
||||||
video_file=args.file,
|
video_file=args.file,
|
||||||
title=args.title,
|
title=args.title,
|
||||||
|
description=args.desc,
|
||||||
tags=parse_tags(args.tags),
|
tags=parse_tags(args.tags),
|
||||||
publish_date=args.schedule or 0,
|
publish_date=args.schedule or 0,
|
||||||
thumbnail_file=args.thumbnail,
|
thumbnail_file=args.thumbnail,
|
||||||
@@ -495,6 +633,7 @@ async def dispatch(args: argparse.Namespace) -> int:
|
|||||||
request = KuaishouNoteUploadRequest(
|
request = KuaishouNoteUploadRequest(
|
||||||
account_name=args.account,
|
account_name=args.account,
|
||||||
image_files=parse_image_files(args.images),
|
image_files=parse_image_files(args.images),
|
||||||
|
title=args.title,
|
||||||
note=args.note,
|
note=args.note,
|
||||||
tags=parse_tags(args.tags),
|
tags=parse_tags(args.tags),
|
||||||
publish_date=args.schedule or 0,
|
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}")
|
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.platform == "bilibili":
|
||||||
if args.action == "login":
|
if args.action == "login":
|
||||||
result = await login_bilibili_account(args.account)
|
result = await login_bilibili_account(args.account)
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ description: 当 agent 需要通过已安装的 `sau` CLI 完成抖音登录、c
|
|||||||
| 视频上传 | `sau douyin upload-video ...` | 上传并发布抖音视频 |
|
| 视频上传 | `sau douyin upload-video ...` | 上传并发布抖音视频 |
|
||||||
| 图文上传 | `sau douyin upload-note ...` | 上传并发布抖音图文 |
|
| 图文上传 | `sau douyin upload-note ...` | 上传并发布抖音图文 |
|
||||||
|
|
||||||
|
元数据约定:
|
||||||
|
|
||||||
|
- 视频使用 `title + desc + tags`
|
||||||
|
- 图文使用 `title + note + tags`
|
||||||
|
|
||||||
## 默认工作流
|
## 默认工作流
|
||||||
|
|
||||||
1. 先确认 `references/runtime-requirements.md` 里的运行前提。
|
1. 先确认 `references/runtime-requirements.md` 里的运行前提。
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ sau douyin upload-video \
|
|||||||
--account <account> \
|
--account <account> \
|
||||||
--file <video-path> \
|
--file <video-path> \
|
||||||
--title "<title>" \
|
--title "<title>" \
|
||||||
|
[--desc "<description>"] \
|
||||||
[--tags tag1,tag2] \
|
[--tags tag1,tag2] \
|
||||||
[--schedule "YYYY-MM-DD HH:MM"] \
|
[--schedule "YYYY-MM-DD HH:MM"] \
|
||||||
[--thumbnail <image-path>] \
|
[--thumbnail <image-path>] \
|
||||||
@@ -52,6 +53,7 @@ sau douyin upload-video \
|
|||||||
- `--file`
|
- `--file`
|
||||||
- `--title`
|
- `--title`
|
||||||
- 可选参数:
|
- 可选参数:
|
||||||
|
- `--desc`
|
||||||
- `--tags`
|
- `--tags`
|
||||||
- `--schedule`
|
- `--schedule`
|
||||||
- `--thumbnail`
|
- `--thumbnail`
|
||||||
@@ -67,7 +69,8 @@ sau douyin upload-video \
|
|||||||
sau douyin upload-note \
|
sau douyin upload-note \
|
||||||
--account <account> \
|
--account <account> \
|
||||||
--images <image-1> [image-2 ...] \
|
--images <image-1> [image-2 ...] \
|
||||||
--note "<content>" \
|
--title "<title>" \
|
||||||
|
[--note "<content>"] \
|
||||||
[--tags tag1,tag2] \
|
[--tags tag1,tag2] \
|
||||||
[--schedule "YYYY-MM-DD HH:MM"] \
|
[--schedule "YYYY-MM-DD HH:MM"] \
|
||||||
[--debug] \
|
[--debug] \
|
||||||
@@ -77,8 +80,9 @@ sau douyin upload-note \
|
|||||||
- 必填参数:
|
- 必填参数:
|
||||||
- `--account`
|
- `--account`
|
||||||
- `--images`
|
- `--images`
|
||||||
- `--note`
|
- `--title`
|
||||||
- 可选参数:
|
- 可选参数:
|
||||||
|
- `--note`
|
||||||
- `--tags`
|
- `--tags`
|
||||||
- `--schedule`
|
- `--schedule`
|
||||||
- `--debug`
|
- `--debug`
|
||||||
@@ -99,5 +103,7 @@ YYYY-MM-DD HH:MM
|
|||||||
|
|
||||||
- `upload-video` 每次命令只支持一个视频文件
|
- `upload-video` 每次命令只支持一个视频文件
|
||||||
- `upload-note` 每次命令支持多张图片
|
- `upload-note` 每次命令支持多张图片
|
||||||
|
- 视频描述字段统一使用 `--desc`
|
||||||
|
- 图文正文统一使用 `--note`
|
||||||
- `upload-note` 当前不支持 GIF
|
- `upload-note` 当前不支持 GIF
|
||||||
- `upload-note` 当前最多支持 35 张图片
|
- `upload-note` 当前最多支持 35 张图片
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ sau douyin login --account <account>
|
|||||||
|
|
||||||
- `--account`
|
- `--account`
|
||||||
- `--images`
|
- `--images`
|
||||||
- `--note`
|
- `--title`
|
||||||
|
|
||||||
|
`--note` 当前是可选图文正文。
|
||||||
|
|
||||||
## 图片限制
|
## 图片限制
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ def main() -> None:
|
|||||||
"videos/demo.mp4",
|
"videos/demo.mp4",
|
||||||
"--title",
|
"--title",
|
||||||
"Douyin video from Python",
|
"Douyin video from Python",
|
||||||
|
"--desc",
|
||||||
|
"Douyin video description from Python",
|
||||||
"--tags",
|
"--tags",
|
||||||
"cli,video",
|
"cli,video",
|
||||||
"--thumbnail",
|
"--thumbnail",
|
||||||
@@ -42,6 +44,8 @@ def main() -> None:
|
|||||||
"--images",
|
"--images",
|
||||||
"videos/1.png",
|
"videos/1.png",
|
||||||
"videos/2.png",
|
"videos/2.png",
|
||||||
|
"--title",
|
||||||
|
"Douyin note title from Python",
|
||||||
"--note",
|
"--note",
|
||||||
"Douyin note from Python",
|
"Douyin note from Python",
|
||||||
"--tags",
|
"--tags",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ sau douyin upload-video `
|
|||||||
--account $account `
|
--account $account `
|
||||||
--file $video `
|
--file $video `
|
||||||
--title "Douyin video from PowerShell" `
|
--title "Douyin video from PowerShell" `
|
||||||
|
--desc "Douyin video description from PowerShell" `
|
||||||
--tags "cli,video" `
|
--tags "cli,video" `
|
||||||
--thumbnail $thumbnail `
|
--thumbnail $thumbnail `
|
||||||
--headless
|
--headless
|
||||||
@@ -21,6 +22,7 @@ sau douyin upload-video `
|
|||||||
sau douyin upload-note `
|
sau douyin upload-note `
|
||||||
--account $account `
|
--account $account `
|
||||||
--images $noteImages `
|
--images $noteImages `
|
||||||
|
--title "Douyin note title from PowerShell" `
|
||||||
--note "Douyin note from PowerShell" `
|
--note "Douyin note from PowerShell" `
|
||||||
--tags "cli,note" `
|
--tags "cli,note" `
|
||||||
--headless
|
--headless
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ sau douyin upload-video \
|
|||||||
--account "$account" \
|
--account "$account" \
|
||||||
--file "$video" \
|
--file "$video" \
|
||||||
--title "Douyin video from bash" \
|
--title "Douyin video from bash" \
|
||||||
|
--desc "Douyin video description from bash" \
|
||||||
--tags "cli,video" \
|
--tags "cli,video" \
|
||||||
--thumbnail "$thumbnail" \
|
--thumbnail "$thumbnail" \
|
||||||
--headless
|
--headless
|
||||||
@@ -22,6 +23,7 @@ sau douyin upload-video \
|
|||||||
sau douyin upload-note \
|
sau douyin upload-note \
|
||||||
--account "$account" \
|
--account "$account" \
|
||||||
--images "videos/1.png" "videos/2.png" \
|
--images "videos/1.png" "videos/2.png" \
|
||||||
|
--title "Douyin note title from bash" \
|
||||||
--note "Douyin note from bash" \
|
--note "Douyin note from bash" \
|
||||||
--tags "cli,note" \
|
--tags "cli,note" \
|
||||||
--headless
|
--headless
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ description: 当 agent 需要通过已安装的 `sau` CLI 完成快手登录、c
|
|||||||
| 视频上传 | `sau kuaishou upload-video ...` | 上传并发布快手视频 |
|
| 视频上传 | `sau kuaishou upload-video ...` | 上传并发布快手视频 |
|
||||||
| 图文上传 | `sau kuaishou upload-note ...` | 上传并发布快手图文 |
|
| 图文上传 | `sau kuaishou upload-note ...` | 上传并发布快手图文 |
|
||||||
|
|
||||||
|
元数据约定:
|
||||||
|
|
||||||
|
- 视频使用 `title + desc + tags`
|
||||||
|
- 图文使用 `title + note + tags`
|
||||||
|
|
||||||
## 默认工作流
|
## 默认工作流
|
||||||
|
|
||||||
1. 先确认 `references/runtime-requirements.md` 里的运行前提。
|
1. 先确认 `references/runtime-requirements.md` 里的运行前提。
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ sau kuaishou upload-video \
|
|||||||
--account <account> \
|
--account <account> \
|
||||||
--file <video-path> \
|
--file <video-path> \
|
||||||
--title "<title>" \
|
--title "<title>" \
|
||||||
|
[--desc "<description>"] \
|
||||||
[--tags tag1,tag2] \
|
[--tags tag1,tag2] \
|
||||||
[--schedule "YYYY-MM-DD HH:MM"] \
|
[--schedule "YYYY-MM-DD HH:MM"] \
|
||||||
[--thumbnail <image-path>] \
|
[--thumbnail <image-path>] \
|
||||||
@@ -50,6 +51,7 @@ sau kuaishou upload-video \
|
|||||||
- `--file`
|
- `--file`
|
||||||
- `--title`
|
- `--title`
|
||||||
- 可选参数:
|
- 可选参数:
|
||||||
|
- `--desc`
|
||||||
- `--tags`
|
- `--tags`
|
||||||
- `--schedule`
|
- `--schedule`
|
||||||
- `--thumbnail`
|
- `--thumbnail`
|
||||||
@@ -63,7 +65,8 @@ sau kuaishou upload-video \
|
|||||||
sau kuaishou upload-note \
|
sau kuaishou upload-note \
|
||||||
--account <account> \
|
--account <account> \
|
||||||
--images <image-1> [image-2 ...] \
|
--images <image-1> [image-2 ...] \
|
||||||
--note "<content>" \
|
--title "<title>" \
|
||||||
|
[--note "<content>"] \
|
||||||
[--tags tag1,tag2] \
|
[--tags tag1,tag2] \
|
||||||
[--schedule "YYYY-MM-DD HH:MM"] \
|
[--schedule "YYYY-MM-DD HH:MM"] \
|
||||||
[--debug] \
|
[--debug] \
|
||||||
@@ -73,8 +76,9 @@ sau kuaishou upload-note \
|
|||||||
- 必填参数:
|
- 必填参数:
|
||||||
- `--account`
|
- `--account`
|
||||||
- `--images`
|
- `--images`
|
||||||
- `--note`
|
- `--title`
|
||||||
- 可选参数:
|
- 可选参数:
|
||||||
|
- `--note`
|
||||||
- `--tags`
|
- `--tags`
|
||||||
- `--schedule`
|
- `--schedule`
|
||||||
- `--debug`
|
- `--debug`
|
||||||
@@ -95,4 +99,6 @@ YYYY-MM-DD HH:MM
|
|||||||
|
|
||||||
- `upload-video` 每次命令只支持一个视频文件
|
- `upload-video` 每次命令只支持一个视频文件
|
||||||
- `upload-note` 每次命令支持多张图片
|
- `upload-note` 每次命令支持多张图片
|
||||||
|
- 视频描述字段统一使用 `--desc`
|
||||||
|
- 图文正文统一使用 `--note`
|
||||||
- `upload-note` 当前要求传入真实的多张图片文件,而不是同一路径重复多次
|
- `upload-note` 当前要求传入真实的多张图片文件,而不是同一路径重复多次
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ sau kuaishou login --account <account>
|
|||||||
|
|
||||||
- `--account`
|
- `--account`
|
||||||
- `--images`
|
- `--images`
|
||||||
- `--note`
|
- `--title`
|
||||||
|
|
||||||
|
`--note` 当前是可选图文正文。
|
||||||
|
|
||||||
## 图文上传只有一张生效
|
## 图文上传只有一张生效
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ def main() -> None:
|
|||||||
"videos/demo.mp4",
|
"videos/demo.mp4",
|
||||||
"--title",
|
"--title",
|
||||||
"Kuaishou video from Python",
|
"Kuaishou video from Python",
|
||||||
|
"--desc",
|
||||||
|
"Kuaishou video description from Python",
|
||||||
"--tags",
|
"--tags",
|
||||||
"cli,video",
|
"cli,video",
|
||||||
"--thumbnail",
|
"--thumbnail",
|
||||||
@@ -43,6 +45,8 @@ def main() -> None:
|
|||||||
"videos/1.png",
|
"videos/1.png",
|
||||||
"videos/2.png",
|
"videos/2.png",
|
||||||
"videos/3.png",
|
"videos/3.png",
|
||||||
|
"--title",
|
||||||
|
"Kuaishou note title from Python",
|
||||||
"--note",
|
"--note",
|
||||||
"Kuaishou note from Python",
|
"Kuaishou note from Python",
|
||||||
"--tags",
|
"--tags",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ sau kuaishou upload-video `
|
|||||||
--account $account `
|
--account $account `
|
||||||
--file $video `
|
--file $video `
|
||||||
--title "Kuaishou video from PowerShell" `
|
--title "Kuaishou video from PowerShell" `
|
||||||
|
--desc "Kuaishou video description from PowerShell" `
|
||||||
--tags "cli,video" `
|
--tags "cli,video" `
|
||||||
--thumbnail $thumbnail `
|
--thumbnail $thumbnail `
|
||||||
--headless
|
--headless
|
||||||
@@ -21,6 +22,7 @@ sau kuaishou upload-video `
|
|||||||
sau kuaishou upload-note `
|
sau kuaishou upload-note `
|
||||||
--account $account `
|
--account $account `
|
||||||
--images $noteImages `
|
--images $noteImages `
|
||||||
|
--title "Kuaishou note title from PowerShell" `
|
||||||
--note "Kuaishou note from PowerShell" `
|
--note "Kuaishou note from PowerShell" `
|
||||||
--tags "cli,note" `
|
--tags "cli,note" `
|
||||||
--headless
|
--headless
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ sau kuaishou upload-video \
|
|||||||
--account "$account" \
|
--account "$account" \
|
||||||
--file "$video" \
|
--file "$video" \
|
||||||
--title "Kuaishou video from bash" \
|
--title "Kuaishou video from bash" \
|
||||||
|
--desc "Kuaishou video description from bash" \
|
||||||
--tags "cli,video" \
|
--tags "cli,video" \
|
||||||
--thumbnail "$thumbnail" \
|
--thumbnail "$thumbnail" \
|
||||||
--headless
|
--headless
|
||||||
@@ -22,6 +23,7 @@ sau kuaishou upload-video \
|
|||||||
sau kuaishou upload-note \
|
sau kuaishou upload-note \
|
||||||
--account "$account" \
|
--account "$account" \
|
||||||
--images "videos/1.png" "videos/2.png" "videos/3.png" \
|
--images "videos/1.png" "videos/2.png" "videos/3.png" \
|
||||||
|
--title "Kuaishou note title from bash" \
|
||||||
--note "Kuaishou note from bash" \
|
--note "Kuaishou note from bash" \
|
||||||
--tags "cli,note" \
|
--tags "cli,note" \
|
||||||
--headless
|
--headless
|
||||||
|
|||||||
@@ -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 / macOS(bash / 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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -380,6 +380,7 @@ class DouYinVideo(DouYinBaseUploader):
|
|||||||
productLink="",
|
productLink="",
|
||||||
productTitle="",
|
productTitle="",
|
||||||
thumbnail_portrait_path=None,
|
thumbnail_portrait_path=None,
|
||||||
|
desc: str | None = None,
|
||||||
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
|
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
|
||||||
debug: bool = DEBUG_MODE,
|
debug: bool = DEBUG_MODE,
|
||||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||||
@@ -398,6 +399,7 @@ class DouYinVideo(DouYinBaseUploader):
|
|||||||
self.thumbnail_portrait_path = thumbnail_portrait_path
|
self.thumbnail_portrait_path = thumbnail_portrait_path
|
||||||
self.productLink = productLink
|
self.productLink = productLink
|
||||||
self.productTitle = productTitle
|
self.productTitle = productTitle
|
||||||
|
self.desc = desc or ""
|
||||||
|
|
||||||
async def validate_upload_args(self):
|
async def validate_upload_args(self):
|
||||||
await self.validate_base_args()
|
await self.validate_base_args()
|
||||||
@@ -505,7 +507,7 @@ class DouYinVideo(DouYinBaseUploader):
|
|||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
|
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)} 个话题"))
|
douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题"))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@@ -578,6 +580,7 @@ class DouYinNote(DouYinBaseUploader):
|
|||||||
tags,
|
tags,
|
||||||
publish_date: datetime | int,
|
publish_date: datetime | int,
|
||||||
account_file,
|
account_file,
|
||||||
|
title: str | None = None,
|
||||||
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
|
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
|
||||||
debug: bool = DEBUG_MODE,
|
debug: bool = DEBUG_MODE,
|
||||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||||
@@ -590,13 +593,14 @@ class DouYinNote(DouYinBaseUploader):
|
|||||||
headless=headless,
|
headless=headless,
|
||||||
)
|
)
|
||||||
self.image_paths = image_paths
|
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 []
|
self.tags = tags or []
|
||||||
|
|
||||||
async def validate_upload_args(self):
|
async def validate_upload_args(self):
|
||||||
await self.validate_base_args()
|
await self.validate_base_args()
|
||||||
if not self.note or not str(self.note).strip():
|
if not self.title or not str(self.title).strip():
|
||||||
raise ValueError("图文模式下,note 是必须的")
|
raise ValueError("图文模式下,title 是必须的")
|
||||||
if not self.image_paths:
|
if not self.image_paths:
|
||||||
raise ValueError("图文模式下,图片是必须的")
|
raise ValueError("图文模式下,图片是必须的")
|
||||||
|
|
||||||
@@ -634,7 +638,7 @@ class DouYinNote(DouYinBaseUploader):
|
|||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
|
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)} 个话题"))
|
douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题"))
|
||||||
|
|
||||||
if self.publish_strategy == DOUYIN_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
|
if self.publish_strategy == DOUYIN_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ class KSVideo(KSBaseUploader):
|
|||||||
debug: bool = DEBUG_MODE,
|
debug: bool = DEBUG_MODE,
|
||||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||||
thumbnail_path=None,
|
thumbnail_path=None,
|
||||||
|
desc: str | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
publish_date=publish_date,
|
publish_date=publish_date,
|
||||||
@@ -370,6 +371,7 @@ class KSVideo(KSBaseUploader):
|
|||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.tags = tags or []
|
self.tags = tags or []
|
||||||
self.thumbnail_path = thumbnail_path
|
self.thumbnail_path = thumbnail_path
|
||||||
|
self.desc = desc or ""
|
||||||
|
|
||||||
async def validate_upload_args(self):
|
async def validate_upload_args(self):
|
||||||
await self.validate_base_args()
|
await self.validate_base_args()
|
||||||
@@ -462,7 +464,7 @@ class KSVideo(KSBaseUploader):
|
|||||||
await page.keyboard.press("Backspace")
|
await page.keyboard.press("Backspace")
|
||||||
await page.keyboard.press("Control+KeyA")
|
await page.keyboard.press("Control+KeyA")
|
||||||
await page.keyboard.press("Delete")
|
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")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
for index, tag in enumerate(self.tags[:3], start=1):
|
for index, tag in enumerate(self.tags[:3], start=1):
|
||||||
@@ -541,6 +543,7 @@ class KSNote(KSBaseUploader):
|
|||||||
tags,
|
tags,
|
||||||
publish_date: datetime | int,
|
publish_date: datetime | int,
|
||||||
account_file,
|
account_file,
|
||||||
|
title: str | None = None,
|
||||||
publish_strategy: str | None = None,
|
publish_strategy: str | None = None,
|
||||||
debug: bool = DEBUG_MODE,
|
debug: bool = DEBUG_MODE,
|
||||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||||
@@ -553,13 +556,14 @@ class KSNote(KSBaseUploader):
|
|||||||
headless=headless,
|
headless=headless,
|
||||||
)
|
)
|
||||||
self.image_paths = image_paths
|
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 []
|
self.tags = tags or []
|
||||||
|
|
||||||
async def validate_upload_args(self):
|
async def validate_upload_args(self):
|
||||||
await self.validate_base_args()
|
await self.validate_base_args()
|
||||||
if not self.note or not str(self.note).strip():
|
if not self.title or not str(self.title).strip():
|
||||||
raise ValueError("快手图文上传时,note 是必须的")
|
raise ValueError("快手图文上传时,title 是必须的")
|
||||||
if not self.image_paths:
|
if not self.image_paths:
|
||||||
raise ValueError("快手图文上传时,图片是必须的")
|
raise ValueError("快手图文上传时,图片是必须的")
|
||||||
|
|
||||||
|
|||||||
@@ -308,39 +308,30 @@ class XiaoHongShuBaseUploader(BaseVideoUploader):
|
|||||||
self.publish_date = 0
|
self.publish_date = 0
|
||||||
|
|
||||||
async def set_schedule_time_xiaohongshu(self, page: Page, publish_date: datetime):
|
async def set_schedule_time_xiaohongshu(self, page: Page, publish_date: datetime):
|
||||||
print(" [-] 正在设置定时发布时间...")
|
xiaohongshu_logger.info(_msg("🕒", f"小人准备设置定时发布时间: {publish_date.strftime(self.date_format)}"))
|
||||||
print(f"publish_date: {publish_date}")
|
|
||||||
await page.locator('.custom-switch-card').filter(has_text="定时发布").locator('.d-switch').click()
|
await page.locator('.custom-switch-card').filter(has_text="定时发布").locator('.d-switch').click()
|
||||||
await asyncio.sleep(1)
|
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")
|
||||||
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')
|
time_input = page.locator('.d-datepicker-input-filter input.d-text')
|
||||||
await time_input.fill(str(publish_date_hour))
|
await time_input.fill(str(publish_date_hour))
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
async def set_location(self, page: Page, location: str = "青岛市"):
|
async def set_location(self, page: Page, location: str = "青岛市"):
|
||||||
print(f"开始设置位置: {location}")
|
if not location:
|
||||||
print("等待地点输入框加载...")
|
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')
|
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()
|
await loc_ele.click()
|
||||||
print("点击地点输入框完成")
|
|
||||||
print(f"等待1秒后输入位置名称: {location}")
|
|
||||||
await page.wait_for_timeout(1000)
|
await page.wait_for_timeout(1000)
|
||||||
await page.keyboard.type(location)
|
await page.keyboard.type(location)
|
||||||
print(f"位置名称输入完成: {location}")
|
|
||||||
print("等待下拉列表加载...")
|
|
||||||
dropdown_selector = 'div.d-popover.d-popover-default.d-dropdown.--size-min-width-large'
|
dropdown_selector = 'div.d-popover.d-popover-default.d-dropdown.--size-min-width-large'
|
||||||
await page.wait_for_timeout(2000)
|
await page.wait_for_timeout(2000)
|
||||||
try:
|
try:
|
||||||
await page.wait_for_selector(dropdown_selector, timeout=3000)
|
await page.wait_for_selector(dropdown_selector, timeout=3000)
|
||||||
print("下拉列表已加载")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
print("下拉列表未按预期显示,可能结构已变化")
|
xiaohongshu_logger.warning(_msg("😵", "位置下拉列表没按预期出现,小人继续按旧逻辑查找"))
|
||||||
print("额外等待1秒确保内容渲染完成...")
|
|
||||||
await page.wait_for_timeout(1000)
|
await page.wait_for_timeout(1000)
|
||||||
print("尝试使用更灵活的XPath选择器...")
|
|
||||||
flexible_xpath = (
|
flexible_xpath = (
|
||||||
f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
|
f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
|
||||||
f'//div[contains(@class, "d-options-wrapper")]'
|
f'//div[contains(@class, "d-options-wrapper")]'
|
||||||
@@ -348,17 +339,13 @@ class XiaoHongShuBaseUploader(BaseVideoUploader):
|
|||||||
f'//div[contains(@class, "name") and text()="{location}"]'
|
f'//div[contains(@class, "name") and text()="{location}"]'
|
||||||
)
|
)
|
||||||
await page.wait_for_timeout(3000)
|
await page.wait_for_timeout(3000)
|
||||||
print(f"尝试定位包含'{location}'的选项...")
|
|
||||||
try:
|
try:
|
||||||
location_option = await page.wait_for_selector(
|
location_option = await page.wait_for_selector(
|
||||||
flexible_xpath,
|
flexible_xpath,
|
||||||
timeout=3000
|
timeout=3000
|
||||||
)
|
)
|
||||||
|
|
||||||
if location_option:
|
if not location_option:
|
||||||
print(f"使用灵活选择器定位成功: {location_option}")
|
|
||||||
else:
|
|
||||||
print("灵活选择器未找到元素,尝试原始选择器...")
|
|
||||||
location_option = await page.wait_for_selector(
|
location_option = await page.wait_for_selector(
|
||||||
f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
|
f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
|
||||||
f'//div[contains(@class, "d-options-wrapper")]'
|
f'//div[contains(@class, "d-options-wrapper")]'
|
||||||
@@ -367,18 +354,12 @@ class XiaoHongShuBaseUploader(BaseVideoUploader):
|
|||||||
timeout=2000
|
timeout=2000
|
||||||
)
|
)
|
||||||
|
|
||||||
print("滚动到目标选项...")
|
|
||||||
await location_option.scroll_into_view_if_needed()
|
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()
|
await location_option.click()
|
||||||
print(f"成功选择位置: {location}")
|
xiaohongshu_logger.success(_msg("🥳", f"位置已经设置成 {location}"))
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"定位位置失败: {e}")
|
xiaohongshu_logger.error(_msg("😢", f"设置位置失败: {e}"))
|
||||||
print("尝试获取下拉列表中的所有选项...")
|
|
||||||
try:
|
try:
|
||||||
all_options = await page.query_selector_all(
|
all_options = await page.query_selector_all(
|
||||||
'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]'
|
'//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[contains(@class, "d-grid") and contains(@class, "d-options")]'
|
||||||
'/div'
|
'/div'
|
||||||
)
|
)
|
||||||
print(f"找到 {len(all_options)} 个选项")
|
xiaohongshu_logger.debug(_msg("🧍", f"位置下拉里一共找到 {len(all_options)} 个选项"))
|
||||||
for i, option in enumerate(all_options[:3]):
|
for i, option in enumerate(all_options[:3]):
|
||||||
option_text = await option.inner_text()
|
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:
|
except Exception as inner_e:
|
||||||
print(f"获取选项列表失败: {inner_e}")
|
xiaohongshu_logger.debug(_msg("😵", f"读取位置候选列表失败: {inner_e}"))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def fill_title(self, page: Page) -> None:
|
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))
|
self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path))
|
||||||
|
|
||||||
async def handle_upload_error(self, page: Page):
|
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)
|
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):
|
async def set_thumbnail(self, page: Page, thumbnail_path: str):
|
||||||
@@ -503,8 +484,8 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
|
|||||||
xiaohongshu_logger.success(_msg("🥳", "封面已经设置完成"))
|
xiaohongshu_logger.success(_msg("🥳", "封面已经设置完成"))
|
||||||
|
|
||||||
async def upload_video_content(self, page: Page) -> None:
|
async def upload_video_content(self, page: Page) -> None:
|
||||||
xiaohongshu_logger.info(f'[+]正在上传-------{self.title}.mp4')
|
xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4"))
|
||||||
xiaohongshu_logger.info(f'[-] 正在打开主页...')
|
xiaohongshu_logger.info(_msg("🧭", "小人正在赶往视频发布页"))
|
||||||
await page.goto(XHS_PUBLISH_VIDEO_URL)
|
await page.goto(XHS_PUBLISH_VIDEO_URL)
|
||||||
await page.wait_for_url(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)
|
await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(self.file_path)
|
||||||
@@ -523,17 +504,16 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
|
|||||||
upload_success = True
|
upload_success = True
|
||||||
break
|
break
|
||||||
if upload_success:
|
if upload_success:
|
||||||
xiaohongshu_logger.info("[+] 检测到上传成功标识!")
|
xiaohongshu_logger.success(_msg("🥳", "视频已经传完啦"))
|
||||||
break
|
break
|
||||||
print(" [-] 未找到上传成功标识,继续等待...")
|
xiaohongshu_logger.debug(_msg("🧍", "还没看到上传成功标识,小人继续等一会"))
|
||||||
else:
|
else:
|
||||||
print(" [-] 未找到预览元素,继续等待...")
|
xiaohongshu_logger.debug(_msg("🧍", "还没拿到预览区域,小人继续等一会"))
|
||||||
await asyncio.sleep(3)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [-] 检测过程出错: {str(e)},重新尝试...")
|
xiaohongshu_logger.debug(_msg("😵", f"上传状态还没稳定下来,小人继续观察: {e}"))
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
xiaohongshu_logger.info(' [-] 正在填写标题、描述和话题...')
|
xiaohongshu_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
|
||||||
await self.fill_meta(page)
|
await self.fill_meta(page)
|
||||||
|
|
||||||
await self.set_thumbnail(page, self.thumbnail_path)
|
await self.set_thumbnail(page, self.thumbnail_path)
|
||||||
@@ -553,10 +533,11 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
|
|||||||
"https://creator.xiaohongshu.com/publish/success?**",
|
"https://creator.xiaohongshu.com/publish/success?**",
|
||||||
timeout=3000
|
timeout=3000
|
||||||
)
|
)
|
||||||
xiaohongshu_logger.success(" [-]视频发布成功")
|
xiaohongshu_logger.success(_msg("🥳", "视频发布成功,小人开心收工"))
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
xiaohongshu_logger.info(" [-] 视频正在发布中...")
|
xiaohongshu_logger.info(_msg("🏃", "小人正在冲刺发布视频"))
|
||||||
|
if self.debug:
|
||||||
await page.screenshot(full_page=True)
|
await page.screenshot(full_page=True)
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
@@ -564,8 +545,7 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader):
|
|||||||
xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间"))
|
xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间"))
|
||||||
await self.validate_upload_args()
|
await self.validate_upload_args()
|
||||||
xiaohongshu_logger.info(_msg("🥳", "上传前检查通过"))
|
xiaohongshu_logger.info(_msg("🥳", "上传前检查通过"))
|
||||||
|
browser = await playwright.chromium.launch(headless=self.headless, channel="chrome")
|
||||||
browser = await playwright.chromium.launch(headless=self.headless)
|
|
||||||
context = await browser.new_context(
|
context = await browser.new_context(
|
||||||
permissions=["geolocation"],
|
permissions=["geolocation"],
|
||||||
storage_state=self.account_file,
|
storage_state=self.account_file,
|
||||||
@@ -632,8 +612,8 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader):
|
|||||||
self.image_paths = normalized_image_paths
|
self.image_paths = normalized_image_paths
|
||||||
|
|
||||||
async def upload_note_content(self, page: Page) -> None:
|
async def upload_note_content(self, page: Page) -> None:
|
||||||
xiaohongshu_logger.info(f'[+]正在上传图文,共 {len(self.image_paths)} 张图片')
|
xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片"))
|
||||||
xiaohongshu_logger.info('[-] 正在打开图文发布页...')
|
xiaohongshu_logger.info(_msg("🧭", "小人正在赶往图文发布页"))
|
||||||
await page.goto(XHS_PUBLISH_NOTE_URL)
|
await page.goto(XHS_PUBLISH_NOTE_URL)
|
||||||
await page.wait_for_url(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
|
upload_input = page.locator("div[class^='upload-content'] input[class='upload-input']").first
|
||||||
|
|
||||||
await upload_input.wait_for(state="attached", timeout=30000)
|
await upload_input.wait_for(state="attached", timeout=30000)
|
||||||
|
xiaohongshu_logger.info(_msg("📤", "小人正在上传图片"))
|
||||||
await upload_input.set_input_files(self.image_paths)
|
await upload_input.set_input_files(self.image_paths)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
title_container = page.locator('input[placeholder*="填写标题"]').first
|
title_container = page.locator('input[placeholder*="填写标题"]').first
|
||||||
await title_container.wait_for(state="visible", timeout=3000)
|
await title_container.wait_for(state="visible", timeout=3000)
|
||||||
xiaohongshu_logger.info("[+] 检测到图文素材上传完成,已经可以填写内容")
|
xiaohongshu_logger.success(_msg("🥳", "图文素材已经传完,可以开始填写内容了"))
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
xiaohongshu_logger.info(" [-] 图文素材仍在上传中,继续等待...")
|
xiaohongshu_logger.debug(_msg("🧍", "图文素材还在上传,小人继续等一会"))
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
xiaohongshu_logger.info(' [-] 正在填写标题、描述和话题...')
|
xiaohongshu_logger.info(_msg("✍️", "小人开始填标题、描述和话题"))
|
||||||
await self.fill_meta(page)
|
await self.fill_meta(page)
|
||||||
|
|
||||||
if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
|
if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0:
|
||||||
@@ -670,10 +651,11 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader):
|
|||||||
XHS_PUBLISH_SUCCESS_URL_PATTERN,
|
XHS_PUBLISH_SUCCESS_URL_PATTERN,
|
||||||
timeout=3000
|
timeout=3000
|
||||||
)
|
)
|
||||||
xiaohongshu_logger.success(" [-]图文发布成功")
|
xiaohongshu_logger.success(_msg("🥳", "图文发布成功,小人开心收工"))
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
xiaohongshu_logger.info(" [-] 图文正在发布中...")
|
xiaohongshu_logger.info(_msg("🏃", "小人正在冲刺发布图文"))
|
||||||
|
if self.debug:
|
||||||
await page.screenshot(full_page=True)
|
await page.screenshot(full_page=True)
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
@@ -681,9 +663,9 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader):
|
|||||||
xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间"))
|
xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间"))
|
||||||
await self.validate_upload_args()
|
await self.validate_upload_args()
|
||||||
xiaohongshu_logger.info(_msg("🥳", "图文上传前检查通过"))
|
xiaohongshu_logger.info(_msg("🥳", "图文上传前检查通过"))
|
||||||
|
|
||||||
browser = await playwright.chromium.launch(headless=self.headless, channel="chrome")
|
browser = await playwright.chromium.launch(headless=self.headless, channel="chrome")
|
||||||
context = await browser.new_context(
|
context = await browser.new_context(
|
||||||
|
permissions=["geolocation"],
|
||||||
storage_state=self.account_file,
|
storage_state=self.account_file,
|
||||||
)
|
)
|
||||||
context = await set_init_script(context)
|
context = await set_init_script(context)
|
||||||
|
|||||||
Reference in New Issue
Block a user