You need to sign in or sign up before continuing.
zy
Committed by GitHub

功能: P0 增强 — 长文发布、Headless 降级、分步 CLI (#2)

- 新增写长文发布模式(publish_long_article.py):支持长文编辑、一键排版、模板选择
- 新增 Headless 自动降级:未登录时自动切换到有窗口模式
- 新增分步发布命令:fill-publish / fill-publish-video / click-publish
- 拆分 publish 为 fill_publish_form + click_publish_button
- chrome_launcher 新增 restart_chrome / kill_chrome
- 新增 6 个 CLI 子命令,总计 19 个
- 更新 SKILL.md 含长文模式和分步发布工作流

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
... ... @@ -25,10 +25,11 @@ xiaohongshu-skills/
│ │ ├── user_profile.py # 用户主页
│ │ ├── comment.py # 评论、回复
│ │ ├── like_favorite.py # 点赞、收藏
│ │ ├── publish.py # 图文发布
│ │ └── publish_video.py # 视频发布
│ ├── cli.py # 统一 CLI 入口(13 个子命令)
│ ├── chrome_launcher.py # Chrome 进程管理
│ │ ├── publish.py # 图文发布(fill + click 分步支持)
│ │ ├── publish_video.py # 视频发布(fill + click 分步支持)
│ │ └── publish_long_article.py # 长文发布(模板选择 + 排版)
│ ├── cli.py # 统一 CLI 入口(19 个子命令)
│ ├── chrome_launcher.py # Chrome 进程管理(含 restart 降级)
│ ├── account_manager.py # 多账号管理
│ ├── image_downloader.py # 媒体下载(SHA256 缓存)
│ ├── title_utils.py # UTF-16 标题长度计算
... ... @@ -72,7 +73,7 @@ uv run pytest # 运行测试
1. **scripts/ — Python CDP 引擎**
- 基于 xiaohongshu-mcp Go 源码从零重写
- `xhs/` 包:模块化的核心自动化库
- `cli.py`:统一 CLI 入口,13 个子命令对应 MCP 工具
- `cli.py`:统一 CLI 入口,19 个子命令(13 个 MCP + 6 个增强)
- JSON 结构化输出,便于 agent 解析
- 多账号支持,独立 Chrome Profile 隔离
- 反检测保护(stealth flags + JS 注入)
... ... @@ -123,11 +124,11 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima
- **xiaohongshu-mcp Go 源码**: /Users/zy/src/zy/xiaohongshu-mcp/
## MCP 工具对照表
## CLI 子命令对照表
scripts/cli.py 的 13 个子命令对应 xiaohongshu-mcp 的 MCP 工具
scripts/cli.py 的 19 个子命令
| CLI 子命令 | MCP 工具 | 分类 |
| CLI 子命令 | 对应 MCP 工具 | 分类 |
|--|--|--|
| `check-login` | check_login_status | 认证 |
| `login` | get_login_qrcode | 认证 |
... ... @@ -142,3 +143,9 @@ scripts/cli.py 的 13 个子命令对应 xiaohongshu-mcp 的 MCP 工具:
| `favorite-feed` | favorite_feed | 互动 |
| `publish` | publish_content | 发布 |
| `publish-video` | publish_with_video | 发布 |
| `fill-publish` | — | 分步发布(图文填写) |
| `fill-publish-video` | — | 分步发布(视频填写) |
| `click-publish` | — | 分步发布(点击发布) |
| `long-article` | — | 长文发布(填写+排版) |
| `select-template` | — | 长文发布(选择模板) |
| `next-step` | — | 长文发布(下一步+描述) |
... ...
# 小红书 Skills 开发任务
# 小红书 Skills P0 增强任务
## 目标
基于 xiaohongshu-mcp Go 源码,从零重写 Python CDP 引擎,为 OpenClaw 生态构建完整的小红书自动化 Skills
在现有 13 个 MCP 工具基础上,补充 3 项 P0 能力:写长文发布模式、Headless 自动降级、分步 CLI 命令
## 参考资料
- **xiaohongshu-mcp Go 源码**: `/Users/zy/src/zy/xiaohongshu-mcp/` — 10k stars,13 个 MCP 工具
- **xiaohongshu-mcp 数据结构**: `/Users/zy/src/zy/xiaohongshu-mcp/xiaohongshu/types.go`
- **xiaohongshu-mcp 工具定义**: `/Users/zy/src/zy/xiaohongshu-mcp/mcp_server.go`
- **xiaohongshu-mcp/skills Python 实现**(主要参考):
- `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/scripts/cdp_publish.py` — 长文发布核心逻辑
- `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/scripts/publish_pipeline.py` — headless 降级逻辑
- `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/scripts/chrome_launcher.py` — Chrome 重启/模式切换
- `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/SKILL.md` — 长文工作流 SKILL 定义
- `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/references/publish-workflow.md` — DOM 选择器参考
## 架构
- **当前项目代码**: `/Users/zy/src/zy/00_autoclaw/xiaohongshu-skills/scripts/`
### 模块结构
## 代码规范
- `uv run ruff check .` 无错误
- `uv run ruff format --check .` 无差异
- 完整 type hints,`from __future__ import annotations`
- 公共函数有 docstring
- 行长度 ≤ 100
- 异常继承 `XHSError`
- JSON 输出 `ensure_ascii=False`
- Exit code: 0=成功,1=未登录,2=错误
## 任务拆解
### Task 1: 写长文发布模式
参考 `cdp_publish.py` 的 `publish_long_article()`、`get_template_names()`、`select_template()`、`click_next_and_prepare_publish()` 方法。
#### 1.1 新增选择器(`xhs/selectors.py`)
添加长文模式相关的 CSS 选择器:
- `LONG_ARTICLE_TAB` — "写长文" tab
- `NEW_CREATION_BUTTON` — "新的创作" 按钮
- `LONG_ARTICLE_TITLE` — 长文标题 textarea
- `AUTO_FORMAT_BUTTON` — "一键排版" 按钮
- `TEMPLATE_CARD` — 模板卡片
- `TEMPLATE_TITLE` — 模板名称
- `NEXT_STEP_BUTTON` — "下一步" 按钮
参考 reference `publish-workflow.md` 中的 DOM 选择器参考表。
#### 1.2 新增长文发布模块(`xhs/publish_long_article.py`)
创建独立模块,包含以下函数:
```python
def publish_long_article(page, title, content, image_paths=None) -> list[str]:
"""长文发布:导航 → 点击写长文 → 新的创作 → 填写标题正文 → 一键排版。
返回可用模板名称列表。"""
def get_template_names(page) -> list[str]:
"""获取当前可用的排版模板名称列表。"""
def select_template(page, template_name) -> bool:
"""选择指定名称的排版模板。"""
def click_next_and_fill_description(page, description) -> None:
"""点击下一步,进入发布页并填写正文描述。
注意:发布页有独立的正文编辑器,需单独填入。
如果 description 超过 1000 字,应压缩到 800 字左右。"""
```
#### 1.3 新增 CLI 子命令(`cli.py`)
添加 3 个子命令:
```bash
# 长文模式:填写内容 + 一键排版,返回模板列表
python scripts/cli.py long-article \
--title-file T --content-file C [--images P1 P2]
# 选择模板
python scripts/cli.py select-template --name "模板名"
# 点击下一步 + 填写发布页描述
python scripts/cli.py next-step --content-file C
```
#### 1.4 更新 SKILL.md
`skills/xhs-publish/SKILL.md` 中添加写长文模式的完整工作流:
- 输入判断:用户说"发长文 / 写长文 / 长文模式"时触发
- Step B.1-B.5 的工作流
- 模板选择通过 AskUserQuestion 让用户选
### Task 2: Headless 自动降级
参考 `publish_pipeline.py` 的登录检查 + 模式切换逻辑,以及 `chrome_launcher.py` 的 `restart_chrome()`
#### 2.1 增强 `chrome_launcher.py`
添加 `restart_chrome()` 函数:
- 关闭当前 Chrome 实例
- 以新模式(headless 或 headed)重新启动
- 等待端口就绪
#### 2.2 增强 `publish_pipeline.py`
`run_publish_pipeline()` 中加入降级逻辑:
```
检查登录 → 如果未登录且是 headless 模式:
1. 关闭无头 Chrome
2. 以有窗口模式重新启动 Chrome
3. 打开登录页
4. 返回 {"success": false, "error": "未登录", "action": "switched_to_headed", "message": "已切换到有窗口模式,请在浏览器中扫码登录"}
5. exit code 1
```
scripts/
├── xhs/ # 核心 XHS 自动化包
│ ├── cdp.py # CDP WebSocket 客户端
│ ├── stealth.py # 反检测 JS 注入 + Chrome 启动参数
│ ├── cookies.py # Cookie 文件持久化
│ ├── types.py # 数据类型(dataclass)
│ ├── errors.py # 异常体系
│ ├── selectors.py # CSS 选择器常量
│ ├── urls.py # URL 常量
│ ├── human.py # 人类行为模拟
│ ├── login.py # 登录
│ ├── feeds.py # 首页 Feed
│ ├── search.py # 搜索 + 筛选
│ ├── feed_detail.py # 笔记详情 + 评论加载
│ ├── user_profile.py # 用户主页
│ ├── comment.py # 评论、回复
│ ├── like_favorite.py # 点赞、收藏
│ ├── publish.py # 图文发布
│ └── publish_video.py # 视频发布
├── cli.py # 统一 CLI 入口(13 个子命令)
├── chrome_launcher.py # Chrome 进程管理
├── account_manager.py # 多账号管理
├── image_downloader.py # 媒体下载(SHA256 缓存)
├── title_utils.py # UTF-16 标题长度计算
├── run_lock.py # 单实例锁
└── publish_pipeline.py # 发布编排器
#### 2.3 新增 CLI 参数
`publish` 和 `publish-video` 子命令添加 `--headless` 参数:
```bash
python scripts/cli.py publish --headless \
--title-file T --content-file C --images P1 P2
```
### CLI 接口(对应 Go 的 13 个 MCP 工具)
`--headless` + 未登录时,自动降级到有窗口模式。
### Task 3: 分步 CLI 命令
参考 `cdp_publish.py` 的 `fill`、`click-publish` 子命令设计。目标是让 agent 可以在填写表单和点击发布之间插入用户确认步骤。
#### 3.1 新增 CLI 子命令
```bash
python scripts/cli.py check-login
python scripts/cli.py login
python scripts/cli.py delete-cookies
python scripts/cli.py list-feeds
python scripts/cli.py search-feeds --keyword "关键词" [--sort-by --note-type ...]
python scripts/cli.py get-feed-detail --feed-id ID --xsec-token TOKEN [--load-all-comments]
python scripts/cli.py user-profile --user-id ID --xsec-token TOKEN
python scripts/cli.py post-comment --feed-id ID --xsec-token TOKEN --content "内容"
python scripts/cli.py reply-comment --feed-id ID --xsec-token TOKEN --content "内容" [--comment-id | --user-id]
python scripts/cli.py like-feed --feed-id ID --xsec-token TOKEN [--unlike]
python scripts/cli.py favorite-feed --feed-id ID --xsec-token TOKEN [--unfavorite]
python scripts/cli.py publish --title-file T --content-file C --images P1 P2 [--tags --schedule-at --visibility]
python scripts/cli.py publish-video --title-file T --content-file C --video P [--tags --schedule-at]
# 只填写表单,不发布(图文模式)
python scripts/cli.py fill-publish \
--title-file T --content-file C --images P1 P2 \
[--tags --schedule-at --visibility --original]
# 只填写表单,不发布(视频模式)
python scripts/cli.py fill-publish-video \
--title-file T --content-file C --video P \
[--tags --schedule-at --visibility]
# 点击发布按钮(在用户确认后调用)
python scripts/cli.py click-publish
```
全局选项:`--host`, `--port`, `--account`
输出:JSON(`ensure_ascii=False`
退出码:0=成功,1=未登录,2=错误
#### 3.2 拆分现有 publish 逻辑
`xhs/publish.py` 中将 `publish_image_content()` 拆分为:
- `fill_publish_form(page, content)` — 导航、上传、填写表单,**不点击发布**
- `click_publish_button(page)` — 仅点击发布按钮
`publish_image_content()` 保持不变(内部调用两者),向后兼容。
同理拆分 `xhs/publish_video.py`
#### 3.3 更新 SKILL.md
`skills/xhs-publish/SKILL.md` 中:
- 推荐的发布流程改为:fill → 用户通过 AskUserQuestion 确认 → click-publish
- 保留一步到位的 `publish` 命令作为快捷方式
## 代码规范要求
### Task 4: 验证 + 收尾
- Python 代码必须通过 `ruff check` 和 `ruff format`
- 完整的 type hints(PEP 484),使用 `str | None` 而非 `Optional[str]`
- 公共函数和类必须有 docstring
- 行长度上限 100 字符
- 使用 `from __future__ import annotations` 启用延迟注解
- 异常类统一继承自 `XHSError`
- CLI 使用 argparse,exit code: 0=成功,1=未登录,2=错误
- JSON 输出使用 `ensure_ascii=False` 保留中文
- `uv run ruff check .` 无错误
- `uv run ruff format --check .` 无差异
- 所有新增 CLI 子命令 `--help` 正常输出
- `skills/xhs-publish/SKILL.md` 包含长文模式和分步发布的完整工作流
- `CLAUDE.md` 的 MCP 工具对照表更新(新增子命令)
## 完成标志
当以下条件全部满足时,输出完成标志:
1. `xhs/` 包 17 个模块已全部创建
2. `cli.py` 13 个子命令已实现
3. 5 个支撑脚本已重写
4. 5 个 `skills/*/SKILL.md` 已更新
5. 根目录 `SKILL.md`、`CLAUDE.md`、`README.md` 已更新
1. `xhs/publish_long_article.py` 已创建,含 4 个核心函数
2. `cli.py` 新增 6 个子命令:`long-article`, `select-template`, `next-step`, `fill-publish`, `fill-publish-video`, `click-publish`
3. `chrome_launcher.py` 含 `restart_chrome()` 函数
4. `publish_pipeline.py` 含 headless 自动降级逻辑
5. `skills/xhs-publish/SKILL.md` 含长文模式和分步发布工作流
6. `uv run ruff check .` 无错误
7. `uv run ruff format --check .` 无差异
<promise>ALL SKILLS COMPLETE</promise>
<promise>P0 ENHANCE COMPLETE</promise>
... ...
... ... @@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import os
import platform
... ... @@ -139,6 +140,85 @@ def is_chrome_running(port: int = DEFAULT_PORT) -> bool:
return False
def kill_chrome(port: int = DEFAULT_PORT) -> None:
"""关闭指定端口的 Chrome 实例。
尝试通过 CDP Browser.close 命令关闭,失败则使用进程信号。
Args:
port: Chrome 调试端口。
"""
import requests
# 策略1: 通过 CDP 关闭
try:
resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
if resp.status_code == 200:
ws_url = resp.json().get("webSocketDebuggerUrl")
if ws_url:
import websockets.sync.client
ws = websockets.sync.client.connect(ws_url)
ws.send(json.dumps({"id": 1, "method": "Browser.close"}))
ws.close()
logger.info("通过 CDP Browser.close 关闭 Chrome (port=%d)", port)
time.sleep(1)
return
except Exception:
pass
# 策略2: 通过 lsof 查找并 kill 进程
try:
result = subprocess.run(
["lsof", "-ti", f":{port}"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
import contextlib
pids = result.stdout.strip().split("\n")
for pid in pids:
with contextlib.suppress(OSError, ValueError):
os.kill(int(pid), signal.SIGTERM)
logger.info("通过 SIGTERM 关闭 Chrome 进程 (port=%d)", port)
time.sleep(1)
return
except Exception:
pass
logger.warning("未能关闭 Chrome (port=%d)", port)
def restart_chrome(
port: int = DEFAULT_PORT,
headless: bool = False,
user_data_dir: str | None = None,
chrome_bin: str | None = None,
) -> subprocess.Popen:
"""重启 Chrome:关闭当前实例后以新模式重新启动。
Args:
port: 远程调试端口。
headless: 是否无头模式。
user_data_dir: 用户数据目录。
chrome_bin: Chrome 可执行文件路径。
Returns:
新的 Chrome 子进程。
"""
logger.info("重启 Chrome: port=%d, headless=%s", port, headless)
kill_chrome(port)
time.sleep(1)
return launch_chrome(
port=port,
headless=headless,
user_data_dir=user_data_dir,
chrome_bin=chrome_bin,
)
def _wait_for_chrome(port: int, timeout: float = 15.0) -> None:
"""等待 Chrome 调试端口就绪。"""
deadline = time.monotonic() + timeout
... ...
... ... @@ -35,6 +35,23 @@ def _connect(args: argparse.Namespace):
return browser, page
def _headless_fallback(port: int) -> None:
"""Headless 模式未登录时自动降级到有窗口模式。"""
from chrome_launcher import restart_chrome
logger.info("Headless 模式未登录,切换到有窗口模式...")
restart_chrome(port=port, headless=False)
_output(
{
"success": False,
"error": "未登录",
"action": "switched_to_headed",
"message": "已切换到有窗口模式,请在浏览器中扫码登录",
},
exit_code=1,
)
# ========== 子命令实现 ==========
... ... @@ -234,6 +251,7 @@ def cmd_favorite_feed(args: argparse.Namespace) -> None:
def cmd_publish(args: argparse.Namespace) -> None:
"""发布图文内容。"""
from image_downloader import process_images
from xhs.login import check_login_status
from xhs.publish import publish_image_content
from xhs.types import PublishImageContent
... ... @@ -250,6 +268,14 @@ def cmd_publish(args: argparse.Namespace) -> None:
browser, page = _connect(args)
try:
# headless 模式登录检查 + 自动降级
headless = getattr(args, "headless", False)
if headless and not check_login_status(page):
browser.close_page(page)
browser.close()
_headless_fallback(args.port)
return
publish_image_content(
page,
PublishImageContent(
... ... @@ -268,8 +294,164 @@ def cmd_publish(args: argparse.Namespace) -> None:
browser.close()
def cmd_fill_publish(args: argparse.Namespace) -> None:
"""只填写图文表单,不发布。"""
from image_downloader import process_images
from xhs.publish import fill_publish_form
from xhs.types import PublishImageContent
with open(args.title_file, encoding="utf-8") as f:
title = f.read().strip()
with open(args.content_file, encoding="utf-8") as f:
content = f.read().strip()
image_paths = process_images(args.images) if args.images else []
if not image_paths:
_output({"success": False, "error": "没有有效的图片"}, exit_code=2)
browser, page = _connect(args)
try:
fill_publish_form(
page,
PublishImageContent(
title=title,
content=content,
tags=args.tags or [],
image_paths=image_paths,
schedule_time=args.schedule_at,
is_original=args.original,
visibility=args.visibility or "",
),
)
_output(
{
"success": True,
"title": title,
"images": len(image_paths),
"status": "表单已填写,等待确认发布",
}
)
finally:
browser.close_page(page)
browser.close()
def cmd_fill_publish_video(args: argparse.Namespace) -> None:
"""只填写视频表单,不发布。"""
from xhs.publish_video import fill_publish_video_form
from xhs.types import PublishVideoContent
with open(args.title_file, encoding="utf-8") as f:
title = f.read().strip()
with open(args.content_file, encoding="utf-8") as f:
content = f.read().strip()
browser, page = _connect(args)
try:
fill_publish_video_form(
page,
PublishVideoContent(
title=title,
content=content,
tags=args.tags or [],
video_path=args.video,
schedule_time=args.schedule_at,
visibility=args.visibility or "",
),
)
_output(
{
"success": True,
"title": title,
"video": args.video,
"status": "视频表单已填写,等待确认发布",
}
)
finally:
browser.close_page(page)
browser.close()
def cmd_click_publish(args: argparse.Namespace) -> None:
"""点击发布按钮(在用户确认后调用)。"""
from xhs.publish import click_publish_button
browser, page = _connect(args)
try:
click_publish_button(page)
_output({"success": True, "status": "发布完成"})
finally:
browser.close_page(page)
browser.close()
def cmd_long_article(args: argparse.Namespace) -> None:
"""长文模式:填写内容 + 一键排版,返回模板列表。"""
from xhs.publish_long_article import publish_long_article
with open(args.title_file, encoding="utf-8") as f:
title = f.read().strip()
with open(args.content_file, encoding="utf-8") as f:
content = f.read().strip()
browser, page = _connect(args)
try:
template_names = publish_long_article(
page,
title=title,
content=content,
image_paths=args.images,
)
_output(
{
"success": True,
"templates": template_names,
"status": "长文已填写,请选择模板",
}
)
finally:
browser.close_page(page)
browser.close()
def cmd_select_template(args: argparse.Namespace) -> None:
"""选择排版模板。"""
from xhs.publish_long_article import select_template
browser, page = _connect(args)
try:
selected = select_template(page, args.name)
if selected:
_output({"success": True, "template": args.name, "status": "模板已选择"})
else:
_output(
{"success": False, "error": f"未找到模板: {args.name}"},
exit_code=2,
)
finally:
browser.close_page(page)
browser.close()
def cmd_next_step(args: argparse.Namespace) -> None:
"""点击下一步 + 填写发布页描述。"""
from xhs.publish_long_article import click_next_and_fill_description
with open(args.content_file, encoding="utf-8") as f:
description = f.read().strip()
browser, page = _connect(args)
try:
click_next_and_fill_description(page, description)
_output({"success": True, "status": "已进入发布页,等待确认发布"})
finally:
browser.close_page(page)
browser.close()
def cmd_publish_video(args: argparse.Namespace) -> None:
"""发布视频内容。"""
from xhs.login import check_login_status
from xhs.publish_video import publish_video_content
from xhs.types import PublishVideoContent
... ... @@ -280,6 +462,14 @@ def cmd_publish_video(args: argparse.Namespace) -> None:
browser, page = _connect(args)
try:
# headless 模式登录检查 + 自动降级
headless = getattr(args, "headless", False)
if headless and not check_login_status(page):
browser.close_page(page)
browser.close()
_headless_fallback(args.port)
return
publish_video_content(
page,
PublishVideoContent(
... ... @@ -396,6 +586,7 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
sub.add_argument("--original", action="store_true", help="声明原创")
sub.add_argument("--visibility", help="可见范围")
sub.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
sub.set_defaults(func=cmd_publish)
# publish-video
... ... @@ -406,8 +597,51 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_argument("--tags", nargs="*", help="标签")
sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
sub.add_argument("--visibility", help="可见范围")
sub.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
sub.set_defaults(func=cmd_publish_video)
# fill-publish(只填写图文表单,不发布)
sub = subparsers.add_parser("fill-publish", help="填写图文表单(不发布)")
sub.add_argument("--title-file", required=True, help="标题文件路径")
sub.add_argument("--content-file", required=True, help="正文文件路径")
sub.add_argument("--images", nargs="+", required=True, help="图片路径/URL")
sub.add_argument("--tags", nargs="*", help="标签")
sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
sub.add_argument("--original", action="store_true", help="声明原创")
sub.add_argument("--visibility", help="可见范围")
sub.set_defaults(func=cmd_fill_publish)
# fill-publish-video(只填写视频表单,不发布)
sub = subparsers.add_parser("fill-publish-video", help="填写视频表单(不发布)")
sub.add_argument("--title-file", required=True, help="标题文件路径")
sub.add_argument("--content-file", required=True, help="正文文件路径")
sub.add_argument("--video", required=True, help="视频文件路径")
sub.add_argument("--tags", nargs="*", help="标签")
sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
sub.add_argument("--visibility", help="可见范围")
sub.set_defaults(func=cmd_fill_publish_video)
# click-publish(点击发布按钮)
sub = subparsers.add_parser("click-publish", help="点击发布按钮")
sub.set_defaults(func=cmd_click_publish)
# long-article(长文模式)
sub = subparsers.add_parser("long-article", help="长文模式:填写 + 一键排版")
sub.add_argument("--title-file", required=True, help="标题文件路径")
sub.add_argument("--content-file", required=True, help="正文文件路径")
sub.add_argument("--images", nargs="*", help="可选图片路径")
sub.set_defaults(func=cmd_long_article)
# select-template(选择模板)
sub = subparsers.add_parser("select-template", help="选择排版模板")
sub.add_argument("--name", required=True, help="模板名称")
sub.set_defaults(func=cmd_select_template)
# next-step(下一步 + 填写描述)
sub = subparsers.add_parser("next-step", help="点击下一步 + 填写描述")
sub.add_argument("--content-file", required=True, help="描述内容文件路径")
sub.set_defaults(func=cmd_next_step)
return parser
... ...
... ... @@ -29,9 +29,12 @@ def run_publish_pipeline(
host: str = "127.0.0.1",
port: int = 9222,
account: str = "",
headless: bool = False,
) -> dict:
"""执行完整发布流水线。
当 headless=True 且未登录时,自动降级到有窗口模式。
Returns:
发布结果字典。
"""
... ... @@ -56,7 +59,28 @@ def run_publish_pipeline(
try:
# 登录检查
if not check_login_status(page):
return {"success": False, "error": "未登录", "exit_code": 1}
browser.close_page(page)
browser.close()
# Headless 自动降级:切换到有窗口模式
if headless:
from chrome_launcher import restart_chrome
logger.info("Headless 模式未登录,切换到有窗口模式...")
restart_chrome(port=port, headless=False)
return {
"success": False,
"error": "未登录",
"action": "switched_to_headed",
"message": "已切换到有窗口模式,请在浏览器中扫码登录",
"exit_code": 1,
}
return {
"success": False,
"error": "未登录",
"exit_code": 1,
}
# 发布
if video:
... ... @@ -113,6 +137,7 @@ def main() -> None:
parser.add_argument("--schedule-at", help="定时发布时间 (ISO8601)")
parser.add_argument("--original", action="store_true", help="声明原创")
parser.add_argument("--visibility", default="", help="可见范围")
parser.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9222)
parser.add_argument("--account", default="")
... ... @@ -136,10 +161,12 @@ def main() -> None:
host=args.host,
port=args.port,
account=args.account,
headless=args.headless,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0 if result["success"] else 2)
exit_code = result.get("exit_code", 0 if result["success"] else 2)
sys.exit(exit_code)
if __name__ == "__main__":
... ...
... ... @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
def publish_image_content(page: Page, content: PublishImageContent) -> None:
"""发布图文内容。
"""发布图文内容(填写表单 + 点击发布)
Args:
page: CDP 页面对象。
... ... @@ -49,6 +49,23 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None:
TitleTooLongError: 标题超长。
ContentTooLongError: 正文超长。
"""
fill_publish_form(page, content)
click_publish_button(page)
def fill_publish_form(page: Page, content: PublishImageContent) -> None:
"""填写图文发布表单,不点击发布按钮。
Args:
page: CDP 页面对象。
content: 发布内容。
Raises:
PublishError: 填写失败。
UploadTimeoutError: 上传超时。
TitleTooLongError: 标题超长。
ContentTooLongError: 正文超长。
"""
if not content.image_paths:
raise PublishError("图片不能为空")
... ... @@ -77,8 +94,8 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None:
content.visibility,
)
# 提交发布
_submit_publish(
# 填写表单(不点击发布)
_fill_publish_form(
page,
content.title,
content.content,
... ... @@ -89,6 +106,20 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None:
)
def click_publish_button(page: Page) -> None:
"""点击发布按钮。
Args:
page: CDP 页面对象。
Raises:
PublishError: 点击失败。
"""
page.click_element(PUBLISH_BUTTON)
time.sleep(3)
logger.info("发布完成")
# ========== 页面导航 ==========
... ... @@ -192,7 +223,7 @@ def _wait_for_upload_complete(page: Page, expected_count: int) -> None:
# ========== 表单提交 ==========
def _submit_publish(
def _fill_publish_form(
page: Page,
title: str,
content: str,
... ... @@ -201,7 +232,7 @@ def _submit_publish(
is_original: bool,
visibility: str,
) -> None:
"""填写表单并提交。"""
"""填写表单(不点击发布)。"""
# 标题
page.input_text(TITLE_INPUT, title)
time.sleep(0.5)
... ... @@ -240,10 +271,7 @@ def _submit_publish(
except Exception as e:
logger.warning("设置原创声明失败: %s", e)
# 点击发布
page.click_element(PUBLISH_BUTTON)
time.sleep(3)
logger.info("发布完成")
logger.info("表单填写完成,等待确认发布")
def _find_content_element(page: Page) -> str:
... ...
"""长文发布模式,参考 cdp_publish.py 的长文工作流。"""
from __future__ import annotations
import json
import logging
import time
from .cdp import Page
from .errors import PublishError
from .publish import _click_publish_tab, _find_content_element, _navigate_to_publish_page
from .selectors import (
AUTO_FORMAT_BUTTON_TEXT,
CONTENT_EDITOR,
LONG_ARTICLE_TITLE,
NEW_CREATION_BUTTON_TEXT,
NEXT_STEP_BUTTON_TEXT,
TEMPLATE_CARD,
TEMPLATE_TITLE,
)
logger = logging.getLogger(__name__)
# 等待常量
_AUTO_FORMAT_WAIT = 3.0
_TEMPLATE_WAIT_ROUNDS = 15
_PAGE_LOAD_WAIT = 3.0
def publish_long_article(
page: Page,
title: str,
content: str,
image_paths: list[str] | None = None,
) -> list[str]:
"""长文发布:导航 → 点击写长文 → 新的创作 → 填写标题正文 → 一键排版。
返回可用模板名称列表。
Args:
page: CDP 页面对象。
title: 长文标题。
content: 长文正文(段落用换行分隔)。
image_paths: 可选的图片路径列表(插入编辑器)。
Returns:
可用模板名称列表。
Raises:
PublishError: 操作失败。
"""
# 1. 导航到发布页
_navigate_to_publish_page(page)
# 2. 点击"写长文"TAB
_click_publish_tab(page, "写长文")
time.sleep(1)
# 3. 点击"新的创作"
_click_new_creation(page)
# 4. 填写标题(textarea)
_fill_long_title(page, title)
# 5. 填写正文(TipTap 编辑器)
_fill_long_content(page, content)
# 6. 可选:插入图片到编辑器
if image_paths:
_insert_images_to_editor(page, image_paths)
# 7. 点击"一键排版"
_click_auto_format(page)
# 8. 等待模板加载并返回名称列表
_wait_for_templates(page)
template_names = get_template_names(page)
logger.info("模板加载完成: %s", template_names)
return template_names
def get_template_names(page: Page) -> list[str]:
"""获取当前可用的排版模板名称列表。
Args:
page: CDP 页面对象。
Returns:
模板名称列表。
"""
names = page.evaluate(
f"""
(() => {{
const cards = document.querySelectorAll({json.dumps(TEMPLATE_CARD)});
const names = [];
for (const card of cards) {{
const title = card.querySelector({json.dumps(TEMPLATE_TITLE)});
names.push(title ? title.textContent.trim() : 'Template ' + names.length);
}}
return names;
}})()
"""
)
return names or []
def select_template(page: Page, template_name: str) -> bool:
"""选择指定名称的排版模板。
Args:
page: CDP 页面对象。
template_name: 模板名称。
Returns:
是否成功选择。
"""
clicked = page.evaluate(
f"""
(() => {{
const cards = document.querySelectorAll({json.dumps(TEMPLATE_CARD)});
for (const card of cards) {{
const title = card.querySelector({json.dumps(TEMPLATE_TITLE)});
if (title && title.textContent.trim() === {json.dumps(template_name)}) {{
card.click();
return true;
}}
}}
return false;
}})()
"""
)
if clicked:
logger.info("已选择模板: %s", template_name)
time.sleep(1)
else:
logger.warning("未找到模板: %s", template_name)
return bool(clicked)
def click_next_and_fill_description(page: Page, description: str) -> None:
"""点击下一步,进入发布页并填写正文描述。
注意:发布页有独立的正文编辑器,需单独填入。
如果 description 超过 1000 字,应压缩到 800 字左右。
Args:
page: CDP 页面对象。
description: 发布页正文描述。
Raises:
PublishError: 操作失败。
"""
# 点击"下一步"
_click_button_by_text(page, NEXT_STEP_BUTTON_TEXT)
time.sleep(_PAGE_LOAD_WAIT)
# 填写发布页描述
if description:
# 截断描述到 1000 字以内
if len(description) > 1000:
description = description[:800]
logger.warning("描述超过1000字,已截断到800字")
content_selector = _find_content_element(page)
page.input_content_editable(content_selector, description)
logger.info("已填写发布页描述")
# ========== 内部辅助函数 ==========
def _click_new_creation(page: Page) -> None:
"""点击"新的创作"按钮。"""
_click_button_by_text(page, NEW_CREATION_BUTTON_TEXT)
time.sleep(2)
page.wait_dom_stable()
logger.info("已点击'新的创作'")
def _fill_long_title(page: Page, title: str) -> None:
"""填写长文标题(textarea,需使用 native setter)。"""
page.wait_for_element(LONG_ARTICLE_TITLE, timeout=10)
page.evaluate(
f"""
(() => {{
const el = document.querySelector({json.dumps(LONG_ARTICLE_TITLE)});
if (!el) return false;
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype, 'value'
).set;
el.focus();
nativeSetter.call(el, {json.dumps(title)});
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return true;
}})()
"""
)
logger.info("已填写长文标题: %s", title[:20])
time.sleep(0.5)
def _fill_long_content(page: Page, content: str) -> None:
"""填写长文正文(TipTap/ProseMirror 编辑器)。"""
content_selector = CONTENT_EDITOR
if not page.has_element(CONTENT_EDITOR):
content_selector = _find_content_element(page)
page.input_content_editable(content_selector, content)
logger.info("已填写长文正文 (%d 字)", len(content))
time.sleep(1)
def _insert_images_to_editor(page: Page, image_paths: list[str]) -> None:
"""将图片插入到编辑器中。"""
for img_path in image_paths:
normalized = img_path.replace("\\", "/")
page.evaluate(
f"""
(() => {{
const editor = document.querySelector({json.dumps(CONTENT_EDITOR)});
if (!editor) return false;
const img = document.createElement('img');
img.src = 'file:///' + {json.dumps(normalized)};
editor.appendChild(img);
editor.dispatchEvent(new Event('input', {{ bubbles: true }}));
return true;
}})()
"""
)
logger.info("已插入 %d 张图片到编辑器", len(image_paths))
time.sleep(1)
def _click_auto_format(page: Page) -> None:
"""点击"一键排版"按钮。"""
_click_button_by_text(page, AUTO_FORMAT_BUTTON_TEXT)
logger.info("已点击'一键排版',等待模板加载...")
time.sleep(_AUTO_FORMAT_WAIT)
def _wait_for_templates(page: Page) -> bool:
"""等待模板卡片出现。"""
for _ in range(_TEMPLATE_WAIT_ROUNDS):
count = page.get_elements_count(TEMPLATE_CARD)
if count and count > 0:
logger.info("发现 %d 个模板卡片", count)
return True
time.sleep(1)
logger.warning("等待模板卡片超时")
return False
def _click_button_by_text(page: Page, text: str) -> None:
"""通过文本内容查找并点击按钮(通用方法)。"""
clicked = page.evaluate(
f"""
(() => {{
const elems = document.querySelectorAll(
'button, [role="button"], span, div, a, [class*="btn"]'
);
for (const el of elems) {{
if (el.textContent.trim() === {json.dumps(text)}) {{
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
el.click();
return true;
}}
}}
return false;
}})()
"""
)
if not clicked:
raise PublishError(f"未找到'{text}'按钮,页面结构可能已变化")
... ...
... ... @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
def publish_video_content(page: Page, content: PublishVideoContent) -> None:
"""发布视频内容。
"""发布视频内容(填写表单 + 点击发布)
Args:
page: CDP 页面对象。
... ... @@ -38,6 +38,21 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None:
PublishError: 发布失败。
UploadTimeoutError: 上传/处理超时。
"""
fill_publish_video_form(page, content)
click_publish_video_button(page)
def fill_publish_video_form(page: Page, content: PublishVideoContent) -> None:
"""填写视频发布表单,不点击发布按钮。
Args:
page: CDP 页面对象。
content: 视频发布内容。
Raises:
PublishError: 填写失败。
UploadTimeoutError: 上传/处理超时。
"""
if not content.video_path:
raise PublishError("视频不能为空")
... ... @@ -51,8 +66,8 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None:
# 上传视频
_upload_video(page, content.video_path)
# 提交
_submit_publish_video(
# 填写表单(不点击发布)
_fill_publish_video_form(
page,
content.title,
content.content,
... ... @@ -62,6 +77,18 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None:
)
def click_publish_video_button(page: Page) -> None:
"""点击视频发布按钮。
Args:
page: CDP 页面对象。
"""
_wait_for_publish_button_clickable(page)
page.click_element(PUBLISH_BUTTON)
time.sleep(3)
logger.info("视频发布完成")
def _upload_video(page: Page, video_path: str) -> None:
"""上传视频文件。"""
if not os.path.exists(video_path):
... ... @@ -104,7 +131,7 @@ def _wait_for_publish_button_clickable(page: Page) -> None:
raise UploadTimeoutError("等待发布按钮可点击超时(10分钟)")
def _submit_publish_video(
def _fill_publish_video_form(
page: Page,
title: str,
content: str,
... ... @@ -112,7 +139,7 @@ def _submit_publish_video(
schedule_time: str | None,
visibility: str,
) -> None:
"""填写视频表单并提交。"""
"""填写视频表单(不点击发布)。"""
# 标题
page.input_text(TITLE_INPUT, title)
time.sleep(1)
... ... @@ -136,13 +163,7 @@ def _submit_publish_video(
# 可见范围
_set_visibility(page, visibility)
# 等待发布按钮可点击
_wait_for_publish_button_clickable(page)
# 点击发布
page.click_element(PUBLISH_BUTTON)
time.sleep(3)
logger.info("视频发布完成")
logger.info("视频表单填写完成,等待确认发布")
def _js_str(s: str) -> str:
... ...
... ... @@ -64,5 +64,16 @@ TAG_FIRST_ITEM = ".item"
# 弹窗
POPOVER = "div.d-popover"
# ========== 写长文模式 ==========
# 注意: 长文模式的按钮(写长文、新的创作、一键排版、下一步)通过文本匹配定位
LONG_ARTICLE_TAB_TEXT = "写长文"
NEW_CREATION_BUTTON_TEXT = "新的创作"
AUTO_FORMAT_BUTTON_TEXT = "一键排版"
NEXT_STEP_BUTTON_TEXT = "下一步"
LONG_ARTICLE_TITLE = 'textarea.d-text[placeholder="输入标题"]'
TEMPLATE_CARD = ".template-card"
TEMPLATE_TITLE = ".template-card .template-title"
# ========== 用户主页 ==========
SIDEBAR_PROFILE = "div.main-container li.user.side-bar-component a.link-wrapper span.channel"
... ...
---
name: xhs-publish
description: |
小红书内容发布技能。支持图文发布、视频发布、定时发布、标签、可见性设置。
当用户要求发布内容到小红书、上传图文、上传视频时触发。
小红书内容发布技能。支持图文发布、视频发布、长文发布、定时发布、标签、可见性设置。
当用户要求发布内容到小红书、上传图文、上传视频、发长文时触发。
---
# 小红书内容发布
... ... @@ -13,23 +13,25 @@ description: |
按优先级判断:
1. 用户已提供 `标题 + 正文 + 视频(本地路径)`:直接进入视频发布流程。
2. 用户已提供 `标题 + 正文 + 图片(本地路径或 URL)`:直接进入图文发布流程。
3. 用户只提供网页 URL:先用 WebFetch 提取内容和图片,再给出可发布草稿等待确认。
4. 信息不全:先补齐缺失信息,不要直接发布。
1. 用户说"发长文 / 写长文 / 长文模式":进入 **长文发布流程(流程 B)**
2. 用户已提供 `标题 + 正文 + 视频(本地路径)`:进入 **视频发布流程(流程 A.2)**
3. 用户已提供 `标题 + 正文 + 图片(本地路径或 URL)`:进入 **图文发布流程(流程 A.1)**
4. 用户只提供网页 URL:先用 WebFetch 提取内容和图片,再给出可发布草稿等待确认。
5. 信息不全:先补齐缺失信息,不要直接发布。
## 必做约束
- **发布前必须让用户确认最终标题、正文和图片/视频**
- **推荐使用分步发布**:先 fill → 用户确认 → 再 click-publish。
- 图文发布时,没有图片不得发布。
- 视频发布时,没有视频不得发布。图片和视频不可混合(二选一)。
- 标题长度不超过 20(UTF-16 编码计算,中文字符计 1,英文/数字/空格计 1)。
- 如果使用文件路径,必须使用绝对路径,禁止相对路径。
- 需要先有运行中的 Chrome,且已登录。
## 工作流程
## 流程 A: 图文/视频发布
### Step 1: 处理内容
### Step A.1: 处理内容
#### 完整内容模式
直接使用用户提供的标题和正文。
... ... @@ -40,7 +42,7 @@ description: |
3. 适当总结内容,保持语言自然、适合小红书阅读习惯。
4. 如果提取不到图片,告知用户手动获取。
### Step 2: 内容检查
### Step A.2: 内容检查
#### 标题检查
标题长度必须 ≤ 20(UTF-16 编码长度)。如果超长,自动生成符合长度的新标题。
... ... @@ -50,25 +52,68 @@ description: |
- 简体中文,语言自然。
- 话题标签放在正文最后一行,格式:`#标签1 #标签2 #标签3`
### Step 3: 用户确认
### Step A.3: 用户确认
通过 `AskUserQuestion` 展示即将发布的内容(标题、正文、图片/视频),获得明确确认后继续。
### Step 4: 写入临时文件
### Step A.4: 写入临时文件
将标题和正文写入 UTF-8 文本文件。不要在命令行参数中内联中文文本。
### Step 5: 执行发布
### Step A.5: 执行发布(推荐分步方式)
#### 图文发布
#### 分步发布(推荐)
先填写表单,让用户在浏览器中确认预览后再发布:
```bash
# 步骤 1: 填写图文表单(不发布)
python scripts/cli.py fill-publish \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg" \
[--tags "标签1" "标签2"] \
[--schedule-at "2026-03-10T12:00:00"] \
[--original] [--visibility "公开可见"]
# 步骤 2: 通过 AskUserQuestion 让用户确认浏览器中的预览
# 步骤 3: 点击发布
python scripts/cli.py click-publish
```
视频分步发布:
```bash
# 使用 CLI 直接发布
# 步骤 1: 填写视频表单(不发布)
python scripts/cli.py fill-publish-video \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--video "/abs/path/video.mp4" \
[--tags "标签1" "标签2"] \
[--visibility "公开可见"]
# 步骤 2: 用户确认
# 步骤 3: 点击发布
python scripts/cli.py click-publish
```
#### 一步到位发布(快捷方式)
```bash
# 图文一步到位
python scripts/cli.py publish \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg"
# 视频一步到位
python scripts/cli.py publish-video \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--video "/abs/path/video.mp4"
# 带标签和定时发布
python scripts/cli.py publish \
--title-file /tmp/xhs_title.txt \
... ... @@ -77,31 +122,30 @@ python scripts/cli.py publish \
--tags "标签1" "标签2" \
--schedule-at "2026-03-10T12:00:00" \
--original
# 使用发布流水线(含图片下载和登录检查)
python scripts/publish_pipeline.py \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--images "https://example.com/pic1.jpg" "/abs/path/pic2.jpg"
```
#### 视频发布
#### Headless 模式(无头自动降级)
```bash
python scripts/cli.py publish-video \
# 使用 --headless 参数,未登录时自动切换到有窗口模式
python scripts/cli.py publish --headless \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--video "/abs/path/video.mp4"
--images "/abs/path/pic1.jpg"
# 带标签和可见性
python scripts/cli.py publish-video \
# 发布流水线(含图片下载和登录检查 + 自动降级)
python scripts/publish_pipeline.py --headless \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--video "/abs/path/video.mp4" \
--tags "标签1" "标签2" \
--visibility "公开"
--images "https://example.com/pic1.jpg" "/abs/path/pic2.jpg"
```
`--headless` + 未登录时,脚本会:
1. 关闭无头 Chrome
2. 以有窗口模式重新启动 Chrome
3. 返回 JSON 包含 `"action": "switched_to_headed"`
4. 提示用户在浏览器中扫码登录
#### 指定账号/远程 Chrome
```bash
... ... @@ -118,15 +162,65 @@ python scripts/cli.py --host 10.0.0.12 --port 9222 publish \
--images "/abs/path/pic1.jpg"
```
### Step 6: 处理输出
## 流程 B: 长文发布
- **Exit code 0**:发布成功。输出 JSON 包含 `success`, `title`, `images`/`video`, `status`
- **Exit code 1**:未登录,提示用户先登录(参考 xhs-auth)。
- **Exit code 2**:错误,报告 JSON 中的 `error` 字段。
当用户说"发长文 / 写长文 / 长文模式"时触发。长文模式使用小红书的长文编辑器,支持排版模板。
### Step B.1: 准备长文内容
收集标题和正文。长文标题使用 textarea 输入,没有 20 字限制(但建议简洁)。
### Step B.2: 用户确认标题和正文
通过 `AskUserQuestion` 确认长文内容。
### Step B.3: 写入临时文件并执行长文模式
```bash
python scripts/cli.py long-article \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
[--images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg"]
```
该命令会:
1. 导航到发布页
2. 点击"写长文" tab
3. 点击"新的创作"
4. 填写标题和正文
5. 点击"一键排版"
6. 返回 JSON 包含 `templates` 列表
### Step 7: 报告结果
### Step B.4: 选择排版模板
根据输出告知用户发布是否成功。
通过 `AskUserQuestion` 展示可用模板列表,让用户选择:
```bash
python scripts/cli.py select-template --name "用户选择的模板名"
```
### Step B.5: 进入发布页
```bash
# 点击下一步,填写发布页描述(正文摘要,不超过 1000 字)
python scripts/cli.py next-step \
--content-file /tmp/xhs_description.txt
```
注意:发布页的描述编辑器是独立的,需要单独填入内容。如果描述超过 1000 字,脚本会自动截断到 800 字。
### Step B.6: 用户确认并发布
```bash
# 用户在浏览器中确认预览后
python scripts/cli.py click-publish
```
## 处理输出
- **Exit code 0**:成功。输出 JSON 包含 `success`, `title`, `images`/`video`/`templates`, `status`
- **Exit code 1**:未登录,提示用户先登录(参考 xhs-auth)。若使用 `--headless` 且自动降级,JSON 中 `action` 为 `switched_to_headed`
- **Exit code 2**:错误,报告 JSON 中的 `error` 字段。
## 常用参数
... ... @@ -140,14 +234,16 @@ python scripts/cli.py --host 10.0.0.12 --port 9222 publish \
| `--schedule-at ISO8601` | 定时发布时间 |
| `--original` | 声明原创 |
| `--visibility` | 可见范围 |
| `--headless` | 无头模式(未登录自动降级到有窗口模式) |
| `--host HOST` | 远程 CDP 主机 |
| `--port PORT` | CDP 端口(默认 9222) |
| `--account name` | 指定账号 |
## 失败处理
- **登录失败**:提示用户重新扫码登录并重试。
- **登录失败**:提示用户重新扫码登录并重试。使用 `--headless` 时会自动降级到有窗口模式。
- **图片下载失败**:提示更换图片 URL 或改用本地图片。
- **视频处理超时**:视频上传后需等待处理(最长 10 分钟),超时后提示重试。
- **标题过长**:自动缩短标题,保持语义。
- **页面选择器失效**:提示检查脚本中的选择器定义。
- **模板加载超时**:长文模式下模板可能加载缓慢,等待 15 秒后超时。
... ...