diff --git a/README.md b/README.md index b840680..b0120ce 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ | --- | --- | --- | --- | --- | --- | --- | --- | | 抖音 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 当前主线重构最完整 | | Bilibili | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | 运行时自动准备 `biliup` | -| 小红书(浏览器版) | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 当前仓库有浏览器 uploader | +| 小红书(浏览器版) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 浏览器自动化,CLI/Skill 已接入 | | 快手 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 浏览器自动化,CLI/Skill 初版已接入 | | 视频号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 对应 `tencent_uploader` | | 百家号 | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | 浏览器自动化 | @@ -49,6 +49,7 @@ - CLI 使用请看:[CLI 使用说明](./docs/CLI.md) - agent / skill 请看:[Douyin Upload Skill](./skills/douyin-upload/SKILL.md) - agent / skill 请看:[Kuaishou Upload Skill](./skills/kuaishou-upload/SKILL.md) +- agent / skill 请看:[Xiaohongshu Upload Skill](./skills/xiaohongshu-upload/SKILL.md) - agent / skill 请看:[Bilibili Upload Skill](./skills/bilibili-upload/SKILL.md) - 历史 Web 说明请看:[历史 Web 版本说明](./docs/legacy-web.md) - 其他单平台 skill 与整合型 skill 仍在开发中 @@ -93,18 +94,23 @@ Web 端相关代码仍然保留,但已经不是当前主线,不保证可直 ### 方式 1:使用 CLI -当前抖音、快手、Bilibili 已经接入 CLI: +当前抖音、快手、小红书、Bilibili 已经接入 CLI: ```bash sau douyin login --account sau douyin check --account -sau douyin upload-video --account --file videos/demo.mp4 --title "示例标题" -sau douyin upload-note --account --images videos/1.png videos/2.png --note "图文示例" +sau douyin upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" +sau douyin upload-note --account --images videos/1.png videos/2.png --title "图文标题" --note "图文正文" sau kuaishou login --account sau kuaishou check --account -sau kuaishou upload-video --account --file videos/demo.mp4 --title "示例标题" -sau kuaishou upload-note --account --images videos/1.png videos/2.png videos/3.png --note "图文示例" +sau kuaishou upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" +sau kuaishou upload-note --account --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文正文" + +sau xiaohongshu login --account +sau xiaohongshu check --account +sau xiaohongshu upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" +sau xiaohongshu upload-note --account --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文正文" sau bilibili login --account sau bilibili check --account @@ -115,6 +121,9 @@ sau bilibili upload-video --account --file videos/demo.mp4 --titl - `creator` 之类的名字只是示例值,真正含义是 `account_name` - 一个 `account_name` 对应一个账号文件,可以准备多个账号,也可以按账号名并发执行任务 +- 浏览器平台统一约定: +- 视频使用 `title + desc + tags` +- 图文使用 `title + note + tags` - Bilibili CLI 不要求用户手动安装 `biliup` - 首次运行相关命令时,程序会自动下载 `biliup` - 后续运行会自动检查上游 release 并更新 @@ -127,8 +136,8 @@ sau bilibili upload-video --account --file videos/demo.mp4 --titl - 当前主线 CLI 包装示例 - 历史直连 uploader 示例 -对抖音、快手、Bilibili 来说,当前主线优先使用上面的 `sau ...` CLI。 -下面这些脚本里,`upload_to_douyin.py` 和 `upload_to_kuaishou.py` 属于历史直连 uploader 示例: +对抖音、快手、小红书、Bilibili 来说,当前主线优先使用上面的 `sau ...` CLI。 +下面这些脚本主要是历史直连 uploader 示例或调试入口: - `examples/upload_to_douyin.py` - `examples/upload_video_to_bilibili.py` diff --git a/docs/CLI.md b/docs/CLI.md index 8fa1734..b6fa5f8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -4,6 +4,7 @@ - `douyin` - `kuaishou` +- `xiaohongshu` - `bilibili` 实现说明: @@ -13,6 +14,7 @@ - 如果需要给 OpenClaw、Codex 等 agent 使用,可参考仓库内 skill: - `skills/douyin-upload/` - `skills/kuaishou-upload/` + - `skills/xiaohongshu-upload/` - `skills/bilibili-upload/` ## 安装 CLI 入口 @@ -28,6 +30,7 @@ uv pip install -e . ```bash sau douyin --help sau kuaishou --help +sau xiaohongshu --help sau bilibili --help ``` @@ -45,8 +48,8 @@ $env:PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright"; patchr sau douyin login --account sau douyin login --account --headless sau douyin check --account -sau douyin upload-video --account --file videos/demo.mp4 --title "示例标题" --tags 运动,训练 -sau douyin upload-note --account --images videos/1.png videos/2.png --note "图文示例" --tags 图文,测试 +sau douyin upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --tags 运动,训练 +sau douyin upload-note --account --images videos/1.png videos/2.png --title "图文标题" --note "图文示例" --tags 图文,测试 ``` ## 快手 CLI 子命令 @@ -54,8 +57,17 @@ sau douyin upload-note --account --images videos/1.png videos/2.p ```bash sau kuaishou login --account sau kuaishou check --account -sau kuaishou upload-video --account --file videos/demo.mp4 --title "示例标题" --tags 运动,训练 -sau kuaishou upload-note --account --images videos/1.png videos/2.png videos/3.png --note "图文示例" --tags 图文,测试 +sau kuaishou upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --tags 运动,训练 +sau kuaishou upload-note --account --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文示例" --tags 图文,测试 +``` + +## 小红书 CLI 子命令 + +```bash +sau xiaohongshu login --account +sau xiaohongshu check --account +sau xiaohongshu upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --tags 小红书,视频 +sau xiaohongshu upload-note --account --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文示例" --tags 图文,测试 ``` ## Bilibili CLI 子命令 @@ -70,6 +82,9 @@ sau bilibili upload-video --account --file videos/demo.mp4 --titl - `creator` 之类的名字只是示例值,真正传的是用户自定义的 `account_name` - 一个 `account_name` 对应一个账号文件,可以准备多个账号并发使用 +- 浏览器平台统一元数据约定: +- 视频使用 `title + desc + tags` +- 图文使用 `title + note + tags` - `sau bilibili ...` 会自动准备 `biliup` - 如果本地没有 `biliup`,第一次运行会自动下载 - 如果上游 GitHub Release 有更新,运行时会先自动更新 @@ -77,7 +92,7 @@ sau bilibili upload-video --account --file videos/demo.mp4 --titl ## 登录二维码说明 -- 抖音和快手登录过程中,CLI / uploader 可能会生成临时二维码图片 +- 抖音、快手、小红书登录过程中,CLI / uploader 可能会生成临时二维码图片 - 对普通用户来说,可以直接打开该图片扫码 - 对可操作本地文件的 agent 来说,不要只把图片路径告诉用户 - 这类二维码图片本身就是给用户扫码的,agent 应优先直接展示/发送本地图片给用户 @@ -85,13 +100,15 @@ sau bilibili upload-video --account --file videos/demo.mp4 --titl ## 定时发布 -抖音、快手、Bilibili 的视频上传都支持 `--schedule`。只要传了 `--schedule`,CLI 就会自动切换到对应平台的定时发布策略;不传则默认立即发布。 +抖音、快手、小红书的图文和视频上传,以及 Bilibili 的视频上传都支持 `--schedule`。只要传了 `--schedule`,CLI 就会自动切换到对应平台的定时发布策略;不传则默认立即发布。 ```bash -sau douyin upload-video --account --file videos/demo.mp4 --title "示例标题" --schedule "2026-03-24 21:30" -sau douyin upload-note --account --images videos/1.png videos/2.png --note "图文示例" --schedule "2026-03-24 21:30" -sau kuaishou upload-video --account --file videos/demo.mp4 --title "示例标题" --schedule "2026-03-24 21:30" -sau kuaishou upload-note --account --images videos/1.png videos/2.png videos/3.png --note "图文示例" --schedule "2026-03-24 21:30" +sau douyin upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --schedule "2026-03-24 21:30" +sau douyin upload-note --account --images videos/1.png videos/2.png --title "图文标题" --note "图文示例" --schedule "2026-03-24 21:30" +sau kuaishou upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --schedule "2026-03-24 21:30" +sau kuaishou upload-note --account --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文示例" --schedule "2026-03-24 21:30" +sau xiaohongshu upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --schedule "2026-03-24 21:30" +sau xiaohongshu upload-note --account --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文示例" --schedule "2026-03-24 21:30" sau bilibili upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" --tid 249 --schedule "2026-03-24 21:30" ``` @@ -121,6 +138,7 @@ CLI 将 `debug` 和 `headless` 拆成了两个独立维度: ```bash --file videos/demo.mp4 --title "示例标题" +--desc "示例简介" --tags 运动,训练 --thumbnail videos/demo.png ``` @@ -135,7 +153,6 @@ CLI 将 `debug` 和 `headless` 拆成了两个独立维度: Bilibili 额外要求: ```bash ---desc "视频简介" --tid 249 ``` @@ -147,6 +164,7 @@ Bilibili 额外要求: ```bash --images videos/1.png videos/2.png videos/3.png +--title "图文标题" --note "图文内容" --tags 图文,测试 ``` @@ -155,5 +173,6 @@ Bilibili 额外要求: - 抖音:最多 35 张图片,不支持 GIF - 快手:支持多张图片,建议传真实不同文件,不要把同一路径重复多次 +- 小红书:支持多张图片,正文 `--note` 可选,但 `--title` 建议始终显式传入 后续维护 CLI 时,优先看 `sau_cli.py`、`uploader/` 和 `skills/`。 diff --git a/docs/install.md b/docs/install.md index 9858786..aa3730a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -84,6 +84,7 @@ Windows 也可以直接手动复制并重命名。 sau --help sau douyin --help sau kuaishou --help +sau xiaohongshu --help sau bilibili --help ``` @@ -97,7 +98,8 @@ sau bilibili --help ```bash sau douyin login --account sau douyin check --account -sau douyin upload-video --account --file videos/demo.mp4 --title "示例标题" +sau douyin upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" +sau douyin upload-note --account --images videos/1.png videos/2.png --title "图文标题" --note "图文正文" ``` ### 8. 快手主线示例 @@ -105,11 +107,20 @@ sau douyin upload-video --account --file videos/demo.mp4 --title ```bash sau kuaishou login --account sau kuaishou check --account -sau kuaishou upload-video --account --file videos/demo.mp4 --title "示例标题" -sau kuaishou upload-note --account --images videos/1.png videos/2.png videos/3.png --note "图文示例" +sau kuaishou upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" +sau kuaishou upload-note --account --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文正文" ``` -### 9. Bilibili 主线示例 +### 9. 小红书主线示例 + +```bash +sau xiaohongshu login --account +sau xiaohongshu check --account +sau xiaohongshu upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" +sau xiaohongshu upload-note --account --images videos/1.png videos/2.png videos/3.png --title "图文标题" --note "图文正文" +``` + +### 10. Bilibili 主线示例 ```bash sau bilibili login --account @@ -121,6 +132,9 @@ sau bilibili upload-video --account --file videos/demo.mp4 --titl - `creator` 之类的名字只是示例值,真正传的是用户自定义的 `account_name` - 一个 `account_name` 对应一个账号文件,可以准备多个账号并发使用 +- 浏览器平台统一元数据约定: +- 视频使用 `title + desc + tags` +- 图文使用 `title + note + tags` - 用户不需要手动安装 `biliup` - 首次运行 Bilibili 相关命令时,程序会自动下载 `biliup` - 后续运行会自动检查上游 release 并自动更新 @@ -161,6 +175,7 @@ PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright i sau --help sau douyin --help sau kuaishou --help +sau xiaohongshu --help sau bilibili --help ``` @@ -177,6 +192,11 @@ sau kuaishou check sau kuaishou upload-video sau kuaishou upload-note +sau xiaohongshu login +sau xiaohongshu check +sau xiaohongshu upload-video +sau xiaohongshu upload-note + sau bilibili login sau bilibili check sau bilibili upload-video @@ -188,6 +208,8 @@ sau bilibili upload-video - `skills/douyin-upload/references/cli-contract.md` - `skills/kuaishou-upload/SKILL.md` - `skills/kuaishou-upload/references/cli-contract.md` +- `skills/xiaohongshu-upload/SKILL.md` +- `skills/xiaohongshu-upload/references/cli-contract.md` - `skills/bilibili-upload/SKILL.md` - `skills/bilibili-upload/references/cli-contract.md` diff --git a/docs/superpowers/plans/2026-03-25-xiaohongshu-shallow-alignment.md b/docs/superpowers/plans/2026-03-25-xiaohongshu-shallow-alignment.md new file mode 100644 index 0000000..b2fb9cb --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-xiaohongshu-shallow-alignment.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. diff --git a/docs/update.md b/docs/update.md index 0038085..6cf1675 100644 --- a/docs/update.md +++ b/docs/update.md @@ -47,9 +47,11 @@ PLAYWRIGHT_DOWNLOAD_HOST="https://npmmirror.com/mirrors/playwright" patchright i sau --help sau douyin --help sau kuaishou --help +sau xiaohongshu --help sau bilibili --help sau douyin check --account your_account sau kuaishou check --account your_account +sau xiaohongshu check --account your_account sau bilibili check --account your_account ``` @@ -57,6 +59,7 @@ sau bilibili check --account your_account - `skills/douyin-upload/SKILL.md` - `skills/kuaishou-upload/SKILL.md` +- `skills/xiaohongshu-upload/SKILL.md` - `skills/bilibili-upload/SKILL.md` - `docs/CLI.md` @@ -84,14 +87,16 @@ uv pip install -e . sau --help sau douyin --help sau kuaishou --help +sau xiaohongshu --help sau bilibili --help ``` -5. 如果任务是抖音或快手链路,再继续验证: +5. 如果任务是浏览器平台链路,再继续验证: ```bash sau douyin check --account test sau kuaishou check --account test +sau xiaohongshu check --account test sau bilibili check --account test ``` @@ -103,6 +108,9 @@ sau bilibili check --account test - `skills/kuaishou-upload/SKILL.md` - `skills/kuaishou-upload/references/cli-contract.md` - `skills/kuaishou-upload/references/runtime-requirements.md` +- `skills/xiaohongshu-upload/SKILL.md` +- `skills/xiaohongshu-upload/references/cli-contract.md` +- `skills/xiaohongshu-upload/references/runtime-requirements.md` - `skills/bilibili-upload/SKILL.md` - `skills/bilibili-upload/references/cli-contract.md` - `skills/bilibili-upload/references/runtime-requirements.md` diff --git a/examples/get_xiaohongshu_cookie.py b/examples/get_xiaohongshu_cookie.py index 5c065cb..77bb0cc 100644 --- a/examples/get_xiaohongshu_cookie.py +++ b/examples/get_xiaohongshu_cookie.py @@ -1,3 +1,11 @@ +""" +当前主线优先使用 CLI: + + sau xiaohongshu login --account + +这个脚本保留为小红书 uploader 的调试入口 / 历史直连路径。 +""" + import asyncio from pathlib import Path diff --git a/examples/upload_video_to_xiaohongshu.py b/examples/upload_video_to_xiaohongshu.py index 24da441..ea0ffa2 100644 --- a/examples/upload_video_to_xiaohongshu.py +++ b/examples/upload_video_to_xiaohongshu.py @@ -1,3 +1,13 @@ +""" +当前主线优先使用 CLI: + + sau xiaohongshu login --account + sau xiaohongshu upload-video --account --file videos/demo.mp4 --title "示例标题" --desc "示例简介" + sau xiaohongshu upload-note --account --images videos/1.png videos/2.png --title "图文标题" --note "图文正文" + +这个脚本保留为小红书 uploader 的调试入口 / 历史直连路径。 +""" + import asyncio from datetime import datetime, timedelta from pathlib import Path diff --git a/sau_cli.py b/sau_cli.py index 6bf18be..36e945d 100644 --- a/sau_cli.py +++ b/sau_cli.py @@ -26,6 +26,14 @@ from uploader.ks_uploader.main import ( cookie_auth as kuaishou_cookie_auth, ks_setup, ) +from uploader.xiaohongshu_uploader.main import ( + XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE, + XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED, + XiaoHongShuNote, + XiaoHongShuVideo, + cookie_auth as xiaohongshu_cookie_auth, + xiaohongshu_setup, +) SCHEDULE_FORMAT = "%Y-%m-%d %H:%M" @@ -35,6 +43,7 @@ class DouyinVideoUploadRequest: account_name: str video_file: Path title: str + description: str tags: list[str] publish_date: datetime | int thumbnail_file: Path | None = None @@ -49,6 +58,7 @@ class DouyinVideoUploadRequest: class DouyinNoteUploadRequest: account_name: str image_files: list[Path] + title: str note: str tags: list[str] publish_date: datetime | int @@ -62,6 +72,7 @@ class KuaishouVideoUploadRequest: account_name: str video_file: Path title: str + description: str tags: list[str] publish_date: datetime | int thumbnail_file: Path | None = None @@ -74,6 +85,7 @@ class KuaishouVideoUploadRequest: class KuaishouNoteUploadRequest: account_name: str image_files: list[Path] + title: str note: str tags: list[str] publish_date: datetime | int @@ -82,6 +94,33 @@ class KuaishouNoteUploadRequest: headless: bool = True +@dataclass(slots=True) +class XiaohongshuVideoUploadRequest: + account_name: str + video_file: Path + title: str + description: str + tags: list[str] + publish_date: datetime | int + thumbnail_file: Path | None = None + publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE + debug: bool = True + headless: bool = True + + +@dataclass(slots=True) +class XiaohongshuNoteUploadRequest: + account_name: str + image_files: list[Path] + title: str + note: str + tags: list[str] + publish_date: datetime | int + publish_strategy: str = XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE + debug: bool = True + headless: bool = True + + @dataclass(slots=True) class BilibiliVideoUploadRequest: account_name: str @@ -153,6 +192,18 @@ async def check_kuaishou_account(account_name: str) -> bool: return await kuaishou_cookie_auth(str(account_file)) +async def login_xiaohongshu_account(account_name: str, headless: bool = True) -> dict: + account_file = resolve_account_file("xiaohongshu", account_name) + return await xiaohongshu_setup(str(account_file), handle=True, return_detail=True, headless=headless) + + +async def check_xiaohongshu_account(account_name: str) -> bool: + account_file = resolve_account_file("xiaohongshu", account_name) + if not account_file.exists(): + return False + return await xiaohongshu_cookie_auth(str(account_file)) + + async def login_bilibili_account(account_name: str) -> dict: account_file = resolve_account_file("bilibili", account_name) if not has_interactive_terminal(): @@ -197,6 +248,7 @@ async def upload_video(request: DouyinVideoUploadRequest) -> Path: request.tags, request.publish_date, str(account_file), + desc=request.description, thumbnail_portrait_path=str(request.thumbnail_file) if request.thumbnail_file else None, productLink=request.product_link, productTitle=request.product_title, @@ -218,6 +270,7 @@ async def upload_note(request: DouyinNoteUploadRequest) -> Path: app = DouYinNote( image_paths=[str(path) for path in request.image_files], + title=request.title, note=request.note, tags=request.tags, publish_date=request.publish_date, @@ -241,6 +294,7 @@ async def upload_kuaishou_video(request: KuaishouVideoUploadRequest) -> Path: app = KSVideo( title=request.title, file_path=str(request.video_file), + desc=request.description, tags=request.tags, publish_date=request.publish_date, account_file=str(account_file), @@ -263,6 +317,55 @@ async def upload_kuaishou_note(request: KuaishouNoteUploadRequest) -> Path: app = KSNote( image_paths=[str(path) for path in request.image_files], + title=request.title, + note=request.note, + tags=request.tags, + publish_date=request.publish_date, + account_file=str(account_file), + publish_strategy=request.publish_strategy, + debug=request.debug, + headless=request.headless, + ) + await app.main() + return account_file + + +async def upload_xiaohongshu_video(request: XiaohongshuVideoUploadRequest) -> Path: + account_file = resolve_account_file("xiaohongshu", request.account_name) + is_ready = await xiaohongshu_setup(str(account_file), handle=False) + if not is_ready: + raise RuntimeError( + f"Xiaohongshu cookie is missing or expired: {account_file}. Run `sau xiaohongshu login --account {request.account_name}` first." + ) + + app = XiaoHongShuVideo( + title=request.title, + file_path=str(request.video_file), + desc=request.description, + tags=request.tags, + publish_date=request.publish_date, + account_file=str(account_file), + thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None, + publish_strategy=request.publish_strategy, + debug=request.debug, + headless=request.headless, + ) + await app.main() + return account_file + + +async def upload_xiaohongshu_note(request: XiaohongshuNoteUploadRequest) -> Path: + account_file = resolve_account_file("xiaohongshu", request.account_name) + is_ready = await xiaohongshu_setup(str(account_file), handle=False) + if not is_ready: + raise RuntimeError( + f"Xiaohongshu cookie is missing or expired: {account_file}. Run `sau xiaohongshu login --account {request.account_name}` first." + ) + + app = XiaoHongShuNote( + image_paths=[str(path) for path in request.image_files], + title=request.title, + desc=request.note, note=request.note, tags=request.tags, publish_date=request.publish_date, @@ -350,6 +453,7 @@ def build_parser() -> argparse.ArgumentParser: upload_video_parser.add_argument("--account", required=True, help="Douyin user-defined account_name") upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path") upload_video_parser.add_argument("--title", required=True, help="Video title") + upload_video_parser.add_argument("--desc", default="", help="Optional video description") upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path") @@ -360,7 +464,8 @@ def build_parser() -> argparse.ArgumentParser: upload_note_parser = douyin_actions.add_parser("upload-note", help="Upload one note to Douyin") upload_note_parser.add_argument("--account", required=True, help="Douyin user-defined account_name") upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths") - upload_note_parser.add_argument("--note", required=True, help="Note content") + upload_note_parser.add_argument("--title", required=True, help="Note title") + upload_note_parser.add_argument("--note", default="", help="Optional note content") upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") add_runtime_flags(upload_note_parser) @@ -378,6 +483,7 @@ def build_parser() -> argparse.ArgumentParser: kuaishou_upload_video_parser.add_argument("--account", required=True, help="Kuaishou user-defined account_name") kuaishou_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path") kuaishou_upload_video_parser.add_argument("--title", required=True, help="Video title") + kuaishou_upload_video_parser.add_argument("--desc", default="", help="Optional video description") kuaishou_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") kuaishou_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") kuaishou_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path") @@ -386,11 +492,40 @@ def build_parser() -> argparse.ArgumentParser: kuaishou_upload_note_parser = kuaishou_actions.add_parser("upload-note", help="Upload one note to Kuaishou") kuaishou_upload_note_parser.add_argument("--account", required=True, help="Kuaishou user-defined account_name") kuaishou_upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths") - kuaishou_upload_note_parser.add_argument("--note", required=True, help="Note content") + kuaishou_upload_note_parser.add_argument("--title", required=True, help="Note title") + kuaishou_upload_note_parser.add_argument("--note", default="", help="Optional note content") kuaishou_upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") kuaishou_upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") add_runtime_flags(kuaishou_upload_note_parser) + xiaohongshu_parser = platform_parsers.add_parser("xiaohongshu", help="Xiaohongshu operations") + xiaohongshu_actions = xiaohongshu_parser.add_subparsers(dest="action", required=True) + + for action_name in ("login", "check"): + action_parser = xiaohongshu_actions.add_parser(action_name, help=f"Xiaohongshu {action_name}") + action_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name") + if action_name == "login": + add_runtime_flags(action_parser) + + xiaohongshu_upload_video_parser = xiaohongshu_actions.add_parser("upload-video", help="Upload one video to Xiaohongshu") + xiaohongshu_upload_video_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name") + xiaohongshu_upload_video_parser.add_argument("--file", required=True, type=existing_file_path, help="Video file path") + xiaohongshu_upload_video_parser.add_argument("--title", required=True, help="Video title") + xiaohongshu_upload_video_parser.add_argument("--desc", default="", help="Optional video description") + xiaohongshu_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") + xiaohongshu_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") + xiaohongshu_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path") + add_runtime_flags(xiaohongshu_upload_video_parser) + + xiaohongshu_upload_note_parser = xiaohongshu_actions.add_parser("upload-note", help="Upload one note to Xiaohongshu") + xiaohongshu_upload_note_parser.add_argument("--account", required=True, help="Xiaohongshu user-defined account_name") + xiaohongshu_upload_note_parser.add_argument("--images", required=True, nargs="+", type=existing_file_path, help="Image file paths") + xiaohongshu_upload_note_parser.add_argument("--title", required=True, help="Note title") + xiaohongshu_upload_note_parser.add_argument("--note", default="", help="Optional note content") + xiaohongshu_upload_note_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") + xiaohongshu_upload_note_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") + add_runtime_flags(xiaohongshu_upload_note_parser) + bilibili_parser = platform_parsers.add_parser("bilibili", help="Bilibili operations") bilibili_actions = bilibili_parser.add_subparsers(dest="action", required=True) @@ -430,6 +565,7 @@ async def dispatch(args: argparse.Namespace) -> int: account_name=args.account, video_file=args.file, title=args.title, + description=args.desc, tags=parse_tags(args.tags), publish_date=args.schedule or 0, thumbnail_file=args.thumbnail, @@ -447,6 +583,7 @@ async def dispatch(args: argparse.Namespace) -> int: request = DouyinNoteUploadRequest( account_name=args.account, image_files=parse_image_files(args.images), + title=args.title, note=args.note, tags=parse_tags(args.tags), publish_date=args.schedule or 0, @@ -480,6 +617,7 @@ async def dispatch(args: argparse.Namespace) -> int: account_name=args.account, video_file=args.file, title=args.title, + description=args.desc, tags=parse_tags(args.tags), publish_date=args.schedule or 0, thumbnail_file=args.thumbnail, @@ -495,6 +633,7 @@ async def dispatch(args: argparse.Namespace) -> int: request = KuaishouNoteUploadRequest( account_name=args.account, image_files=parse_image_files(args.images), + title=args.title, note=args.note, tags=parse_tags(args.tags), publish_date=args.schedule or 0, @@ -508,6 +647,58 @@ async def dispatch(args: argparse.Namespace) -> int: raise RuntimeError(f"Unsupported Kuaishou action: {args.action}") + if args.platform == "xiaohongshu": + if args.action == "login": + result = await login_xiaohongshu_account(args.account, headless=args.headless) + if not result["success"]: + raise RuntimeError(result["message"]) + print(f"Xiaohongshu login flow completed: {result['account_file']}") + return 0 + + if args.action == "check": + is_valid = await check_xiaohongshu_account(args.account) + print("valid" if is_valid else "invalid") + return 0 if is_valid else 1 + + publish_strategy = ( + XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED if args.schedule else XIAOHONGSHU_PUBLISH_STRATEGY_IMMEDIATE + ) + + if args.action == "upload-video": + request = XiaohongshuVideoUploadRequest( + account_name=args.account, + video_file=args.file, + title=args.title, + description=args.desc, + tags=parse_tags(args.tags), + publish_date=args.schedule or 0, + thumbnail_file=args.thumbnail, + publish_strategy=publish_strategy, + debug=args.debug, + headless=args.headless, + ) + await upload_xiaohongshu_video(request) + print(f"Xiaohongshu video upload submitted: {request.video_file}") + return 0 + + if args.action == "upload-note": + request = XiaohongshuNoteUploadRequest( + account_name=args.account, + image_files=parse_image_files(args.images), + title=args.title, + note=args.note, + tags=parse_tags(args.tags), + publish_date=args.schedule or 0, + publish_strategy=publish_strategy, + debug=args.debug, + headless=args.headless, + ) + await upload_xiaohongshu_note(request) + print(f"Xiaohongshu note upload submitted: {len(request.image_files)} images") + return 0 + + raise RuntimeError(f"Unsupported Xiaohongshu action: {args.action}") + if args.platform == "bilibili": if args.action == "login": result = await login_bilibili_account(args.account) diff --git a/skills/douyin-upload/SKILL.md b/skills/douyin-upload/SKILL.md index f277d5f..9dc162b 100644 --- a/skills/douyin-upload/SKILL.md +++ b/skills/douyin-upload/SKILL.md @@ -20,6 +20,11 @@ description: 当 agent 需要通过已安装的 `sau` CLI 完成抖音登录、c | 视频上传 | `sau douyin upload-video ...` | 上传并发布抖音视频 | | 图文上传 | `sau douyin upload-note ...` | 上传并发布抖音图文 | +元数据约定: + +- 视频使用 `title + desc + tags` +- 图文使用 `title + note + tags` + ## 默认工作流 1. 先确认 `references/runtime-requirements.md` 里的运行前提。 diff --git a/skills/douyin-upload/references/cli-contract.md b/skills/douyin-upload/references/cli-contract.md index 46bd07d..dfc9a3f 100644 --- a/skills/douyin-upload/references/cli-contract.md +++ b/skills/douyin-upload/references/cli-contract.md @@ -38,6 +38,7 @@ sau douyin upload-video \ --account \ --file \ --title "" \ + [--desc "<description>"] \ [--tags tag1,tag2] \ [--schedule "YYYY-MM-DD HH:MM"] \ [--thumbnail <image-path>] \ @@ -52,6 +53,7 @@ sau douyin upload-video \ - `--file` - `--title` - 可选参数: + - `--desc` - `--tags` - `--schedule` - `--thumbnail` @@ -67,7 +69,8 @@ sau douyin upload-video \ sau douyin upload-note \ --account <account> \ --images <image-1> [image-2 ...] \ - --note "<content>" \ + --title "<title>" \ + [--note "<content>"] \ [--tags tag1,tag2] \ [--schedule "YYYY-MM-DD HH:MM"] \ [--debug] \ @@ -77,8 +80,9 @@ sau douyin upload-note \ - 必填参数: - `--account` - `--images` - - `--note` + - `--title` - 可选参数: + - `--note` - `--tags` - `--schedule` - `--debug` @@ -99,5 +103,7 @@ YYYY-MM-DD HH:MM - `upload-video` 每次命令只支持一个视频文件 - `upload-note` 每次命令支持多张图片 +- 视频描述字段统一使用 `--desc` +- 图文正文统一使用 `--note` - `upload-note` 当前不支持 GIF - `upload-note` 当前最多支持 35 张图片 diff --git a/skills/douyin-upload/references/troubleshooting.md b/skills/douyin-upload/references/troubleshooting.md index 8e9f3b1..4a788aa 100644 --- a/skills/douyin-upload/references/troubleshooting.md +++ b/skills/douyin-upload/references/troubleshooting.md @@ -63,7 +63,9 @@ sau douyin login --account <account> - `--account` - `--images` -- `--note` +- `--title` + +`--note` 当前是可选图文正文。 ## 图片限制 diff --git a/skills/douyin-upload/scripts/examples/douyin_cli_template.py b/skills/douyin-upload/scripts/examples/douyin_cli_template.py index 3c23a41..ca3e7de 100644 --- a/skills/douyin-upload/scripts/examples/douyin_cli_template.py +++ b/skills/douyin-upload/scripts/examples/douyin_cli_template.py @@ -27,6 +27,8 @@ def main() -> None: "videos/demo.mp4", "--title", "Douyin video from Python", + "--desc", + "Douyin video description from Python", "--tags", "cli,video", "--thumbnail", @@ -42,6 +44,8 @@ def main() -> None: "--images", "videos/1.png", "videos/2.png", + "--title", + "Douyin note title from Python", "--note", "Douyin note from Python", "--tags", diff --git a/skills/douyin-upload/scripts/examples/douyin_commands.ps1 b/skills/douyin-upload/scripts/examples/douyin_commands.ps1 index 03743c8..8a04d0c 100644 --- a/skills/douyin-upload/scripts/examples/douyin_commands.ps1 +++ b/skills/douyin-upload/scripts/examples/douyin_commands.ps1 @@ -14,6 +14,7 @@ sau douyin upload-video ` --account $account ` --file $video ` --title "Douyin video from PowerShell" ` + --desc "Douyin video description from PowerShell" ` --tags "cli,video" ` --thumbnail $thumbnail ` --headless @@ -21,6 +22,7 @@ sau douyin upload-video ` sau douyin upload-note ` --account $account ` --images $noteImages ` + --title "Douyin note title from PowerShell" ` --note "Douyin note from PowerShell" ` --tags "cli,note" ` --headless diff --git a/skills/douyin-upload/scripts/examples/douyin_commands.sh b/skills/douyin-upload/scripts/examples/douyin_commands.sh index 55e01b2..3ee6708 100644 --- a/skills/douyin-upload/scripts/examples/douyin_commands.sh +++ b/skills/douyin-upload/scripts/examples/douyin_commands.sh @@ -15,6 +15,7 @@ sau douyin upload-video \ --account "$account" \ --file "$video" \ --title "Douyin video from bash" \ + --desc "Douyin video description from bash" \ --tags "cli,video" \ --thumbnail "$thumbnail" \ --headless @@ -22,6 +23,7 @@ sau douyin upload-video \ sau douyin upload-note \ --account "$account" \ --images "videos/1.png" "videos/2.png" \ + --title "Douyin note title from bash" \ --note "Douyin note from bash" \ --tags "cli,note" \ --headless diff --git a/skills/kuaishou-upload/SKILL.md b/skills/kuaishou-upload/SKILL.md index 151a0fa..53a318c 100644 --- a/skills/kuaishou-upload/SKILL.md +++ b/skills/kuaishou-upload/SKILL.md @@ -20,6 +20,11 @@ description: 当 agent 需要通过已安装的 `sau` CLI 完成快手登录、c | 视频上传 | `sau kuaishou upload-video ...` | 上传并发布快手视频 | | 图文上传 | `sau kuaishou upload-note ...` | 上传并发布快手图文 | +元数据约定: + +- 视频使用 `title + desc + tags` +- 图文使用 `title + note + tags` + ## 默认工作流 1. 先确认 `references/runtime-requirements.md` 里的运行前提。 diff --git a/skills/kuaishou-upload/references/cli-contract.md b/skills/kuaishou-upload/references/cli-contract.md index 2370d45..0c843ce 100644 --- a/skills/kuaishou-upload/references/cli-contract.md +++ b/skills/kuaishou-upload/references/cli-contract.md @@ -38,6 +38,7 @@ sau kuaishou upload-video \ --account <account> \ --file <video-path> \ --title "<title>" \ + [--desc "<description>"] \ [--tags tag1,tag2] \ [--schedule "YYYY-MM-DD HH:MM"] \ [--thumbnail <image-path>] \ @@ -50,6 +51,7 @@ sau kuaishou upload-video \ - `--file` - `--title` - 可选参数: + - `--desc` - `--tags` - `--schedule` - `--thumbnail` @@ -63,7 +65,8 @@ sau kuaishou upload-video \ sau kuaishou upload-note \ --account <account> \ --images <image-1> [image-2 ...] \ - --note "<content>" \ + --title "<title>" \ + [--note "<content>"] \ [--tags tag1,tag2] \ [--schedule "YYYY-MM-DD HH:MM"] \ [--debug] \ @@ -73,8 +76,9 @@ sau kuaishou upload-note \ - 必填参数: - `--account` - `--images` - - `--note` + - `--title` - 可选参数: + - `--note` - `--tags` - `--schedule` - `--debug` @@ -95,4 +99,6 @@ YYYY-MM-DD HH:MM - `upload-video` 每次命令只支持一个视频文件 - `upload-note` 每次命令支持多张图片 +- 视频描述字段统一使用 `--desc` +- 图文正文统一使用 `--note` - `upload-note` 当前要求传入真实的多张图片文件,而不是同一路径重复多次 diff --git a/skills/kuaishou-upload/references/troubleshooting.md b/skills/kuaishou-upload/references/troubleshooting.md index c6a9812..0413000 100644 --- a/skills/kuaishou-upload/references/troubleshooting.md +++ b/skills/kuaishou-upload/references/troubleshooting.md @@ -63,7 +63,9 @@ sau kuaishou login --account <account> - `--account` - `--images` -- `--note` +- `--title` + +`--note` 当前是可选图文正文。 ## 图文上传只有一张生效 diff --git a/skills/kuaishou-upload/scripts/examples/kuaishou_cli_template.py b/skills/kuaishou-upload/scripts/examples/kuaishou_cli_template.py index 0f3f08a..a8984f5 100644 --- a/skills/kuaishou-upload/scripts/examples/kuaishou_cli_template.py +++ b/skills/kuaishou-upload/scripts/examples/kuaishou_cli_template.py @@ -27,6 +27,8 @@ def main() -> None: "videos/demo.mp4", "--title", "Kuaishou video from Python", + "--desc", + "Kuaishou video description from Python", "--tags", "cli,video", "--thumbnail", @@ -43,6 +45,8 @@ def main() -> None: "videos/1.png", "videos/2.png", "videos/3.png", + "--title", + "Kuaishou note title from Python", "--note", "Kuaishou note from Python", "--tags", diff --git a/skills/kuaishou-upload/scripts/examples/kuaishou_commands.ps1 b/skills/kuaishou-upload/scripts/examples/kuaishou_commands.ps1 index a35ee7e..e54ca41 100644 --- a/skills/kuaishou-upload/scripts/examples/kuaishou_commands.ps1 +++ b/skills/kuaishou-upload/scripts/examples/kuaishou_commands.ps1 @@ -14,6 +14,7 @@ sau kuaishou upload-video ` --account $account ` --file $video ` --title "Kuaishou video from PowerShell" ` + --desc "Kuaishou video description from PowerShell" ` --tags "cli,video" ` --thumbnail $thumbnail ` --headless @@ -21,6 +22,7 @@ sau kuaishou upload-video ` sau kuaishou upload-note ` --account $account ` --images $noteImages ` + --title "Kuaishou note title from PowerShell" ` --note "Kuaishou note from PowerShell" ` --tags "cli,note" ` --headless diff --git a/skills/kuaishou-upload/scripts/examples/kuaishou_commands.sh b/skills/kuaishou-upload/scripts/examples/kuaishou_commands.sh index eb80253..dc47011 100644 --- a/skills/kuaishou-upload/scripts/examples/kuaishou_commands.sh +++ b/skills/kuaishou-upload/scripts/examples/kuaishou_commands.sh @@ -15,6 +15,7 @@ sau kuaishou upload-video \ --account "$account" \ --file "$video" \ --title "Kuaishou video from bash" \ + --desc "Kuaishou video description from bash" \ --tags "cli,video" \ --thumbnail "$thumbnail" \ --headless @@ -22,6 +23,7 @@ sau kuaishou upload-video \ sau kuaishou upload-note \ --account "$account" \ --images "videos/1.png" "videos/2.png" "videos/3.png" \ + --title "Kuaishou note title from bash" \ --note "Kuaishou note from bash" \ --tags "cli,note" \ --headless diff --git a/skills/xiaohongshu-upload/SKILL.md b/skills/xiaohongshu-upload/SKILL.md new file mode 100644 index 0000000..63afbbf --- /dev/null +++ b/skills/xiaohongshu-upload/SKILL.md @@ -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` diff --git a/skills/xiaohongshu-upload/references/cli-contract.md b/skills/xiaohongshu-upload/references/cli-contract.md new file mode 100644 index 0000000..9c34be4 --- /dev/null +++ b/skills/xiaohongshu-upload/references/cli-contract.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` diff --git a/skills/xiaohongshu-upload/references/runtime-requirements.md b/skills/xiaohongshu-upload/references/runtime-requirements.md new file mode 100644 index 0000000..d6abd73 --- /dev/null +++ b/skills/xiaohongshu-upload/references/runtime-requirements.md @@ -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 应优先直接把图片展示/发送给用户扫码,不要只告诉用户图片路径 diff --git a/skills/xiaohongshu-upload/references/troubleshooting.md b/skills/xiaohongshu-upload/references/troubleshooting.md new file mode 100644 index 0000000..3906a88 --- /dev/null +++ b/skills/xiaohongshu-upload/references/troubleshooting.md @@ -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` 即可改为立即发布。 diff --git a/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_cli_template.py b/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_cli_template.py new file mode 100644 index 0000000..6e911a3 --- /dev/null +++ b/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_cli_template.py @@ -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() diff --git a/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_commands.ps1 b/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_commands.ps1 new file mode 100644 index 0000000..81be59d --- /dev/null +++ b/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_commands.ps1 @@ -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 diff --git a/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_commands.sh b/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_commands.sh new file mode 100644 index 0000000..58d57e2 --- /dev/null +++ b/skills/xiaohongshu-upload/scripts/examples/xiaohongshu_commands.sh @@ -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 diff --git a/tests/test_sau_browser_cli.py b/tests/test_sau_browser_cli.py new file mode 100644 index 0000000..b3d2c65 --- /dev/null +++ b/tests/test_sau_browser_cli.py @@ -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() diff --git a/tests/test_xiaohongshu_uploader.py b/tests/test_xiaohongshu_uploader.py new file mode 100644 index 0000000..d075cc9 --- /dev/null +++ b/tests/test_xiaohongshu_uploader.py @@ -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() diff --git a/uploader/douyin_uploader/main.py b/uploader/douyin_uploader/main.py index c139110..83160a4 100644 --- a/uploader/douyin_uploader/main.py +++ b/uploader/douyin_uploader/main.py @@ -380,6 +380,7 @@ class DouYinVideo(DouYinBaseUploader): productLink="", productTitle="", thumbnail_portrait_path=None, + desc: str | None = None, publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE, debug: bool = DEBUG_MODE, headless: bool = LOCAL_CHROME_HEADLESS, @@ -398,6 +399,7 @@ class DouYinVideo(DouYinBaseUploader): self.thumbnail_portrait_path = thumbnail_portrait_path self.productLink = productLink self.productTitle = productTitle + self.desc = desc or "" async def validate_upload_args(self): await self.validate_base_args() @@ -505,7 +507,7 @@ class DouYinVideo(DouYinBaseUploader): await asyncio.sleep(1) douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题")) - await self.fill_title_and_description(page, self.title, self.title, self.tags) + await self.fill_title_and_description(page, self.title, self.desc or self.title, self.tags) douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题")) while True: @@ -578,6 +580,7 @@ class DouYinNote(DouYinBaseUploader): tags, publish_date: datetime | int, account_file, + title: str | None = None, publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE, debug: bool = DEBUG_MODE, headless: bool = LOCAL_CHROME_HEADLESS, @@ -590,13 +593,14 @@ class DouYinNote(DouYinBaseUploader): headless=headless, ) self.image_paths = image_paths - self.note = note + self.note = note or "" + self.title = title or (self.note[:30] if self.note else "") self.tags = tags or [] async def validate_upload_args(self): await self.validate_base_args() - if not self.note or not str(self.note).strip(): - raise ValueError("图文模式下,note 是必须的") + if not self.title or not str(self.title).strip(): + raise ValueError("图文模式下,title 是必须的") if not self.image_paths: raise ValueError("图文模式下,图片是必须的") @@ -634,7 +638,7 @@ class DouYinNote(DouYinBaseUploader): await asyncio.sleep(1) douyin_logger.info(_msg("✍️", "小人开始填标题、描述和话题")) - await self.fill_title_and_description(page, self.note, self.note, self.tags) + await self.fill_title_and_description(page, self.title, self.note, self.tags) douyin_logger.info(_msg("🏷️", f"小人一共贴了 {len(self.tags)} 个话题")) if self.publish_strategy == DOUYIN_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0: diff --git a/uploader/ks_uploader/main.py b/uploader/ks_uploader/main.py index 9ef036b..b0ff872 100644 --- a/uploader/ks_uploader/main.py +++ b/uploader/ks_uploader/main.py @@ -358,6 +358,7 @@ class KSVideo(KSBaseUploader): debug: bool = DEBUG_MODE, headless: bool = LOCAL_CHROME_HEADLESS, thumbnail_path=None, + desc: str | None = None, ): super().__init__( publish_date=publish_date, @@ -370,6 +371,7 @@ class KSVideo(KSBaseUploader): self.file_path = file_path self.tags = tags or [] self.thumbnail_path = thumbnail_path + self.desc = desc or "" async def validate_upload_args(self): await self.validate_base_args() @@ -462,7 +464,7 @@ class KSVideo(KSBaseUploader): await page.keyboard.press("Backspace") await page.keyboard.press("Control+KeyA") await page.keyboard.press("Delete") - await page.keyboard.type(self.title) + await page.keyboard.type(self.desc or self.title) await page.keyboard.press("Enter") for index, tag in enumerate(self.tags[:3], start=1): @@ -541,6 +543,7 @@ class KSNote(KSBaseUploader): tags, publish_date: datetime | int, account_file, + title: str | None = None, publish_strategy: str | None = None, debug: bool = DEBUG_MODE, headless: bool = LOCAL_CHROME_HEADLESS, @@ -553,13 +556,14 @@ class KSNote(KSBaseUploader): headless=headless, ) self.image_paths = image_paths - self.note = note + self.note = note or "" + self.title = title or (self.note[:20] if self.note else "") self.tags = tags or [] async def validate_upload_args(self): await self.validate_base_args() - if not self.note or not str(self.note).strip(): - raise ValueError("快手图文上传时,note 是必须的") + if not self.title or not str(self.title).strip(): + raise ValueError("快手图文上传时,title 是必须的") if not self.image_paths: raise ValueError("快手图文上传时,图片是必须的") diff --git a/uploader/xiaohongshu_uploader/main.py b/uploader/xiaohongshu_uploader/main.py index 5ad08f4..37fdd44 100644 --- a/uploader/xiaohongshu_uploader/main.py +++ b/uploader/xiaohongshu_uploader/main.py @@ -308,39 +308,30 @@ class XiaoHongShuBaseUploader(BaseVideoUploader): self.publish_date = 0 async def set_schedule_time_xiaohongshu(self, page: Page, publish_date: datetime): - print(" [-] 正在设置定时发布时间...") - print(f"publish_date: {publish_date}") + xiaohongshu_logger.info(_msg("🕒", f"小人准备设置定时发布时间: {publish_date.strftime(self.date_format)}")) await page.locator('.custom-switch-card').filter(has_text="定时发布").locator('.d-switch').click() await asyncio.sleep(1) publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M") - publish_date_hour = publish_date.strftime("%Y-%m-%d %H:%M") - print(f"publish_date_hour: {publish_date_hour}") - await asyncio.sleep(1) time_input = page.locator('.d-datepicker-input-filter input.d-text') await time_input.fill(str(publish_date_hour)) + await asyncio.sleep(1) async def set_location(self, page: Page, location: str = "青岛市"): - print(f"开始设置位置: {location}") - print("等待地点输入框加载...") + if not location: + return True + + xiaohongshu_logger.info(_msg("📍", f"小人准备设置位置: {location}")) loc_ele = await page.wait_for_selector('div.d-text.d-select-placeholder.d-text-ellipsis.d-text-nowrap') - print(f"已定位到地点输入框: {loc_ele}") await loc_ele.click() - print("点击地点输入框完成") - print(f"等待1秒后输入位置名称: {location}") await page.wait_for_timeout(1000) await page.keyboard.type(location) - print(f"位置名称输入完成: {location}") - print("等待下拉列表加载...") dropdown_selector = 'div.d-popover.d-popover-default.d-dropdown.--size-min-width-large' await page.wait_for_timeout(2000) try: await page.wait_for_selector(dropdown_selector, timeout=3000) - print("下拉列表已加载") except Exception: - print("下拉列表未按预期显示,可能结构已变化") - print("额外等待1秒确保内容渲染完成...") + xiaohongshu_logger.warning(_msg("😵", "位置下拉列表没按预期出现,小人继续按旧逻辑查找")) await page.wait_for_timeout(1000) - print("尝试使用更灵活的XPath选择器...") flexible_xpath = ( f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]' f'//div[contains(@class, "d-options-wrapper")]' @@ -348,17 +339,13 @@ class XiaoHongShuBaseUploader(BaseVideoUploader): f'//div[contains(@class, "name") and text()="{location}"]' ) await page.wait_for_timeout(3000) - print(f"尝试定位包含'{location}'的选项...") try: location_option = await page.wait_for_selector( flexible_xpath, timeout=3000 ) - if location_option: - print(f"使用灵活选择器定位成功: {location_option}") - else: - print("灵活选择器未找到元素,尝试原始选择器...") + if not location_option: location_option = await page.wait_for_selector( f'//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]' f'//div[contains(@class, "d-options-wrapper")]' @@ -367,18 +354,12 @@ class XiaoHongShuBaseUploader(BaseVideoUploader): timeout=2000 ) - print("滚动到目标选项...") await location_option.scroll_into_view_if_needed() - print("元素已滚动到视图内") - is_visible = await location_option.is_visible() - print(f"目标选项是否可见: {is_visible}") - print("准备点击目标选项...") await location_option.click() - print(f"成功选择位置: {location}") + xiaohongshu_logger.success(_msg("🥳", f"位置已经设置成 {location}")) return True except Exception as e: - print(f"定位位置失败: {e}") - print("尝试获取下拉列表中的所有选项...") + xiaohongshu_logger.error(_msg("😢", f"设置位置失败: {e}")) try: all_options = await page.query_selector_all( '//div[contains(@class, "d-popover") and contains(@class, "d-dropdown")]' @@ -386,12 +367,12 @@ class XiaoHongShuBaseUploader(BaseVideoUploader): '//div[contains(@class, "d-grid") and contains(@class, "d-options")]' '/div' ) - print(f"找到 {len(all_options)} 个选项") + xiaohongshu_logger.debug(_msg("🧍", f"位置下拉里一共找到 {len(all_options)} 个选项")) for i, option in enumerate(all_options[:3]): option_text = await option.inner_text() - print(f"选项 {i + 1}: {option_text.strip()[:50]}...") + xiaohongshu_logger.debug(_msg("🧾", f"候选位置 {i + 1}: {option_text.strip()[:50]}")) except Exception as inner_e: - print(f"获取选项列表失败: {inner_e}") + xiaohongshu_logger.debug(_msg("😵", f"读取位置候选列表失败: {inner_e}")) return False async def fill_title(self, page: Page) -> None: @@ -470,7 +451,7 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader): self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path)) async def handle_upload_error(self, page: Page): - xiaohongshu_logger.info('视频出错了,重新上传中') + xiaohongshu_logger.warning(_msg("😵", "视频上传摔了一跤,小人马上重新上传")) await page.locator('div.progress-div [class^="upload-btn-input"]').set_input_files(self.file_path) async def set_thumbnail(self, page: Page, thumbnail_path: str): @@ -503,8 +484,8 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader): xiaohongshu_logger.success(_msg("🥳", "封面已经设置完成")) async def upload_video_content(self, page: Page) -> None: - xiaohongshu_logger.info(f'[+]正在上传-------{self.title}.mp4') - xiaohongshu_logger.info(f'[-] 正在打开主页...') + xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运视频: {self.title}.mp4")) + xiaohongshu_logger.info(_msg("🧭", "小人正在赶往视频发布页")) await page.goto(XHS_PUBLISH_VIDEO_URL) await page.wait_for_url(XHS_PUBLISH_VIDEO_URL) await page.locator("div[class^='upload-content'] input[class='upload-input']").set_input_files(self.file_path) @@ -519,21 +500,20 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader): upload_success = False for stage in stage_elements: text_content = await page.evaluate('(element) => element.textContent', stage) - if '上传成功' in text_content or '分辨率' in text_content : + if '上传成功' in text_content or '分辨率' in text_content: upload_success = True break if upload_success: - xiaohongshu_logger.info("[+] 检测到上传成功标识!") + xiaohongshu_logger.success(_msg("🥳", "视频已经传完啦")) break - print(" [-] 未找到上传成功标识,继续等待...") + xiaohongshu_logger.debug(_msg("🧍", "还没看到上传成功标识,小人继续等一会")) else: - print(" [-] 未找到预览元素,继续等待...") - await asyncio.sleep(3) + xiaohongshu_logger.debug(_msg("🧍", "还没拿到预览区域,小人继续等一会")) except Exception as e: - print(f" [-] 检测过程出错: {str(e)},重新尝试...") - await asyncio.sleep(3) + xiaohongshu_logger.debug(_msg("😵", f"上传状态还没稳定下来,小人继续观察: {e}")) + await asyncio.sleep(2) - xiaohongshu_logger.info(' [-] 正在填写标题、描述和话题...') + xiaohongshu_logger.info(_msg("✍️", "小人开始填标题、描述和话题")) await self.fill_meta(page) await self.set_thumbnail(page, self.thumbnail_path) @@ -553,19 +533,19 @@ class XiaoHongShuVideo(XiaoHongShuBaseUploader): "https://creator.xiaohongshu.com/publish/success?**", timeout=3000 ) - xiaohongshu_logger.success(" [-]视频发布成功") + xiaohongshu_logger.success(_msg("🥳", "视频发布成功,小人开心收工")) break except Exception: - xiaohongshu_logger.info(" [-] 视频正在发布中...") - await page.screenshot(full_page=True) + xiaohongshu_logger.info(_msg("🏃", "小人正在冲刺发布视频")) + if self.debug: + await page.screenshot(full_page=True) await asyncio.sleep(0.5) async def upload(self, playwright: Playwright) -> None: xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、视频文件、封面和发布时间")) await self.validate_upload_args() xiaohongshu_logger.info(_msg("🥳", "上传前检查通过")) - - browser = await playwright.chromium.launch(headless=self.headless) + browser = await playwright.chromium.launch(headless=self.headless, channel="chrome") context = await browser.new_context( permissions=["geolocation"], storage_state=self.account_file, @@ -632,8 +612,8 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader): self.image_paths = normalized_image_paths async def upload_note_content(self, page: Page) -> None: - xiaohongshu_logger.info(f'[+]正在上传图文,共 {len(self.image_paths)} 张图片') - xiaohongshu_logger.info('[-] 正在打开图文发布页...') + xiaohongshu_logger.info(_msg("🏃", f"小人开始搬运图文,共 {len(self.image_paths)} 张图片")) + xiaohongshu_logger.info(_msg("🧭", "小人正在赶往图文发布页")) await page.goto(XHS_PUBLISH_NOTE_URL) await page.wait_for_url(XHS_PUBLISH_NOTE_URL) @@ -642,19 +622,20 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader): upload_input = page.locator("div[class^='upload-content'] input[class='upload-input']").first await upload_input.wait_for(state="attached", timeout=30000) + xiaohongshu_logger.info(_msg("📤", "小人正在上传图片")) await upload_input.set_input_files(self.image_paths) while True: try: title_container = page.locator('input[placeholder*="填写标题"]').first await title_container.wait_for(state="visible", timeout=3000) - xiaohongshu_logger.info("[+] 检测到图文素材上传完成,已经可以填写内容") + xiaohongshu_logger.success(_msg("🥳", "图文素材已经传完,可以开始填写内容了")) break except Exception: - xiaohongshu_logger.info(" [-] 图文素材仍在上传中,继续等待...") + xiaohongshu_logger.debug(_msg("🧍", "图文素材还在上传,小人继续等一会")) await asyncio.sleep(1) - xiaohongshu_logger.info(' [-] 正在填写标题、描述和话题...') + xiaohongshu_logger.info(_msg("✍️", "小人开始填标题、描述和话题")) await self.fill_meta(page) if self.publish_strategy == XIAOHONGSHU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0: @@ -670,20 +651,21 @@ class XiaoHongShuNote(XiaoHongShuBaseUploader): XHS_PUBLISH_SUCCESS_URL_PATTERN, timeout=3000 ) - xiaohongshu_logger.success(" [-]图文发布成功") + xiaohongshu_logger.success(_msg("🥳", "图文发布成功,小人开心收工")) break except Exception: - xiaohongshu_logger.info(" [-] 图文正在发布中...") - await page.screenshot(full_page=True) + xiaohongshu_logger.info(_msg("🏃", "小人正在冲刺发布图文")) + if self.debug: + await page.screenshot(full_page=True) await asyncio.sleep(0.5) async def upload(self, playwright: Playwright) -> None: xiaohongshu_logger.info(_msg("🧍", "小人先检查 cookie、图片和发布时间")) await self.validate_upload_args() xiaohongshu_logger.info(_msg("🥳", "图文上传前检查通过")) - browser = await playwright.chromium.launch(headless=self.headless, channel="chrome") context = await browser.new_context( + permissions=["geolocation"], storage_state=self.account_file, ) context = await set_init_script(context)