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/ @@ -25,10 +25,11 @@ xiaohongshu-skills/
25 │ │ ├── user_profile.py # 用户主页 25 │ │ ├── user_profile.py # 用户主页
26 │ │ ├── comment.py # 评论、回复 26 │ │ ├── comment.py # 评论、回复
27 │ │ ├── like_favorite.py # 点赞、收藏 27 │ │ ├── like_favorite.py # 点赞、收藏
28 -│ │ ├── publish.py # 图文发布  
29 -│ │ └── publish_video.py # 视频发布  
30 -│ ├── cli.py # 统一 CLI 入口(13 个子命令)  
31 -│ ├── chrome_launcher.py # Chrome 进程管理 28 +│ │ ├── publish.py # 图文发布(fill + click 分步支持)
  29 +│ │ ├── publish_video.py # 视频发布(fill + click 分步支持)
  30 +│ │ └── publish_long_article.py # 长文发布(模板选择 + 排版)
  31 +│ ├── cli.py # 统一 CLI 入口(19 个子命令)
  32 +│ ├── chrome_launcher.py # Chrome 进程管理(含 restart 降级)
32 │ ├── account_manager.py # 多账号管理 33 │ ├── account_manager.py # 多账号管理
33 │ ├── image_downloader.py # 媒体下载(SHA256 缓存) 34 │ ├── image_downloader.py # 媒体下载(SHA256 缓存)
34 │ ├── title_utils.py # UTF-16 标题长度计算 35 │ ├── title_utils.py # UTF-16 标题长度计算
@@ -72,7 +73,7 @@ uv run pytest # 运行测试 @@ -72,7 +73,7 @@ uv run pytest # 运行测试
72 1. **scripts/ — Python CDP 引擎** 73 1. **scripts/ — Python CDP 引擎**
73 - 基于 xiaohongshu-mcp Go 源码从零重写 74 - 基于 xiaohongshu-mcp Go 源码从零重写
74 - `xhs/` 包:模块化的核心自动化库 75 - `xhs/` 包:模块化的核心自动化库
75 - - `cli.py`:统一 CLI 入口,13 个子命令对应 MCP 工具 76 + - `cli.py`:统一 CLI 入口,19 个子命令(13 个 MCP + 6 个增强)
76 - JSON 结构化输出,便于 agent 解析 77 - JSON 结构化输出,便于 agent 解析
77 - 多账号支持,独立 Chrome Profile 隔离 78 - 多账号支持,独立 Chrome Profile 隔离
78 - 反检测保护(stealth flags + JS 注入) 79 - 反检测保护(stealth flags + JS 注入)
@@ -123,11 +124,11 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima @@ -123,11 +124,11 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima
123 124
124 - **xiaohongshu-mcp Go 源码**: /Users/zy/src/zy/xiaohongshu-mcp/ 125 - **xiaohongshu-mcp Go 源码**: /Users/zy/src/zy/xiaohongshu-mcp/
125 126
126 -## MCP 工具对照表 127 +## CLI 子命令对照表
127 128
128 -scripts/cli.py 的 13 个子命令对应 xiaohongshu-mcp 的 MCP 工具 129 +scripts/cli.py 的 19 个子命令
129 130
130 -| CLI 子命令 | MCP 工具 | 分类 | 131 +| CLI 子命令 | 对应 MCP 工具 | 分类 |
131 |--|--|--| 132 |--|--|--|
132 | `check-login` | check_login_status | 认证 | 133 | `check-login` | check_login_status | 认证 |
133 | `login` | get_login_qrcode | 认证 | 134 | `login` | get_login_qrcode | 认证 |
@@ -142,3 +143,9 @@ scripts/cli.py 的 13 个子命令对应 xiaohongshu-mcp 的 MCP 工具: @@ -142,3 +143,9 @@ scripts/cli.py 的 13 个子命令对应 xiaohongshu-mcp 的 MCP 工具:
142 | `favorite-feed` | favorite_feed | 互动 | 143 | `favorite-feed` | favorite_feed | 互动 |
143 | `publish` | publish_content | 发布 | 144 | `publish` | publish_content | 发布 |
144 | `publish-video` | publish_with_video | 发布 | 145 | `publish-video` | publish_with_video | 发布 |
  146 +| `fill-publish` | — | 分步发布(图文填写) |
  147 +| `fill-publish-video` | — | 分步发布(视频填写) |
  148 +| `click-publish` | — | 分步发布(点击发布) |
  149 +| `long-article` | — | 长文发布(填写+排版) |
  150 +| `select-template` | — | 长文发布(选择模板) |
  151 +| `next-step` | — | 长文发布(下一步+描述) |
1 -# 小红书 Skills 开发任务 1 +# 小红书 Skills P0 增强任务
2 2
3 ## 目标 3 ## 目标
4 4
5 -基于 xiaohongshu-mcp Go 源码,从零重写 Python CDP 引擎,为 OpenClaw 生态构建完整的小红书自动化 Skills 5 +在现有 13 个 MCP 工具基础上,补充 3 项 P0 能力:写长文发布模式、Headless 自动降级、分步 CLI 命令
6 6
7 ## 参考资料 7 ## 参考资料
8 8
9 -- **xiaohongshu-mcp Go 源码**: `/Users/zy/src/zy/xiaohongshu-mcp/` — 10k stars,13 个 MCP 工具  
10 -- **xiaohongshu-mcp 数据结构**: `/Users/zy/src/zy/xiaohongshu-mcp/xiaohongshu/types.go`  
11 -- **xiaohongshu-mcp 工具定义**: `/Users/zy/src/zy/xiaohongshu-mcp/mcp_server.go` 9 +- **xiaohongshu-mcp/skills Python 实现**(主要参考):
  10 + - `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/scripts/cdp_publish.py` — 长文发布核心逻辑
  11 + - `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/scripts/publish_pipeline.py` — headless 降级逻辑
  12 + - `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/scripts/chrome_launcher.py` — Chrome 重启/模式切换
  13 + - `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/SKILL.md` — 长文工作流 SKILL 定义
  14 + - `/Users/zy/src/zy/xiaohongshu-mcp/skills/post-to-xhs/references/publish-workflow.md` — DOM 选择器参考
