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>
Showing
10 changed files
with
1008 additions
and
131 deletions
| @@ -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: |
scripts/xhs/publish_long_article.py
0 → 100644
| 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 秒后超时。 |
-
Please register or login to post a comment