12 15
13 -## 架构 16 +- **当前项目代码**: `/Users/zy/src/zy/00_autoclaw/xiaohongshu-skills/scripts/`
14 17
15 -### 模块结构 18 +## 代码规范
16 19
  20 +- `uv run ruff check .` 无错误
  21 +- `uv run ruff format --check .` 无差异
  22 +- 完整 type hints,`from __future__ import annotations`
  23 +- 公共函数有 docstring
  24 +- 行长度 ≤ 100
  25 +- 异常继承 `XHSError`
  26 +- JSON 输出 `ensure_ascii=False`
  27 +- Exit code: 0=成功,1=未登录,2=错误
  28 +
  29 +## 任务拆解
  30 +
  31 +### Task 1: 写长文发布模式
  32 +
  33 +参考 `cdp_publish.py` 的 `publish_long_article()`、`get_template_names()`、`select_template()`、`click_next_and_prepare_publish()` 方法。
  34 +
  35 +#### 1.1 新增选择器(`xhs/selectors.py`)
  36 +
  37 +添加长文模式相关的 CSS 选择器:
  38 +- `LONG_ARTICLE_TAB` — "写长文" tab
  39 +- `NEW_CREATION_BUTTON` — "新的创作" 按钮
  40 +- `LONG_ARTICLE_TITLE` — 长文标题 textarea
  41 +- `AUTO_FORMAT_BUTTON` — "一键排版" 按钮
  42 +- `TEMPLATE_CARD` — 模板卡片
  43 +- `TEMPLATE_TITLE` — 模板名称
  44 +- `NEXT_STEP_BUTTON` — "下一步" 按钮
  45 +
  46 +参考 reference `publish-workflow.md` 中的 DOM 选择器参考表。
  47 +
  48 +#### 1.2 新增长文发布模块(`xhs/publish_long_article.py`)
  49 +
  50 +创建独立模块,包含以下函数:
  51 +
  52 +```python
  53 +def publish_long_article(page, title, content, image_paths=None) -> list[str]:
  54 + """长文发布:导航 → 点击写长文 → 新的创作 → 填写标题正文 → 一键排版。
  55 + 返回可用模板名称列表。"""
  56 +
  57 +def get_template_names(page) -> list[str]:
  58 + """获取当前可用的排版模板名称列表。"""
  59 +
  60 +def select_template(page, template_name) -> bool:
  61 + """选择指定名称的排版模板。"""
  62 +
  63 +def click_next_and_fill_description(page, description) -> None:
  64 + """点击下一步,进入发布页并填写正文描述。
  65 + 注意:发布页有独立的正文编辑器,需单独填入。
  66 + 如果 description 超过 1000 字,应压缩到 800 字左右。"""
  67 +```
  68 +
  69 +#### 1.3 新增 CLI 子命令(`cli.py`)
  70 +
  71 +添加 3 个子命令:
  72 +
  73 +```bash
  74 +# 长文模式:填写内容 + 一键排版,返回模板列表
  75 +python scripts/cli.py long-article \
  76 + --title-file T --content-file C [--images P1 P2]
  77 +
  78 +# 选择模板
  79 +python scripts/cli.py select-template --name "模板名"
  80 +
  81 +# 点击下一步 + 填写发布页描述
  82 +python scripts/cli.py next-step --content-file C
  83 +```
  84 +
  85 +#### 1.4 更新 SKILL.md
  86 +
  87 +`skills/xhs-publish/SKILL.md` 中添加写长文模式的完整工作流:
  88 +- 输入判断:用户说"发长文 / 写长文 / 长文模式"时触发
  89 +- Step B.1-B.5 的工作流
  90 +- 模板选择通过 AskUserQuestion 让用户选
  91 +
  92 +### Task 2: Headless 自动降级
  93 +
  94 +参考 `publish_pipeline.py` 的登录检查 + 模式切换逻辑,以及 `chrome_launcher.py` 的 `restart_chrome()`
  95 +
  96 +#### 2.1 增强 `chrome_launcher.py`
  97 +
  98 +添加 `restart_chrome()` 函数:
  99 +- 关闭当前 Chrome 实例
  100 +- 以新模式(headless 或 headed)重新启动
  101 +- 等待端口就绪
  102 +
  103 +#### 2.2 增强 `publish_pipeline.py`
  104 +
  105 +`run_publish_pipeline()` 中加入降级逻辑:
  106 +
  107 +```
  108 +检查登录 → 如果未登录且是 headless 模式:
  109 + 1. 关闭无头 Chrome
  110 + 2. 以有窗口模式重新启动 Chrome
  111 + 3. 打开登录页
  112 + 4. 返回 {"success": false, "error": "未登录", "action": "switched_to_headed", "message": "已切换到有窗口模式,请在浏览器中扫码登录"}
  113 + 5. exit code 1
17 ``` 114 ```
18 -scripts/  
19 -├── xhs/ # 核心 XHS 自动化包  
20 -│ ├── cdp.py # CDP WebSocket 客户端  
21 -│ ├── stealth.py # 反检测 JS 注入 + Chrome 启动参数  
22 -│ ├── cookies.py # Cookie 文件持久化  
23 -│ ├── types.py # 数据类型(dataclass)  
24 -│ ├── errors.py # 异常体系  
25 -│ ├── selectors.py # CSS 选择器常量  
26 -│ ├── urls.py # URL 常量  
27 -│ ├── human.py # 人类行为模拟  
28 -│ ├── login.py # 登录  
29 -│ ├── feeds.py # 首页 Feed  
30 -│ ├── search.py # 搜索 + 筛选  
31 -│ ├── feed_detail.py # 笔记详情 + 评论加载  
32 -│ ├── user_profile.py # 用户主页  
33 -│ ├── comment.py # 评论、回复  
34 -│ ├── like_favorite.py # 点赞、收藏  
35 -│ ├── publish.py # 图文发布  
36 -│ └── publish_video.py # 视频发布  
37 -├── cli.py # 统一 CLI 入口(13 个子命令)  
38 -├── chrome_launcher.py # Chrome 进程管理  
39 -├── account_manager.py # 多账号管理  
40 -├── image_downloader.py # 媒体下载(SHA256 缓存)  
41 -├── title_utils.py # UTF-16 标题长度计算  
42 -├── run_lock.py # 单实例锁  
43 -└── publish_pipeline.py # 发布编排器 115 +
  116 +#### 2.3 新增 CLI 参数
  117 +
  118 +`publish` 和 `publish-video` 子命令添加 `--headless` 参数:
  119 +
  120 +```bash
  121 +python scripts/cli.py publish --headless \
  122 + --title-file T --content-file C --images P1 P2
44 ``` 123 ```
45 124
46 -### CLI 接口(对应 Go 的 13 个 MCP 工具) 125 +`--headless` + 未登录时,自动降级到有窗口模式。
  126 +
  127 +### Task 3: 分步 CLI 命令
  128 +
  129 +参考 `cdp_publish.py` 的 `fill`、`click-publish` 子命令设计。目标是让 agent 可以在填写表单和点击发布之间插入用户确认步骤。
  130 +
  131 +#### 3.1 新增 CLI 子命令
47 132
48 ```bash 133 ```bash
49 -python scripts/cli.py check-login  
50 -python scripts/cli.py login  
51 -python scripts/cli.py delete-cookies  
52 -python scripts/cli.py list-feeds  
53 -python scripts/cli.py search-feeds --keyword "关键词" [--sort-by --note-type ...]  
54 -python scripts/cli.py get-feed-detail --feed-id ID --xsec-token TOKEN [--load-all-comments]  
55 -python scripts/cli.py user-profile --user-id ID --xsec-token TOKEN  
56 -python scripts/cli.py post-comment --feed-id ID --xsec-token TOKEN --content "内容"  
57 -python scripts/cli.py reply-comment --feed-id ID --xsec-token TOKEN --content "内容" [--comment-id | --user-id]  
58 -python scripts/cli.py like-feed --feed-id ID --xsec-token TOKEN [--unlike]  
59 -python scripts/cli.py favorite-feed --feed-id ID --xsec-token TOKEN [--unfavorite]  
60 -python scripts/cli.py publish --title-file T --content-file C --images P1 P2 [--tags --schedule-at --visibility]  
61 -python scripts/cli.py publish-video --title-file T --content-file C --video P [--tags --schedule-at] 134 +# 只填写表单,不发布(图文模式)
  135 +python scripts/cli.py fill-publish \
  136 + --title-file T --content-file C --images P1 P2 \
  137 + [--tags --schedule-at --visibility --original]
  138 +
  139 +# 只填写表单,不发布(视频模式)
  140 +python scripts/cli.py fill-publish-video \
  141 + --title-file T --content-file C --video P \
  142 + [--tags --schedule-at --visibility]
  143 +
  144 +# 点击发布按钮(在用户确认后调用)
  145 +python scripts/cli.py click-publish
62 ``` 146 ```
63 147
64 -全局选项:`--host`, `--port`, `--account`  
65 -输出:JSON(`ensure_ascii=False`  
66 -退出码:0=成功,1=未登录,2=错误 148 +#### 3.2 拆分现有 publish 逻辑
  149 +
  150 +`xhs/publish.py` 中将 `publish_image_content()` 拆分为:
  151 +- `fill_publish_form(page, content)` — 导航、上传、填写表单,**不点击发布**
  152 +- `click_publish_button(page)` — 仅点击发布按钮
  153 +
  154 +`publish_image_content()` 保持不变(内部调用两者),向后兼容。
  155 +
  156 +同理拆分 `xhs/publish_video.py`
  157 +
  158 +#### 3.3 更新 SKILL.md
  159 +
  160 +`skills/xhs-publish/SKILL.md` 中:
  161 +- 推荐的发布流程改为:fill → 用户通过 AskUserQuestion 确认 → click-publish
  162 +- 保留一步到位的 `publish` 命令作为快捷方式
67 163
68 -## 代码规范要求 164 +### Task 4: 验证 + 收尾
69 165
70 -- Python 代码必须通过 `ruff check` 和 `ruff format`  
71 -- 完整的 type hints(PEP 484),使用 `str | None` 而非 `Optional[str]`  
72 -- 公共函数和类必须有 docstring  
73 -- 行长度上限 100 字符  
74 -- 使用 `from __future__ import annotations` 启用延迟注解  
75 -- 异常类统一继承自 `XHSError`  
76 -- CLI 使用 argparse,exit code: 0=成功,1=未登录,2=错误  
77 -- JSON 输出使用 `ensure_ascii=False` 保留中文 166 +- `uv run ruff check .` 无错误
  167 +- `uv run ruff format --check .` 无差异
  168 +- 所有新增 CLI 子命令 `--help` 正常输出
  169 +- `skills/xhs-publish/SKILL.md` 包含长文模式和分步发布的完整工作流
  170 +- `CLAUDE.md` 的 MCP 工具对照表更新(新增子命令)
78 171
79 ## 完成标志 172 ## 完成标志
80 173
81 当以下条件全部满足时,输出完成标志: 174 当以下条件全部满足时,输出完成标志:
82 -1. `xhs/` 包 17 个模块已全部创建  
83 -2. `cli.py` 13 个子命令已实现  
84 -3. 5 个支撑脚本已重写  
85 -4. 5 个 `skills/*/SKILL.md` 已更新  
86 -5. 根目录 `SKILL.md`、`CLAUDE.md`、`README.md` 已更新 175 +1. `xhs/publish_long_article.py` 已创建,含 4 个核心函数
  176 +2. `cli.py` 新增 6 个子命令:`long-article`, `select-template`, `next-step`, `fill-publish`, `fill-publish-video`, `click-publish`
  177 +3. `chrome_launcher.py` 含 `restart_chrome()` 函数
  178 +4. `publish_pipeline.py` 含 headless 自动降级逻辑
  179 +5. `skills/xhs-publish/SKILL.md` 含长文模式和分步发布工作流
87 6. `uv run ruff check .` 无错误 180 6. `uv run ruff check .` 无错误
88 7. `uv run ruff format --check .` 无差异 181 7. `uv run ruff format --check .` 无差异
89 182
90 -<promise>ALL SKILLS COMPLETE</promise> 183 +<promise>P0 ENHANCE COMPLETE</promise>
@@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
2 2
3 from __future__ import annotations 3 from __future__ import annotations
4 4
  5 +import json
5 import logging 6 import logging
6 import os 7 import os
7 import platform 8 import platform
@@ -139,6 +140,85 @@ def is_chrome_running(port: int = DEFAULT_PORT) -> bool: @@ -139,6 +140,85 @@ def is_chrome_running(port: int = DEFAULT_PORT) -> bool:
139 return False 140 return False
140 141
141 142
  143 +def kill_chrome(port: int = DEFAULT_PORT) -> None:
  144 + """关闭指定端口的 Chrome 实例。
  145 +
  146 + 尝试通过 CDP Browser.close 命令关闭,失败则使用进程信号。
  147 +
  148 + Args:
  149 + port: Chrome 调试端口。
  150 + """
  151 + import requests
  152 +
  153 + # 策略1: 通过 CDP 关闭
  154 + try:
  155 + resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
  156 + if resp.status_code == 200:
  157 + ws_url = resp.json().get("webSocketDebuggerUrl")
  158 + if ws_url:
  159 + import websockets.sync.client
  160 +
  161 + ws = websockets.sync.client.connect(ws_url)
  162 + ws.send(json.dumps({"id": 1, "method": "Browser.close"}))
  163 + ws.close()
  164 + logger.info("通过 CDP Browser.close 关闭 Chrome (port=%d)", port)
  165 + time.sleep(1)
  166 + return
  167 + except Exception:
  168 + pass
  169 +
  170 + # 策略2: 通过 lsof 查找并 kill 进程
  171 + try:
  172 + result = subprocess.run(
  173 + ["lsof", "-ti", f":{port}"],
  174 + capture_output=True,
  175 + text=True,
  176 + timeout=5,
  177 + )
  178 + if result.returncode == 0 and result.stdout.strip():
  179 + import contextlib
  180 +
  181 + pids = result.stdout.strip().split("\n")
  182 + for pid in pids:
  183 + with contextlib.suppress(OSError, ValueError):
  184 + os.kill(int(pid), signal.SIGTERM)
  185 + logger.info("通过 SIGTERM 关闭 Chrome 进程 (port=%d)", port)
  186 + time.sleep(1)
  187 + return
  188 + except Exception:
  189 + pass
  190 +
  191 + logger.warning("未能关闭 Chrome (port=%d)", port)
  192 +
  193 +
  194 +def restart_chrome(
  195 + port: int = DEFAULT_PORT,
  196 + headless: bool = False,
  197 + user_data_dir: str | None = None,
  198 + chrome_bin: str | None = None,
  199 +) -> subprocess.Popen:
  200 + """重启 Chrome:关闭当前实例后以新模式重新启动。
  201 +
  202 + Args:
  203 + port: 远程调试端口。
  204 + headless: 是否无头模式。
  205 + user_data_dir: 用户数据目录。
  206 + chrome_bin: Chrome 可执行文件路径。
  207 +
  208 + Returns:
  209 + 新的 Chrome 子进程。
  210 + """
  211 + logger.info("重启 Chrome: port=%d, headless=%s", port, headless)
  212 + kill_chrome(port)
  213 + time.sleep(1)
  214 + return launch_chrome(
  215 + port=port,
  216 + headless=headless,
  217 + user_data_dir=user_data_dir,
  218 + chrome_bin=chrome_bin,
  219 + )
  220 +
  221 +
142 def _wait_for_chrome(port: int, timeout: float = 15.0) -> None: 222 def _wait_for_chrome(port: int, timeout: float = 15.0) -> None:
143 """等待 Chrome 调试端口就绪。""" 223 """等待 Chrome 调试端口就绪。"""
144 deadline = time.monotonic() + timeout 224 deadline = time.monotonic() + timeout
@@ -35,6 +35,23 @@ def _connect(args: argparse.Namespace): @@ -35,6 +35,23 @@ def _connect(args: argparse.Namespace):
35 return browser, page 35 return browser, page
36 36
37 37
  38 +def _headless_fallback(port: int) -> None:
  39 + """Headless 模式未登录时自动降级到有窗口模式。"""
  40 + from chrome_launcher import restart_chrome
  41 +
  42 + logger.info("Headless 模式未登录,切换到有窗口模式...")
  43 + restart_chrome(port=port, headless=False)
  44 + _output(
  45 + {
  46 + "success": False,
  47 + "error": "未登录",
  48 + "action": "switched_to_headed",
  49 + "message": "已切换到有窗口模式,请在浏览器中扫码登录",
  50 + },
  51 + exit_code=1,
  52 + )
  53 +
  54 +
38 # ========== 子命令实现 ========== 55 # ========== 子命令实现 ==========
39 56
40 57
@@ -234,6 +251,7 @@ def cmd_favorite_feed(args: argparse.Namespace) -> None: @@ -234,6 +251,7 @@ def cmd_favorite_feed(args: argparse.Namespace) -> None:
234 def cmd_publish(args: argparse.Namespace) -> None: 251 def cmd_publish(args: argparse.Namespace) -> None:
235 """发布图文内容。""" 252 """发布图文内容。"""
236 from image_downloader import process_images 253 from image_downloader import process_images
  254 + from xhs.login import check_login_status
237 from xhs.publish import publish_image_content 255 from xhs.publish import publish_image_content
238 from xhs.types import PublishImageContent 256 from xhs.types import PublishImageContent
239 257
@@ -250,6 +268,14 @@ def cmd_publish(args: argparse.Namespace) -> None: @@ -250,6 +268,14 @@ def cmd_publish(args: argparse.Namespace) -> None:
250 268
251 browser, page = _connect(args) 269 browser, page = _connect(args)
252 try: 270 try:
  271 + # headless 模式登录检查 + 自动降级
  272 + headless = getattr(args, "headless", False)
  273 + if headless and not check_login_status(page):
  274 + browser.close_page(page)
  275 + browser.close()
  276 + _headless_fallback(args.port)
  277 + return
  278 +
253 publish_image_content( 279 publish_image_content(
254 page, 280 page,
255 PublishImageContent( 281 PublishImageContent(
@@ -268,8 +294,164 @@ def cmd_publish(args: argparse.Namespace) -> None: @@ -268,8 +294,164 @@ def cmd_publish(args: argparse.Namespace) -> None:
268 browser.close() 294 browser.close()
269 295
270 296
  297 +def cmd_fill_publish(args: argparse.Namespace) -> None:
  298 + """只填写图文表单,不发布。"""
  299 + from image_downloader import process_images
  300 + from xhs.publish import fill_publish_form
  301 + from xhs.types import PublishImageContent
  302 +
  303 + with open(args.title_file, encoding="utf-8") as f:
  304 + title = f.read().strip()
  305 + with open(args.content_file, encoding="utf-8") as f:
  306 + content = f.read().strip()
  307 +
  308 + image_paths = process_images(args.images) if args.images else []
  309 + if not image_paths:
  310 + _output({"success": False, "error": "没有有效的图片"}, exit_code=2)
  311 +
  312 + browser, page = _connect(args)
  313 + try:
  314 + fill_publish_form(
  315 + page,
  316 + PublishImageContent(
  317 + title=title,
  318 + content=content,
  319 + tags=args.tags or [],
  320 + image_paths=image_paths,
  321 + schedule_time=args.schedule_at,
  322 + is_original=args.original,
  323 + visibility=args.visibility or "",
  324 + ),
  325 + )
  326 + _output(
  327 + {
  328 + "success": True,
  329 + "title": title,
  330 + "images": len(image_paths),
  331 + "status": "表单已填写,等待确认发布",
  332 + }
  333 + )
  334 + finally:
  335 + browser.close_page(page)
  336 + browser.close()
  337 +
  338 +
  339 +def cmd_fill_publish_video(args: argparse.Namespace) -> None:
  340 + """只填写视频表单,不发布。"""
  341 + from xhs.publish_video import fill_publish_video_form
  342 + from xhs.types import PublishVideoContent
  343 +
  344 + with open(args.title_file, encoding="utf-8") as f:
  345 + title = f.read().strip()
  346 + with open(args.content_file, encoding="utf-8") as f:
  347 + content = f.read().strip()
  348 +
  349 + browser, page = _connect(args)
  350 + try:
  351 + fill_publish_video_form(
  352 + page,
  353 + PublishVideoContent(
  354 + title=title,
  355 + content=content,
  356 + tags=args.tags or [],
  357 + video_path=args.video,
  358 + schedule_time=args.schedule_at,
  359 + visibility=args.visibility or "",
  360 + ),
  361 + )
  362 + _output(
  363 + {
  364 + "success": True,
  365 + "title": title,
  366 + "video": args.video,
  367 + "status": "视频表单已填写,等待确认发布",
  368 + }
  369 + )
  370 + finally:
  371 + browser.close_page(page)
  372 + browser.close()
  373 +
  374 +
  375 +def cmd_click_publish(args: argparse.Namespace) -> None:
  376 + """点击发布按钮(在用户确认后调用)。"""
  377 + from xhs.publish import click_publish_button
  378 +
  379 + browser, page = _connect(args)
  380 + try:
  381 + click_publish_button(page)
  382 + _output({"success": True, "status": "发布完成"})
  383 + finally:
  384 + browser.close_page(page)
  385 + browser.close()
  386 +
  387 +
  388 +def cmd_long_article(args: argparse.Namespace) -> None:
  389 + """长文模式:填写内容 + 一键排版,返回模板列表。"""
  390 + from xhs.publish_long_article import publish_long_article
  391 +
  392 + with open(args.title_file, encoding="utf-8") as f:
  393 + title = f.read().strip()
  394 + with open(args.content_file, encoding="utf-8") as f:
  395 + content = f.read().strip()
  396 +
  397 + browser, page = _connect(args)
  398 + try:
  399 + template_names = publish_long_article(
  400 + page,
  401 + title=title,
  402 + content=content,
  403 + image_paths=args.images,
  404 + )
  405 + _output(
  406 + {
  407 + "success": True,
  408 + "templates": template_names,
  409 + "status": "长文已填写,请选择模板",
  410 + }
  411 + )
  412 + finally:
  413 + browser.close_page(page)
  414 + browser.close()
  415 +
  416 +
  417 +def cmd_select_template(args: argparse.Namespace) -> None:
  418 + """选择排版模板。"""
  419 + from xhs.publish_long_article import select_template
  420 +
  421 + browser, page = _connect(args)
  422 + try:
  423 + selected = select_template(page, args.name)
  424 + if selected:
  425 + _output({"success": True, "template": args.name, "status": "模板已选择"})
  426 + else:
  427 + _output(
  428 + {"success": False, "error": f"未找到模板: {args.name}"},
  429 + exit_code=2,
  430 + )
  431 + finally:
  432 + browser.close_page(page)
  433 + browser.close()
  434 +
  435 +
  436 +def cmd_next_step(args: argparse.Namespace) -> None:
  437 + """点击下一步 + 填写发布页描述。"""
  438 + from xhs.publish_long_article import click_next_and_fill_description
  439 +
  440 + with open(args.content_file, encoding="utf-8") as f:
  441 + description = f.read().strip()
  442 +
  443 + browser, page = _connect(args)
  444 + try:
  445 + click_next_and_fill_description(page, description)
  446 + _output({"success": True, "status": "已进入发布页,等待确认发布"})
  447 + finally:
  448 + browser.close_page(page)
  449 + browser.close()
  450 +
  451 +
271 def cmd_publish_video(args: argparse.Namespace) -> None: 452 def cmd_publish_video(args: argparse.Namespace) -> None:
272 """发布视频内容。""" 453 """发布视频内容。"""
  454 + from xhs.login import check_login_status
273 from xhs.publish_video import publish_video_content 455 from xhs.publish_video import publish_video_content
274 from xhs.types import PublishVideoContent 456 from xhs.types import PublishVideoContent
275 457
@@ -280,6 +462,14 @@ def cmd_publish_video(args: argparse.Namespace) -> None: @@ -280,6 +462,14 @@ def cmd_publish_video(args: argparse.Namespace) -> None:
280 462
281 browser, page = _connect(args) 463 browser, page = _connect(args)
282 try: 464 try:
  465 + # headless 模式登录检查 + 自动降级
  466 + headless = getattr(args, "headless", False)
  467 + if headless and not check_login_status(page):
  468 + browser.close_page(page)
  469 + browser.close()
  470 + _headless_fallback(args.port)
  471 + return
  472 +
283 publish_video_content( 473 publish_video_content(
284 page, 474 page,
285 PublishVideoContent( 475 PublishVideoContent(
@@ -396,6 +586,7 @@ def build_parser() -> argparse.ArgumentParser: @@ -396,6 +586,7 @@ def build_parser() -> argparse.ArgumentParser:
396 sub.add_argument("--schedule-at", help="定时发布 (ISO8601)") 586 sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
397 sub.add_argument("--original", action="store_true", help="声明原创") 587 sub.add_argument("--original", action="store_true", help="声明原创")
398 sub.add_argument("--visibility", help="可见范围") 588 sub.add_argument("--visibility", help="可见范围")
  589 + sub.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
399 sub.set_defaults(func=cmd_publish) 590 sub.set_defaults(func=cmd_publish)
400 591
401 # publish-video 592 # publish-video
@@ -406,8 +597,51 @@ def build_parser() -> argparse.ArgumentParser: @@ -406,8 +597,51 @@ def build_parser() -> argparse.ArgumentParser:
406 sub.add_argument("--tags", nargs="*", help="标签") 597 sub.add_argument("--tags", nargs="*", help="标签")
407 sub.add_argument("--schedule-at", help="定时发布 (ISO8601)") 598 sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
408 sub.add_argument("--visibility", help="可见范围") 599 sub.add_argument("--visibility", help="可见范围")
  600 + sub.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
409 sub.set_defaults(func=cmd_publish_video) 601 sub.set_defaults(func=cmd_publish_video)
410 602
  603 + # fill-publish(只填写图文表单,不发布)
  604 + sub = subparsers.add_parser("fill-publish", help="填写图文表单(不发布)")
  605 + sub.add_argument("--title-file", required=True, help="标题文件路径")
  606 + sub.add_argument("--content-file", required=True, help="正文文件路径")
  607 + sub.add_argument("--images", nargs="+", required=True, help="图片路径/URL")
  608 + sub.add_argument("--tags", nargs="*", help="标签")
  609 + sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
  610 + sub.add_argument("--original", action="store_true", help="声明原创")
  611 + sub.add_argument("--visibility", help="可见范围")
  612 + sub.set_defaults(func=cmd_fill_publish)
  613 +
  614 + # fill-publish-video(只填写视频表单,不发布)
  615 + sub = subparsers.add_parser("fill-publish-video", help="填写视频表单(不发布)")
  616 + sub.add_argument("--title-file", required=True, help="标题文件路径")
  617 + sub.add_argument("--content-file", required=True, help="正文文件路径")
  618 + sub.add_argument("--video", required=True, help="视频文件路径")
  619 + sub.add_argument("--tags", nargs="*", help="标签")
  620 + sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
  621 + sub.add_argument("--visibility", help="可见范围")
  622 + sub.set_defaults(func=cmd_fill_publish_video)
  623 +
  624 + # click-publish(点击发布按钮)
  625 + sub = subparsers.add_parser("click-publish", help="点击发布按钮")
  626 + sub.set_defaults(func=cmd_click_publish)
  627 +
  628 + # long-article(长文模式)
  629 + sub = subparsers.add_parser("long-article", help="长文模式:填写 + 一键排版")
  630 + sub.add_argument("--title-file", required=True, help="标题文件路径")
  631 + sub.add_argument("--content-file", required=True, help="正文文件路径")
  632 + sub.add_argument("--images", nargs="*", help="可选图片路径")
  633 + sub.set_defaults(func=cmd_long_article)
  634 +
  635 + # select-template(选择模板)
  636 + sub = subparsers.add_parser("select-template", help="选择排版模板")
  637 + sub.add_argument("--name", required=True, help="模板名称")
  638 + sub.set_defaults(func=cmd_select_template)
  639 +
  640 + # next-step(下一步 + 填写描述)
  641 + sub = subparsers.add_parser("next-step", help="点击下一步 + 填写描述")
  642 + sub.add_argument("--content-file", required=True, help="描述内容文件路径")
  643 + sub.set_defaults(func=cmd_next_step)
  644 +
411 return parser 645 return parser
412 646
413 647
@@ -29,9 +29,12 @@ def run_publish_pipeline( @@ -29,9 +29,12 @@ def run_publish_pipeline(
29 host: str = "127.0.0.1", 29 host: str = "127.0.0.1",
30 port: int = 9222, 30 port: int = 9222,
31 account: str = "", 31 account: str = "",
  32 + headless: bool = False,
32 ) -> dict: 33 ) -> dict:
33 """执行完整发布流水线。 34 """执行完整发布流水线。
34 35
  36 + 当 headless=True 且未登录时,自动降级到有窗口模式。
  37 +
35 Returns: 38 Returns:
36 发布结果字典。 39 发布结果字典。
37 """ 40 """
@@ -56,7 +59,28 @@ def run_publish_pipeline( @@ -56,7 +59,28 @@ def run_publish_pipeline(
56 try: 59 try:
57 # 登录检查 60 # 登录检查
58 if not check_login_status(page): 61 if not check_login_status(page):
59 - return {"success": False, "error": "未登录", "exit_code": 1} 62 + browser.close_page(page)
  63 + browser.close()
  64 +
  65 + # Headless 自动降级:切换到有窗口模式
  66 + if headless:
  67 + from chrome_launcher import restart_chrome
  68 +
  69 + logger.info("Headless 模式未登录,切换到有窗口模式...")
  70 + restart_chrome(port=port, headless=False)
  71 + return {
  72 + "success": False,
  73 + "error": "未登录",
  74 + "action": "switched_to_headed",
  75 + "message": "已切换到有窗口模式,请在浏览器中扫码登录",
  76 + "exit_code": 1,
  77 + }
  78 +
  79 + return {
  80 + "success": False,
  81 + "error": "未登录",
  82 + "exit_code": 1,
  83 + }
60 84
61 # 发布 85 # 发布
62 if video: 86 if video:
@@ -113,6 +137,7 @@ def main() -> None: @@ -113,6 +137,7 @@ def main() -> None:
113 parser.add_argument("--schedule-at", help="定时发布时间 (ISO8601)") 137 parser.add_argument("--schedule-at", help="定时发布时间 (ISO8601)")
114 parser.add_argument("--original", action="store_true", help="声明原创") 138 parser.add_argument("--original", action="store_true", help="声明原创")
115 parser.add_argument("--visibility", default="", help="可见范围") 139 parser.add_argument("--visibility", default="", help="可见范围")
  140 + parser.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
116 parser.add_argument("--host", default="127.0.0.1") 141 parser.add_argument("--host", default="127.0.0.1")
117 parser.add_argument("--port", type=int, default=9222) 142 parser.add_argument("--port", type=int, default=9222)
118 parser.add_argument("--account", default="") 143 parser.add_argument("--account", default="")
@@ -136,10 +161,12 @@ def main() -> None: @@ -136,10 +161,12 @@ def main() -> None:
136 host=args.host, 161 host=args.host,
137 port=args.port, 162 port=args.port,
138 account=args.account, 163 account=args.account,
  164 + headless=args.headless,
139 ) 165 )
140 166
141 print(json.dumps(result, ensure_ascii=False, indent=2)) 167 print(json.dumps(result, ensure_ascii=False, indent=2))
142 - sys.exit(0 if result["success"] else 2) 168 + exit_code = result.get("exit_code", 0 if result["success"] else 2)
  169 + sys.exit(exit_code)
143 170
144 171
145 if __name__ == "__main__": 172 if __name__ == "__main__":
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
37 37
38 38
39 def publish_image_content(page: Page, content: PublishImageContent) -> None: 39 def publish_image_content(page: Page, content: PublishImageContent) -> None:
40 - """发布图文内容。 40 + """发布图文内容(填写表单 + 点击发布)
41 41
42 Args: 42 Args:
43 page: CDP 页面对象。 43 page: CDP 页面对象。
@@ -49,6 +49,23 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None: @@ -49,6 +49,23 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None:
49 TitleTooLongError: 标题超长。 49 TitleTooLongError: 标题超长。
50 ContentTooLongError: 正文超长。 50 ContentTooLongError: 正文超长。
51 """ 51 """
  52 + fill_publish_form(page, content)
  53 + click_publish_button(page)
  54 +
  55 +
  56 +def fill_publish_form(page: Page, content: PublishImageContent) -> None:
  57 + """填写图文发布表单,不点击发布按钮。
  58 +
  59 + Args:
  60 + page: CDP 页面对象。
  61 + content: 发布内容。
  62 +
  63 + Raises:
  64 + PublishError: 填写失败。
  65 + UploadTimeoutError: 上传超时。
  66 + TitleTooLongError: 标题超长。
  67 + ContentTooLongError: 正文超长。
  68 + """
52 if not content.image_paths: 69 if not content.image_paths:
53 raise PublishError("图片不能为空") 70 raise PublishError("图片不能为空")
54 71
@@ -77,8 +94,8 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None: @@ -77,8 +94,8 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None:
77 content.visibility, 94 content.visibility,
78 ) 95 )
79 96
80 - # 提交发布  
81 - _submit_publish( 97 + # 填写表单(不点击发布)
  98 + _fill_publish_form(
82 page, 99 page,
83 content.title, 100 content.title,
84 content.content, 101 content.content,
@@ -89,6 +106,20 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None: @@ -89,6 +106,20 @@ def publish_image_content(page: Page, content: PublishImageContent) -> None:
89 ) 106 )
90 107
91 108
  109 +def click_publish_button(page: Page) -> None:
  110 + """点击发布按钮。
  111 +
  112 + Args:
  113 + page: CDP 页面对象。
  114 +
  115 + Raises:
  116 + PublishError: 点击失败。
  117 + """
  118 + page.click_element(PUBLISH_BUTTON)
  119 + time.sleep(3)
  120 + logger.info("发布完成")
  121 +
  122 +
92 # ========== 页面导航 ========== 123 # ========== 页面导航 ==========
93 124
94 125
@@ -192,7 +223,7 @@ def _wait_for_upload_complete(page: Page, expected_count: int) -> None: @@ -192,7 +223,7 @@ def _wait_for_upload_complete(page: Page, expected_count: int) -> None:
192 # ========== 表单提交 ========== 223 # ========== 表单提交 ==========
193 224
194 225
195 -def _submit_publish( 226 +def _fill_publish_form(
196 page: Page, 227 page: Page,
197 title: str, 228 title: str,
198 content: str, 229 content: str,
@@ -201,7 +232,7 @@ def _submit_publish( @@ -201,7 +232,7 @@ def _submit_publish(
201 is_original: bool, 232 is_original: bool,
202 visibility: str, 233 visibility: str,
203 ) -> None: 234 ) -> None:
204 - """填写表单并提交。""" 235 + """填写表单(不点击发布)。"""
205 # 标题 236 # 标题
206 page.input_text(TITLE_INPUT, title) 237 page.input_text(TITLE_INPUT, title)
207 time.sleep(0.5) 238 time.sleep(0.5)
@@ -240,10 +271,7 @@ def _submit_publish( @@ -240,10 +271,7 @@ def _submit_publish(
240 except Exception as e: 271 except Exception as e:
241 logger.warning("设置原创声明失败: %s", e) 272 logger.warning("设置原创声明失败: %s", e)
242 273
243 - # 点击发布  
244 - page.click_element(PUBLISH_BUTTON)  
245 - time.sleep(3)  
246 - logger.info("发布完成") 274 + logger.info("表单填写完成,等待确认发布")
247 275
248 276
249 def _find_content_element(page: Page) -> str: 277 def _find_content_element(page: Page) -> str:
  1 +"""长文发布模式,参考 cdp_publish.py 的长文工作流。"""
  2 +
  3 +from __future__ import annotations
  4 +
  5 +import json
  6 +import logging
  7 +import time
  8 +
  9 +from .cdp import Page
  10 +from .errors import PublishError
  11 +from .publish import _click_publish_tab, _find_content_element, _navigate_to_publish_page
  12 +from .selectors import (
  13 + AUTO_FORMAT_BUTTON_TEXT,
  14 + CONTENT_EDITOR,
  15 + LONG_ARTICLE_TITLE,
  16 + NEW_CREATION_BUTTON_TEXT,
  17 + NEXT_STEP_BUTTON_TEXT,
  18 + TEMPLATE_CARD,
  19 + TEMPLATE_TITLE,
  20 +)
  21 +
  22 +logger = logging.getLogger(__name__)
  23 +
  24 +# 等待常量
  25 +_AUTO_FORMAT_WAIT = 3.0
  26 +_TEMPLATE_WAIT_ROUNDS = 15
  27 +_PAGE_LOAD_WAIT = 3.0
  28 +
  29 +
  30 +def publish_long_article(
  31 + page: Page,
  32 + title: str,
  33 + content: str,
  34 + image_paths: list[str] | None = None,
  35 +) -> list[str]:
  36 + """长文发布:导航 → 点击写长文 → 新的创作 → 填写标题正文 → 一键排版。
  37 +
  38 + 返回可用模板名称列表。
  39 +
  40 + Args:
  41 + page: CDP 页面对象。
  42 + title: 长文标题。
  43 + content: 长文正文(段落用换行分隔)。
  44 + image_paths: 可选的图片路径列表(插入编辑器)。
  45 +
  46 + Returns:
  47 + 可用模板名称列表。
  48 +
  49 + Raises:
  50 + PublishError: 操作失败。
  51 + """
  52 + # 1. 导航到发布页
  53 + _navigate_to_publish_page(page)
  54 +
  55 + # 2. 点击"写长文"TAB
  56 + _click_publish_tab(page, "写长文")
  57 + time.sleep(1)
  58 +
  59 + # 3. 点击"新的创作"
  60 + _click_new_creation(page)
  61 +
  62 + # 4. 填写标题(textarea)
  63 + _fill_long_title(page, title)
  64 +
  65 + # 5. 填写正文(TipTap 编辑器)
  66 + _fill_long_content(page, content)
  67 +
  68 + # 6. 可选:插入图片到编辑器
  69 + if image_paths:
  70 + _insert_images_to_editor(page, image_paths)
  71 +
  72 + # 7. 点击"一键排版"
  73 + _click_auto_format(page)
  74 +
  75 + # 8. 等待模板加载并返回名称列表
  76 + _wait_for_templates(page)
  77 + template_names = get_template_names(page)
  78 + logger.info("模板加载完成: %s", template_names)
  79 + return template_names
  80 +
  81 +
  82 +def get_template_names(page: Page) -> list[str]:
  83 + """获取当前可用的排版模板名称列表。
  84 +
  85 + Args:
  86 + page: CDP 页面对象。
  87 +
  88 + Returns:
  89 + 模板名称列表。
  90 + """
  91 + names = page.evaluate(
  92 + f"""
  93 + (() => {{
  94 + const cards = document.querySelectorAll({json.dumps(TEMPLATE_CARD)});
  95 + const names = [];
  96 + for (const card of cards) {{
  97 + const title = card.querySelector({json.dumps(TEMPLATE_TITLE)});
  98 + names.push(title ? title.textContent.trim() : 'Template ' + names.length);
  99 + }}
  100 + return names;
  101 + }})()
  102 + """
  103 + )
  104 + return names or []
  105 +
  106 +
  107 +def select_template(page: Page, template_name: str) -> bool:
  108 + """选择指定名称的排版模板。
  109 +
  110 + Args:
  111 + page: CDP 页面对象。
  112 + template_name: 模板名称。
  113 +
  114 + Returns:
  115 + 是否成功选择。
  116 + """
  117 + clicked = page.evaluate(
  118 + f"""
  119 + (() => {{
  120 + const cards = document.querySelectorAll({json.dumps(TEMPLATE_CARD)});
  121 + for (const card of cards) {{
  122 + const title = card.querySelector({json.dumps(TEMPLATE_TITLE)});
  123 + if (title && title.textContent.trim() === {json.dumps(template_name)}) {{
  124 + card.click();
  125 + return true;
  126 + }}
  127 + }}
  128 + return false;
  129 + }})()
  130 + """
  131 + )
  132 +
  133 + if clicked:
  134 + logger.info("已选择模板: %s", template_name)
  135 + time.sleep(1)
  136 + else:
  137 + logger.warning("未找到模板: %s", template_name)
  138 +
  139 + return bool(clicked)
  140 +
  141 +
  142 +def click_next_and_fill_description(page: Page, description: str) -> None:
  143 + """点击下一步,进入发布页并填写正文描述。
  144 +
  145 + 注意:发布页有独立的正文编辑器,需单独填入。
  146 + 如果 description 超过 1000 字,应压缩到 800 字左右。
  147 +
  148 + Args:
  149 + page: CDP 页面对象。
  150 + description: 发布页正文描述。
  151 +
  152 + Raises:
  153 + PublishError: 操作失败。
  154 + """
  155 + # 点击"下一步"
  156 + _click_button_by_text(page, NEXT_STEP_BUTTON_TEXT)
  157 + time.sleep(_PAGE_LOAD_WAIT)
  158 +
  159 + # 填写发布页描述
  160 + if description:
  161 + # 截断描述到 1000 字以内
  162 + if len(description) > 1000:
  163 + description = description[:800]
  164 + logger.warning("描述超过1000字,已截断到800字")
  165 +
  166 + content_selector = _find_content_element(page)
  167 + page.input_content_editable(content_selector, description)
  168 + logger.info("已填写发布页描述")
  169 +
  170 +
  171 +# ========== 内部辅助函数 ==========
  172 +
  173 +
  174 +def _click_new_creation(page: Page) -> None:
  175 + """点击"新的创作"按钮。"""
  176 + _click_button_by_text(page, NEW_CREATION_BUTTON_TEXT)
  177 + time.sleep(2)
  178 + page.wait_dom_stable()
  179 + logger.info("已点击'新的创作'")
  180 +
  181 +
  182 +def _fill_long_title(page: Page, title: str) -> None:
  183 + """填写长文标题(textarea,需使用 native setter)。"""
  184 + page.wait_for_element(LONG_ARTICLE_TITLE, timeout=10)
  185 +
  186 + page.evaluate(
  187 + f"""
  188 + (() => {{
  189 + const el = document.querySelector({json.dumps(LONG_ARTICLE_TITLE)});
  190 + if (!el) return false;
  191 + const nativeSetter = Object.getOwnPropertyDescriptor(
  192 + window.HTMLTextAreaElement.prototype, 'value'
  193 + ).set;
  194 + el.focus();
  195 + nativeSetter.call(el, {json.dumps(title)});
  196 + el.dispatchEvent(new Event('input', {{ bubbles: true }}));
  197 + el.dispatchEvent(new Event('change', {{ bubbles: true }}));
  198 + return true;
  199 + }})()
  200 + """
  201 + )
  202 + logger.info("已填写长文标题: %s", title[:20])
  203 + time.sleep(0.5)
  204 +
  205 +
  206 +def _fill_long_content(page: Page, content: str) -> None:
  207 + """填写长文正文(TipTap/ProseMirror 编辑器)。"""
  208 + content_selector = CONTENT_EDITOR
  209 + if not page.has_element(CONTENT_EDITOR):
  210 + content_selector = _find_content_element(page)
  211 +
  212 + page.input_content_editable(content_selector, content)
  213 + logger.info("已填写长文正文 (%d 字)", len(content))
  214 + time.sleep(1)
  215 +
  216 +
  217 +def _insert_images_to_editor(page: Page, image_paths: list[str]) -> None:
  218 + """将图片插入到编辑器中。"""
  219 + for img_path in image_paths:
  220 + normalized = img_path.replace("\\", "/")
  221 + page.evaluate(
  222 + f"""
  223 + (() => {{
  224 + const editor = document.querySelector({json.dumps(CONTENT_EDITOR)});
  225 + if (!editor) return false;
  226 + const img = document.createElement('img');
  227 + img.src = 'file:///' + {json.dumps(normalized)};
  228 + editor.appendChild(img);
  229 + editor.dispatchEvent(new Event('input', {{ bubbles: true }}));
  230 + return true;
  231 + }})()
  232 + """
  233 + )
  234 + logger.info("已插入 %d 张图片到编辑器", len(image_paths))
  235 + time.sleep(1)
  236 +
  237 +
  238 +def _click_auto_format(page: Page) -> None:
  239 + """点击"一键排版"按钮。"""
  240 + _click_button_by_text(page, AUTO_FORMAT_BUTTON_TEXT)
  241 + logger.info("已点击'一键排版',等待模板加载...")
  242 + time.sleep(_AUTO_FORMAT_WAIT)
  243 +
  244 +
  245 +def _wait_for_templates(page: Page) -> bool:
  246 + """等待模板卡片出现。"""
  247 + for _ in range(_TEMPLATE_WAIT_ROUNDS):
  248 + count = page.get_elements_count(TEMPLATE_CARD)
  249 + if count and count > 0:
  250 + logger.info("发现 %d 个模板卡片", count)
  251 + return True
  252 + time.sleep(1)
  253 +
  254 + logger.warning("等待模板卡片超时")
  255 + return False
  256 +
  257 +
  258 +def _click_button_by_text(page: Page, text: str) -> None:
  259 + """通过文本内容查找并点击按钮(通用方法)。"""
  260 + clicked = page.evaluate(
  261 + f"""
  262 + (() => {{
  263 + const elems = document.querySelectorAll(
  264 + 'button, [role="button"], span, div, a, [class*="btn"]'
  265 + );
  266 + for (const el of elems) {{
  267 + if (el.textContent.trim() === {json.dumps(text)}) {{
  268 + const rect = el.getBoundingClientRect();
  269 + if (rect.width === 0 || rect.height === 0) continue;
  270 + el.click();
  271 + return true;
  272 + }}
  273 + }}
  274 + return false;
  275 + }})()
  276 + """
  277 + )
  278 +
  279 + if not clicked:
  280 + raise PublishError(f"未找到'{text}'按钮,页面结构可能已变化")
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
28 28
29 29
30 def publish_video_content(page: Page, content: PublishVideoContent) -> None: 30 def publish_video_content(page: Page, content: PublishVideoContent) -> None:
31 - """发布视频内容。 31 + """发布视频内容(填写表单 + 点击发布)
32 32
33 Args: 33 Args:
34 page: CDP 页面对象。 34 page: CDP 页面对象。
@@ -38,6 +38,21 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None: @@ -38,6 +38,21 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None:
38 PublishError: 发布失败。 38 PublishError: 发布失败。
39 UploadTimeoutError: 上传/处理超时。 39 UploadTimeoutError: 上传/处理超时。
40 """ 40 """
  41 + fill_publish_video_form(page, content)
  42 + click_publish_video_button(page)
  43 +
  44 +
  45 +def fill_publish_video_form(page: Page, content: PublishVideoContent) -> None:
  46 + """填写视频发布表单,不点击发布按钮。
  47 +
  48 + Args:
  49 + page: CDP 页面对象。
  50 + content: 视频发布内容。
  51 +
  52 + Raises:
  53 + PublishError: 填写失败。
  54 + UploadTimeoutError: 上传/处理超时。
  55 + """
41 if not content.video_path: 56 if not content.video_path:
42 raise PublishError("视频不能为空") 57 raise PublishError("视频不能为空")
43 58
@@ -51,8 +66,8 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None: @@ -51,8 +66,8 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None:
51 # 上传视频 66 # 上传视频
52 _upload_video(page, content.video_path) 67 _upload_video(page, content.video_path)
53 68
54 - # 提交  
55 - _submit_publish_video( 69 + # 填写表单(不点击发布)
  70 + _fill_publish_video_form(
56 page, 71 page,
57 content.title, 72 content.title,
58 content.content, 73 content.content,
@@ -62,6 +77,18 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None: @@ -62,6 +77,18 @@ def publish_video_content(page: Page, content: PublishVideoContent) -> None:
62 ) 77 )
63 78
64 79
  80 +def click_publish_video_button(page: Page) -> None:
  81 + """点击视频发布按钮。
  82 +
  83 + Args:
  84 + page: CDP 页面对象。
  85 + """
  86 + _wait_for_publish_button_clickable(page)
  87 + page.click_element(PUBLISH_BUTTON)
  88 + time.sleep(3)
  89 + logger.info("视频发布完成")
  90 +
  91 +
65 def _upload_video(page: Page, video_path: str) -> None: 92 def _upload_video(page: Page, video_path: str) -> None:
66 """上传视频文件。""" 93 """上传视频文件。"""
67 if not os.path.exists(video_path): 94 if not os.path.exists(video_path):
@@ -104,7 +131,7 @@ def _wait_for_publish_button_clickable(page: Page) -> None: @@ -104,7 +131,7 @@ def _wait_for_publish_button_clickable(page: Page) -> None:
104 raise UploadTimeoutError("等待发布按钮可点击超时(10分钟)") 131 raise UploadTimeoutError("等待发布按钮可点击超时(10分钟)")
105 132
106 133
107 -def _submit_publish_video( 134 +def _fill_publish_video_form(
108 page: Page, 135 page: Page,
109 title: str, 136 title: str,
110 content: str, 137 content: str,
@@ -112,7 +139,7 @@ def _submit_publish_video( @@ -112,7 +139,7 @@ def _submit_publish_video(
112 schedule_time: str | None, 139 schedule_time: str | None,
113 visibility: str, 140 visibility: str,
114 ) -> None: 141 ) -> None:
115 - """填写视频表单并提交。""" 142 + """填写视频表单(不点击发布)。"""
116 # 标题 143 # 标题
117 page.input_text(TITLE_INPUT, title) 144 page.input_text(TITLE_INPUT, title)
118 time.sleep(1) 145 time.sleep(1)
@@ -136,13 +163,7 @@ def _submit_publish_video( @@ -136,13 +163,7 @@ def _submit_publish_video(
136 # 可见范围 163 # 可见范围
137 _set_visibility(page, visibility) 164 _set_visibility(page, visibility)
138 165
139 - # 等待发布按钮可点击  
140 - _wait_for_publish_button_clickable(page)  
141 -  
142 - # 点击发布  
143 - page.click_element(PUBLISH_BUTTON)  
144 - time.sleep(3)  
145 - logger.info("视频发布完成") 166 + logger.info("视频表单填写完成,等待确认发布")
146 167
147 168
148 def _js_str(s: str) -> str: 169 def _js_str(s: str) -> str:
@@ -64,5 +64,16 @@ TAG_FIRST_ITEM = ".item" @@ -64,5 +64,16 @@ TAG_FIRST_ITEM = ".item"
64 # 弹窗 64 # 弹窗
65 POPOVER = "div.d-popover" 65 POPOVER = "div.d-popover"
66 66
  67 +# ========== 写长文模式 ==========
  68 +# 注意: 长文模式的按钮(写长文、新的创作、一键排版、下一步)通过文本匹配定位
  69 +LONG_ARTICLE_TAB_TEXT = "写长文"
  70 +NEW_CREATION_BUTTON_TEXT = "新的创作"
  71 +AUTO_FORMAT_BUTTON_TEXT = "一键排版"
  72 +NEXT_STEP_BUTTON_TEXT = "下一步"
  73 +
  74 +LONG_ARTICLE_TITLE = 'textarea.d-text[placeholder="输入标题"]'
  75 +TEMPLATE_CARD = ".template-card"
  76 +TEMPLATE_TITLE = ".template-card .template-title"
  77 +
67 # ========== 用户主页 ========== 78 # ========== 用户主页 ==========
68 SIDEBAR_PROFILE = "div.main-container li.user.side-bar-component a.link-wrapper span.channel" 79 SIDEBAR_PROFILE = "div.main-container li.user.side-bar-component a.link-wrapper span.channel"
1 --- 1 ---
2 name: xhs-publish 2 name: xhs-publish
3 description: | 3 description: |
4 - 小红书内容发布技能。支持图文发布、视频发布、定时发布、标签、可见性设置。  
5 - 当用户要求发布内容到小红书、上传图文、上传视频时触发。 4 + 小红书内容发布技能。支持图文发布、视频发布、长文发布、定时发布、标签、可见性设置。
  5 + 当用户要求发布内容到小红书、上传图文、上传视频、发长文时触发。
6 --- 6 ---
7 7
8 # 小红书内容发布 8 # 小红书内容发布
@@ -13,23 +13,25 @@ description: | @@ -13,23 +13,25 @@ description: |
13 13
14 按优先级判断: 14 按优先级判断:
15 15
16 -1. 用户已提供 `标题 + 正文 + 视频(本地路径)`:直接进入视频发布流程。  
17 -2. 用户已提供 `标题 + 正文 + 图片(本地路径或 URL)`:直接进入图文发布流程。  
18 -3. 用户只提供网页 URL:先用 WebFetch 提取内容和图片,再给出可发布草稿等待确认。  
19 -4. 信息不全:先补齐缺失信息,不要直接发布。 16 +1. 用户说"发长文 / 写长文 / 长文模式":进入 **长文发布流程(流程 B)**
  17 +2. 用户已提供 `标题 + 正文 + 视频(本地路径)`:进入 **视频发布流程(流程 A.2)**
  18 +3. 用户已提供 `标题 + 正文 + 图片(本地路径或 URL)`:进入 **图文发布流程(流程 A.1)**
  19 +4. 用户只提供网页 URL:先用 WebFetch 提取内容和图片,再给出可发布草稿等待确认。
  20 +5. 信息不全:先补齐缺失信息,不要直接发布。
20 21
21 ## 必做约束 22 ## 必做约束
22 23
23 - **发布前必须让用户确认最终标题、正文和图片/视频** 24 - **发布前必须让用户确认最终标题、正文和图片/视频**
  25 +- **推荐使用分步发布**:先 fill → 用户确认 → 再 click-publish。
24 - 图文发布时,没有图片不得发布。 26 - 图文发布时,没有图片不得发布。
25 - 视频发布时,没有视频不得发布。图片和视频不可混合(二选一)。 27 - 视频发布时,没有视频不得发布。图片和视频不可混合(二选一)。
26 - 标题长度不超过 20(UTF-16 编码计算,中文字符计 1,英文/数字/空格计 1)。 28 - 标题长度不超过 20(UTF-16 编码计算,中文字符计 1,英文/数字/空格计 1)。
27 - 如果使用文件路径,必须使用绝对路径,禁止相对路径。 29 - 如果使用文件路径,必须使用绝对路径,禁止相对路径。
28 - 需要先有运行中的 Chrome,且已登录。 30 - 需要先有运行中的 Chrome,且已登录。
29 31
30 -## 工作流程 32 +## 流程 A: 图文/视频发布
31 33
32 -### Step 1: 处理内容 34 +### Step A.1: 处理内容
33 35
34 #### 完整内容模式 36 #### 完整内容模式
35 直接使用用户提供的标题和正文。 37 直接使用用户提供的标题和正文。
@@ -40,7 +42,7 @@ description: | @@ -40,7 +42,7 @@ description: |
40 3. 适当总结内容,保持语言自然、适合小红书阅读习惯。 42 3. 适当总结内容,保持语言自然、适合小红书阅读习惯。
41 4. 如果提取不到图片,告知用户手动获取。 43 4. 如果提取不到图片,告知用户手动获取。
42 44
43 -### Step 2: 内容检查 45 +### Step A.2: 内容检查
44 46
45 #### 标题检查 47 #### 标题检查
46 标题长度必须 ≤ 20(UTF-16 编码长度)。如果超长,自动生成符合长度的新标题。 48 标题长度必须 ≤ 20(UTF-16 编码长度)。如果超长,自动生成符合长度的新标题。
@@ -50,25 +52,68 @@ description: | @@ -50,25 +52,68 @@ description: |
50 - 简体中文,语言自然。 52 - 简体中文,语言自然。
51 - 话题标签放在正文最后一行,格式:`#标签1 #标签2 #标签3` 53 - 话题标签放在正文最后一行,格式:`#标签1 #标签2 #标签3`
52 54
53 -### Step 3: 用户确认 55 +### Step A.3: 用户确认
54 56
55 通过 `AskUserQuestion` 展示即将发布的内容(标题、正文、图片/视频),获得明确确认后继续。 57 通过 `AskUserQuestion` 展示即将发布的内容(标题、正文、图片/视频),获得明确确认后继续。
56 58
57 -### Step 4: 写入临时文件 59 +### Step A.4: 写入临时文件
58 60
59 将标题和正文写入 UTF-8 文本文件。不要在命令行参数中内联中文文本。 61 将标题和正文写入 UTF-8 文本文件。不要在命令行参数中内联中文文本。
60 62
61 -### Step 5: 执行发布 63 +### Step A.5: 执行发布(推荐分步方式)
62 64
63 -#### 图文发布 65 +#### 分步发布(推荐)
  66 +
  67 +先填写表单,让用户在浏览器中确认预览后再发布:
  68 +
  69 +```bash
  70 +# 步骤 1: 填写图文表单(不发布)
  71 +python scripts/cli.py fill-publish \
  72 + --title-file /tmp/xhs_title.txt \
  73 + --content-file /tmp/xhs_content.txt \
  74 + --images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg" \
  75 + [--tags "标签1" "标签2"] \
  76 + [--schedule-at "2026-03-10T12:00:00"] \
  77 + [--original] [--visibility "公开可见"]
  78 +
  79 +# 步骤 2: 通过 AskUserQuestion 让用户确认浏览器中的预览
  80 +
  81 +# 步骤 3: 点击发布
  82 +python scripts/cli.py click-publish
  83 +```
  84 +
  85 +视频分步发布:
64 86
65 ```bash 87 ```bash
66 -# 使用 CLI 直接发布 88 +# 步骤 1: 填写视频表单(不发布)
  89 +python scripts/cli.py fill-publish-video \
  90 + --title-file /tmp/xhs_title.txt \
  91 + --content-file /tmp/xhs_content.txt \
  92 + --video "/abs/path/video.mp4" \
  93 + [--tags "标签1" "标签2"] \
  94 + [--visibility "公开可见"]
  95 +
  96 +# 步骤 2: 用户确认
  97 +
  98 +# 步骤 3: 点击发布
  99 +python scripts/cli.py click-publish
  100 +```
  101 +
  102 +#### 一步到位发布(快捷方式)
  103 +
  104 +```bash
  105 +# 图文一步到位
67 python scripts/cli.py publish \ 106 python scripts/cli.py publish \
68 --title-file /tmp/xhs_title.txt \ 107 --title-file /tmp/xhs_title.txt \
69 --content-file /tmp/xhs_content.txt \ 108 --content-file /tmp/xhs_content.txt \
70 --images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg" 109 --images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg"
71 110
  111 +# 视频一步到位
  112 +python scripts/cli.py publish-video \
  113 + --title-file /tmp/xhs_title.txt \
  114 + --content-file /tmp/xhs_content.txt \
  115 + --video "/abs/path/video.mp4"
  116 +
72 # 带标签和定时发布 117 # 带标签和定时发布
73 python scripts/cli.py publish \ 118 python scripts/cli.py publish \
74 --title-file /tmp/xhs_title.txt \ 119 --title-file /tmp/xhs_title.txt \
@@ -77,31 +122,30 @@ python scripts/cli.py publish \ @@ -77,31 +122,30 @@ python scripts/cli.py publish \
77 --tags "标签1" "标签2" \ 122 --tags "标签1" "标签2" \
78 --schedule-at "2026-03-10T12:00:00" \ 123 --schedule-at "2026-03-10T12:00:00" \
79 --original 124 --original
80 -  
81 -# 使用发布流水线(含图片下载和登录检查)  
82 -python scripts/publish_pipeline.py \  
83 - --title-file /tmp/xhs_title.txt \  
84 - --content-file /tmp/xhs_content.txt \  
85 - --images "https://example.com/pic1.jpg" "/abs/path/pic2.jpg"  
86 ``` 125 ```
87 126
88 -#### 视频发布 127 +#### Headless 模式(无头自动降级)
89 128
90 ```bash 129 ```bash
91 -python scripts/cli.py publish-video \ 130 +# 使用 --headless 参数,未登录时自动切换到有窗口模式
  131 +python scripts/cli.py publish --headless \
92 --title-file /tmp/xhs_title.txt \ 132 --title-file /tmp/xhs_title.txt \
93 --content-file /tmp/xhs_content.txt \ 133 --content-file /tmp/xhs_content.txt \
94 - --video "/abs/path/video.mp4" 134 + --images "/abs/path/pic1.jpg"
95 135
96 -# 带标签和可见性  
97 -python scripts/cli.py publish-video \ 136 +# 发布流水线(含图片下载和登录检查 + 自动降级)
  137 +python scripts/publish_pipeline.py --headless \
98 --title-file /tmp/xhs_title.txt \ 138 --title-file /tmp/xhs_title.txt \
99 --content-file /tmp/xhs_content.txt \ 139 --content-file /tmp/xhs_content.txt \
100 - --video "/abs/path/video.mp4" \  
101 - --tags "标签1" "标签2" \  
102 - --visibility "公开" 140 + --images "https://example.com/pic1.jpg" "/abs/path/pic2.jpg"
103 ``` 141 ```
104 142
  143 +`--headless` + 未登录时,脚本会:
  144 +1. 关闭无头 Chrome
  145 +2. 以有窗口模式重新启动 Chrome
  146 +3. 返回 JSON 包含 `"action": "switched_to_headed"`
  147 +4. 提示用户在浏览器中扫码登录
  148 +
105 #### 指定账号/远程 Chrome 149 #### 指定账号/远程 Chrome
106 150
107 ```bash 151 ```bash
@@ -118,15 +162,65 @@ python scripts/cli.py --host 10.0.0.12 --port 9222 publish \ @@ -118,15 +162,65 @@ python scripts/cli.py --host 10.0.0.12 --port 9222 publish \
118 --images "/abs/path/pic1.jpg" 162 --images "/abs/path/pic1.jpg"
119 ``` 163 ```
120 164
121 -### Step 6: 处理输出 165 +## 流程 B: 长文发布
122 166
123 -- **Exit code 0**:发布成功。输出 JSON 包含 `success`, `title`, `images`/`video`, `status`  
124 -- **Exit code 1**:未登录,提示用户先登录(参考 xhs-auth)。  
125 -- **Exit code 2**:错误,报告 JSON 中的 `error` 字段。 167 +当用户说"发长文 / 写长文 / 长文模式"时触发。长文模式使用小红书的长文编辑器,支持排版模板。
  168 +
  169 +### Step B.1: 准备长文内容
  170 +
  171 +收集标题和正文。长文标题使用 textarea 输入,没有 20 字限制(但建议简洁)。
  172 +
  173 +### Step B.2: 用户确认标题和正文
  174 +
  175 +通过 `AskUserQuestion` 确认长文内容。
  176 +
  177 +### Step B.3: 写入临时文件并执行长文模式
  178 +
  179 +```bash
  180 +python scripts/cli.py long-article \
  181 + --title-file /tmp/xhs_title.txt \
  182 + --content-file /tmp/xhs_content.txt \
  183 + [--images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg"]
  184 +```
  185 +
  186 +该命令会:
  187 +1. 导航到发布页
  188 +2. 点击"写长文" tab
  189 +3. 点击"新的创作"
  190 +4. 填写标题和正文
  191 +5. 点击"一键排版"
  192 +6. 返回 JSON 包含 `templates` 列表
126 193
127 -### Step 7: 报告结果 194 +### Step B.4: 选择排版模板
128 195
129 -根据输出告知用户发布是否成功。 196 +通过 `AskUserQuestion` 展示可用模板列表,让用户选择:
  197 +
  198 +```bash
  199 +python scripts/cli.py select-template --name "用户选择的模板名"
  200 +```
  201 +
  202 +### Step B.5: 进入发布页
  203 +
  204 +```bash
  205 +# 点击下一步,填写发布页描述(正文摘要,不超过 1000 字)
  206 +python scripts/cli.py next-step \
  207 + --content-file /tmp/xhs_description.txt
  208 +```
  209 +
  210 +注意:发布页的描述编辑器是独立的,需要单独填入内容。如果描述超过 1000 字,脚本会自动截断到 800 字。
  211 +
  212 +### Step B.6: 用户确认并发布
  213 +
  214 +```bash
  215 +# 用户在浏览器中确认预览后
  216 +python scripts/cli.py click-publish
  217 +```
  218 +
  219 +## 处理输出
  220 +
  221 +- **Exit code 0**:成功。输出 JSON 包含 `success`, `title`, `images`/`video`/`templates`, `status`
  222 +- **Exit code 1**:未登录,提示用户先登录(参考 xhs-auth)。若使用 `--headless` 且自动降级,JSON 中 `action` 为 `switched_to_headed`
  223 +- **Exit code 2**:错误,报告 JSON 中的 `error` 字段。
130 224
131 ## 常用参数 225 ## 常用参数
132 226
@@ -140,14 +234,16 @@ python scripts/cli.py --host 10.0.0.12 --port 9222 publish \ @@ -140,14 +234,16 @@ python scripts/cli.py --host 10.0.0.12 --port 9222 publish \
140 | `--schedule-at ISO8601` | 定时发布时间 | 234 | `--schedule-at ISO8601` | 定时发布时间 |
141 | `--original` | 声明原创 | 235 | `--original` | 声明原创 |
142 | `--visibility` | 可见范围 | 236 | `--visibility` | 可见范围 |
  237 +| `--headless` | 无头模式(未登录自动降级到有窗口模式) |
143 | `--host HOST` | 远程 CDP 主机 | 238 | `--host HOST` | 远程 CDP 主机 |
144 | `--port PORT` | CDP 端口(默认 9222) | 239 | `--port PORT` | CDP 端口(默认 9222) |
145 | `--account name` | 指定账号 | 240 | `--account name` | 指定账号 |
146 241
147 ## 失败处理 242 ## 失败处理
148 243
149 -- **登录失败**:提示用户重新扫码登录并重试。 244 +- **登录失败**:提示用户重新扫码登录并重试。使用 `--headless` 时会自动降级到有窗口模式。
150 - **图片下载失败**:提示更换图片 URL 或改用本地图片。 245 - **图片下载失败**:提示更换图片 URL 或改用本地图片。
151 - **视频处理超时**:视频上传后需等待处理(最长 10 分钟),超时后提示重试。 246 - **视频处理超时**:视频上传后需等待处理(最长 10 分钟),超时后提示重试。
152 - **标题过长**:自动缩短标题,保持语义。 247 - **标题过长**:自动缩短标题,保持语义。
153 - **页面选择器失效**:提示检查脚本中的选择器定义。 248 - **页面选择器失效**:提示检查脚本中的选择器定义。
  249 +- **模板加载超时**:长文模式下模板可能加载缓慢,等待 15 秒后超时。