Merge pull request #399 from Angiin/feature-add
Feature add post-to-xhs 发布技能
This commit is contained in:
251
skills/post-to-xhs/SKILL.md
Normal file
251
skills/post-to-xhs/SKILL.md
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: post-to-xhs
|
||||
description: >
|
||||
小红书内容发布技能。支持两种发布模式:(1) 上传图文模式 - 图片+短文;(2) 写长文模式 - 长篇文章+排版模板。
|
||||
支持两种输入方式:用户提供完整内容和图片/图片URL,直接发布;或提供网页URL,自动提取内容和图片。
|
||||
用户说"发长文"时使用长文模式,否则默认图文模式。
|
||||
---
|
||||
|
||||
# 小红书内容发布
|
||||
|
||||
根据用户输入自动判断发布方式和发布模式,简化发布流程。
|
||||
|
||||
## 发布模式
|
||||
|
||||
- **上传图文**(默认):图片 + 短文,适合日常分享
|
||||
- **写长文**:长篇文章 + 排版模板选择,适合深度内容。用户明确说"发长文"时使用
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
用户输入
|
||||
│
|
||||
├─ 完整内容 + 图片/图片URL → 判断模式 → 发布流程
|
||||
│
|
||||
└─ 网页 URL → WebFetch 提取内容和图片
|
||||
│
|
||||
├─ 有图片 → 适当总结内容 → 判断模式 → 发布流程
|
||||
│
|
||||
└─ 无图片 → 提示用户手动下载图片
|
||||
│
|
||||
└─ 用户提供图片后 → 发布流程
|
||||
```
|
||||
|
||||
## Step 1: 判断输入类型
|
||||
|
||||
根据用户输入判断:
|
||||
|
||||
- **完整内容模式**:用户提供了标题、正文内容、以及图片(本地路径或URL)
|
||||
- **URL 提取模式**:用户只提供了一个网页 URL
|
||||
|
||||
如果不确定,询问用户。
|
||||
|
||||
## Step 2: 处理内容
|
||||
|
||||
### 完整内容模式
|
||||
|
||||
直接使用用户提供的标题和正文,跳到 Step 3。
|
||||
|
||||
### URL 提取模式
|
||||
|
||||
1. 使用 WebFetch 提取网页内容
|
||||
2. 提取关键信息:标题、正文、图片URL
|
||||
3. 适当总结内容,保持:
|
||||
- 关键信息完整
|
||||
- 语言自然流畅
|
||||
- 适合小红书阅读习惯
|
||||
|
||||
#### 图片提取失败处理
|
||||
|
||||
如果从网页中提取不到图片URL,或图片URL无法访问,**必须**:
|
||||
|
||||
1. 告知用户图片提取失败
|
||||
2. 提供原网页链接,请用户手动访问
|
||||
3. 指导用户:
|
||||
- 在浏览器中打开原网页
|
||||
- 右键点击想要的图片 → "图片另存为" 或 "复制图片地址"
|
||||
- 将保存的图片路径或复制的图片URL提供给我
|
||||
4. 等待用户提供图片后再继续发布流程
|
||||
|
||||
**示例提示语**:
|
||||
```
|
||||
从网页中未能提取到可用的图片。请手动获取:
|
||||
|
||||
1. 打开原文链接:[URL]
|
||||
2. 找到合适的配图,右键另存为本地,或复制图片地址
|
||||
3. 将图片路径或URL发给我
|
||||
|
||||
拿到图片后我们继续发布。
|
||||
```
|
||||
|
||||
## Step 3: 内容检查
|
||||
|
||||
### 标题检查
|
||||
|
||||
标题长度必须 ≤ 38,计算规则:
|
||||
- 中文字符和中文标点(《》、,。等):每个计 2
|
||||
- 英文字母/数字/空格/ASCII标点:每个计 1
|
||||
|
||||
如果超长,自动生成符合长度要求的新标题,保持语义一致。
|
||||
|
||||
### 正文格式
|
||||
|
||||
- 段落之间使用双换行分隔
|
||||
- 语言自然,避免机器翻译感
|
||||
- 简体中文
|
||||
|
||||
## Step 4: 发布到小红书
|
||||
|
||||
完整发布流程参考: [references/publish-workflow.md](references/publish-workflow.md)
|
||||
|
||||
### 4.1 用户确认内容
|
||||
|
||||
通过 `AskUserQuestion` 向用户展示即将发布的内容(标题、正文、图片),获得明确确认后再继续。
|
||||
|
||||
### 4.2 选择发布模式
|
||||
|
||||
通过 `AskUserQuestion` 让用户选择发布模式:
|
||||
|
||||
- **无头模式**(推荐):后台运行,速度快,无浏览器窗口。发布完成后直接报告结果。
|
||||
- **有窗口模式**:显示浏览器窗口,可以预览内容。需要用户确认后再点击发布。
|
||||
|
||||
```
|
||||
AskUserQuestion 示例:
|
||||
问题:选择发布模式
|
||||
选项:
|
||||
- 无头模式(推荐):后台快速发布,无需预览
|
||||
- 有窗口模式:显示浏览器,可预览确认
|
||||
```
|
||||
|
||||
### 4.3 写入临时文件
|
||||
|
||||
将标题和正文写入临时 UTF-8 文本文件。不要在 `python -c` 中内联中文文本。
|
||||
|
||||
### 4.4 运行发布(根据模式分流)
|
||||
|
||||
#### A. 上传图文模式(默认)
|
||||
|
||||
根据用户选择的模式执行发布脚本:
|
||||
|
||||
**无头模式**(添加 `--headless` 参数):
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\publish_pipeline.py" --headless --title-file title.txt --content-file content.txt --image-urls "URL1" "URL2"
|
||||
```
|
||||
|
||||
**有窗口模式**(不添加 `--headless`):
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\publish_pipeline.py" --title-file title.txt --content-file content.txt --image-urls "URL1" "URL2"
|
||||
```
|
||||
|
||||
**其他参数**:
|
||||
```bash
|
||||
# 发布到指定账号
|
||||
python ... --account myaccount ...
|
||||
|
||||
# 使用本地图片
|
||||
python ... --images "C:\path\to\image.jpg"
|
||||
```
|
||||
|
||||
处理输出:
|
||||
- `NOT_LOGGED_IN` (exit code 1) → 脚本自动切换到有窗口模式,提示用户扫码登录,确认后重新运行
|
||||
- `READY_TO_PUBLISH` (exit code 0) → 根据模式进入下一步
|
||||
- Exit code 2 → 报告错误
|
||||
|
||||
#### B. 写长文模式
|
||||
|
||||
**Step B.1 — 填写长文内容 + 一键排版:**
|
||||
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" long-article --title-file title.txt --content-file content.txt
|
||||
```
|
||||
|
||||
可选 `--images img1.jpg img2.jpg` 插入图片到编辑器中。
|
||||
|
||||
输出中包含 `TEMPLATES: [...]` JSON 数组,为可用的排版模板名称列表。
|
||||
|
||||
**Step B.2 — 让用户选择模板:**
|
||||
|
||||
使用 `AskUserQuestion` 将模板名称作为选项展示给用户选择(从 TEMPLATES 输出中解析)。
|
||||
|
||||
**Step B.3 — 选择模板:**
|
||||
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" select-template --name "用户选择的模板名"
|
||||
```
|
||||
|
||||
**Step B.4 — 点击下一步并填写发布页正文描述:**
|
||||
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" click-next-step --content-file content.txt
|
||||
```
|
||||
|
||||
注意:发布页有独立的正文描述编辑器,必须通过 `--content` 或 `--content-file` 传入内容填写。
|
||||
如果正文超过 1000 字,应压缩到 800 字左右再填入,保持语义不变。
|
||||
|
||||
**Step B.5 — 用户预览确认并发布:** 进入下方 4.5 步骤。
|
||||
|
||||
### 4.5 用户预览确认(仅有窗口模式 / 长文模式)
|
||||
|
||||
**仅当用户选择有窗口模式或使用长文模式时**,使用 `AskUserQuestion` 请用户在浏览器中检查预览,确认后再发布。
|
||||
|
||||
无头模式的图文发布跳过此步骤,直接进入 4.6。
|
||||
|
||||
### 4.6 点击发布
|
||||
|
||||
点击发布按钮:
|
||||
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" click-publish
|
||||
```
|
||||
|
||||
### 4.7 报告结果
|
||||
|
||||
根据命令输出告知用户发布是否成功。
|
||||
|
||||
## 重要提示
|
||||
|
||||
- **绝不自动发布** - 必须获得用户确认
|
||||
- **图片要求** - 上传图文模式必须有图片;写长文模式图片可选
|
||||
- **长文模式** - 必须让用户选择模板,不要自动选择
|
||||
- **正文描述** - 长文模式的发布页有独立正文描述框,超过 1000 字需压缩到 800 字左右
|
||||
- **无头模式**:使用 `--headless` 参数自动化发布。如需登录,脚本自动切换到有窗口模式
|
||||
- 如果页面结构变化导致选择器失效,参考 `references/publish-workflow.md` 更新
|
||||
|
||||
## 账号管理
|
||||
|
||||
系统支持多个小红书账号,每个账号有独立的 Chrome profile。
|
||||
|
||||
### 列出账号
|
||||
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" list-accounts
|
||||
```
|
||||
|
||||
### 添加账号
|
||||
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" add-account myaccount --alias "我的账号"
|
||||
```
|
||||
|
||||
### 登录
|
||||
|
||||
```bash
|
||||
# 默认账号
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" login
|
||||
|
||||
# 指定账号
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" --account myaccount login
|
||||
```
|
||||
|
||||
### 切换账号
|
||||
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" switch-account
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" --account otheraccount switch-account
|
||||
```
|
||||
|
||||
### 设置默认账号
|
||||
|
||||
```bash
|
||||
python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" set-default-account myaccount
|
||||
```
|
||||
10
skills/post-to-xhs/config/accounts.json
Normal file
10
skills/post-to-xhs/config/accounts.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"default_account": "default",
|
||||
"accounts": {
|
||||
"default": {
|
||||
"alias": "默认账号",
|
||||
"profile_dir": "C:\\Users\\admin\\AppData\\Local\\Google\\Chrome\\XiaohongshuProfiles\\default",
|
||||
"created_at": null
|
||||
}
|
||||
}
|
||||
}
|
||||
297
skills/post-to-xhs/references/publish-workflow.md
Normal file
297
skills/post-to-xhs/references/publish-workflow.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# 小红书发布流程参考
|
||||
|
||||
本文档描述通过 CDP(Chrome DevTools Protocol)自动发布内容到小红书创作者中心的完整流程。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **Chrome 浏览器已安装** - 标准 Google Chrome
|
||||
2. **Python 依赖已安装** - `websockets`、`requests`
|
||||
3. **首次登录已完成** - 至少登录过一次小红书(cookie 持久化在专用 profile 中)
|
||||
|
||||
## 流程概览
|
||||
|
||||
**上传图文模式**:
|
||||
```
|
||||
生成文案 → 用户确认 → 启动 Chrome → 检查登录 → 导航发布页 → 上传图片 → 填写标题 → 填写正文 → 用户确认发布
|
||||
```
|
||||
|
||||
**写长文模式**:
|
||||
```
|
||||
生成文案 → 用户确认 → 启动 Chrome → 检查登录 → 导航发布页 → 点击"写长文"tab → 点击"新的创作" → 填写标题 → 填写正文 → 一键排版 → 用户选择模板 → 下一步 → 填写发布页正文描述 → 用户确认发布
|
||||
```
|
||||
|
||||
## 详细步骤
|
||||
|
||||
### 1. 启动 / 连接 Chrome
|
||||
|
||||
脚本: `scripts/chrome_launcher.py`
|
||||
|
||||
- 检测 `127.0.0.1:9222` 端口是否已有 Chrome 实例
|
||||
- 若无,启动 Chrome 并附带以下参数:
|
||||
- `--remote-debugging-port=9222`
|
||||
- `--user-data-dir=%LOCALAPPDATA%/Google/Chrome/XiaohongshuProfile`
|
||||
- `--no-first-run`
|
||||
- `--no-default-browser-check`
|
||||
- `--headless=new`(仅在无头模式下)
|
||||
- 等待端口就绪(最多 15 秒)
|
||||
|
||||
**用户数据目录说明**: 使用独立的 `XiaohongshuProfile` 目录,与用户日常浏览器 profile 完全隔离,不会干扰正常使用。
|
||||
|
||||
**无头模式说明**: 使用 `--headless` 参数启动时,Chrome 不会显示窗口,适合自动化发布。如需登录或切换账号,脚本会自动切换到有窗口模式。
|
||||
|
||||
### 2. 检查登录状态
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `check_login()`
|
||||
|
||||
- 导航到 `https://creator.xiaohongshu.com`
|
||||
- 检查当前 URL 是否包含 "login"(被重定向到登录页)
|
||||
- 检查页面是否存在用户信息相关的 DOM 元素
|
||||
- 若未登录,提示用户在 Chrome 窗口中扫码登录
|
||||
|
||||
### 3. 导航到发布页
|
||||
|
||||
- 目标 URL: `https://creator.xiaohongshu.com/publish/publish`
|
||||
- 等待页面完全加载
|
||||
|
||||
### 4. 上传图片
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `_upload_images()`
|
||||
|
||||
- 通过 CDP `DOM.querySelector` 定位 `input[type="file"]` 元素
|
||||
- 使用 CDP `DOM.setFileInputFiles` 命令设置文件路径
|
||||
- 等待图片上传和处理完成
|
||||
|
||||
**图片来源**: 如果图片是 URL,先用 `scripts/image_downloader.py` 下载到临时目录,发布后自动清理。
|
||||
|
||||
### 5. 填写标题
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `_fill_title()`
|
||||
|
||||
- 定位标题输入框
|
||||
- 设置 value 并触发 `input` 和 `change` 事件
|
||||
|
||||
### 6. 填写正文
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `_fill_content()`
|
||||
|
||||
- 定位 contenteditable 编辑区域(TipTap/ProseMirror editor)
|
||||
- 将正文按段落拆分,包裹为 `<p>` 标签写入 innerHTML,段落之间插入 `<p><br></p>` 空行
|
||||
- 触发 `input` 事件
|
||||
|
||||
### 7. 用户确认并发布
|
||||
|
||||
- 脚本填写完成后暂停,提示用户在浏览器中检查预览
|
||||
- 用户确认后,脚本点击发布按钮
|
||||
- 或用户选择手动点击发布按钮
|
||||
|
||||
## 写长文模式详细步骤
|
||||
|
||||
### 1-2. 启动 Chrome 和检查登录
|
||||
|
||||
同上传图文模式。
|
||||
|
||||
### 3. 导航到发布页并点击"写长文"tab
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `_click_long_article_tab()`
|
||||
|
||||
- 导航到 `https://creator.xiaohongshu.com/publish/publish`
|
||||
- 在 `div.creator-tab` 中查找文本为"写长文"的 tab 并点击
|
||||
|
||||
### 4. 点击"新的创作"
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `_click_new_creation()`
|
||||
|
||||
- 在页面中查找包含"新的创作"文本的元素并点击
|
||||
- 等待长文编辑器页面加载
|
||||
|
||||
### 5. 填写长文标题
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `_fill_long_title()`
|
||||
|
||||
- 定位 `textarea.d-text[placeholder="输入标题"]` 元素
|
||||
- 使用 `HTMLTextAreaElement.prototype.value` 的 native setter 设置值
|
||||
- 触发 `input` 和 `change` 事件
|
||||
|
||||
### 6. 填写长文正文
|
||||
|
||||
同上传图文模式的正文填写(TipTap/ProseMirror 编辑器)。
|
||||
|
||||
### 7. 一键排版
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `_click_auto_format()`
|
||||
|
||||
- 查找并点击"一键排版"按钮
|
||||
- 等待模板列表加载
|
||||
|
||||
### 8. 模板选择
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `get_template_names()` + `select_template(name)`
|
||||
|
||||
- `get_template_names()` 从 `.template-card .template-title` 获取所有模板名称
|
||||
- `select_template(name)` 点击指定名称的模板卡片
|
||||
- 已选中的模板卡片 class 为 `template-card selected`
|
||||
|
||||
### 9. 下一步并填写发布页描述
|
||||
|
||||
脚本: `scripts/cdp_publish.py` → `click_next_and_prepare_publish(content)`
|
||||
|
||||
- 点击"下一步"按钮进入发布预览页
|
||||
- 发布页有独立的正文描述编辑器(`div.tiptap.ProseMirror`),需要单独填入内容
|
||||
|
||||
### 10. 用户确认并发布
|
||||
|
||||
同上传图文模式。
|
||||
|
||||
## DOM 选择器参考
|
||||
|
||||
> **注意**: 小红书前端可能随时更新,以下选择器基于编写时的页面结构。如果自动化失败,需要在浏览器 DevTools 中重新抓取选择器,并更新 `cdp_publish.py` 中的 `SELECTORS` 字典。
|
||||
|
||||
| 元素 | 主选择器 | 备选选择器 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 图片上传 | `input.upload-input` | `input[type="file"]` | 隐藏的文件输入,通过 CDP 直接操作 |
|
||||
| 标题输入(图文) | `input[placeholder*="填写标题"]` | `input.d-text` | 图文模式标题输入框 |
|
||||
| 标题输入(长文) | `textarea.d-text[placeholder="输入标题"]` | - | 长文模式 textarea 标题 |
|
||||
| 正文编辑 | `div.tiptap.ProseMirror` | `div.ProseMirror[contenteditable="true"]` | TipTap/ProseMirror 富文本编辑器 |
|
||||
| 发布按钮 | 文本匹配"发布" | - | 通过遍历按钮文本定位 |
|
||||
| 写长文 tab | 文本匹配"写长文"(`div.creator-tab`) | - | 长文模式入口 |
|
||||
| 新的创作按钮 | 文本匹配"新的创作" | - | 长文编辑器入口 |
|
||||
| 一键排版按钮 | 文本匹配"一键排版" | - | 触发模板选择 |
|
||||
| 模板卡片 | `.template-card` | `.template-card.selected`(已选) | 排版模板列表 |
|
||||
| 模板名称 | `.template-card .template-title` | - | 模板卡片内的名称 span |
|
||||
| 下一步按钮 | 文本匹配"下一步" | - | 模板选择后进入发布页 |
|
||||
| 登录检测 | URL 包含 "login" | `.user-info, .creator-header` | 重定向检测 + DOM 元素检测 |
|
||||
|
||||
## 选择器维护指南
|
||||
|
||||
当小红书更新页面导致自动化失败时:
|
||||
|
||||
1. 在 Chrome 中打开 `https://creator.xiaohongshu.com/publish/publish`
|
||||
2. 按 F12 打开开发者工具
|
||||
3. 使用元素选择器(Ctrl+Shift+C)定位目标元素
|
||||
4. 记录新的选择器
|
||||
5. 更新 `scripts/cdp_publish.py` 中 `SELECTORS` 字典对应的值
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 错误 | 原因 | 解决方案 |
|
||||
|---|---|---|
|
||||
| Chrome 未启动 | 端口 9222 无响应 | 运行 `chrome_launcher.py` 或手动启动 Chrome |
|
||||
| 找不到 Chrome | 非标准安装路径 | 检查 Chrome 安装,或在脚本中指定路径 |
|
||||
| 未登录 | cookie 过期或首次使用 | 在 Chrome 窗口中扫码登录 |
|
||||
| 选择器失效 | 小红书页面更新 | 按上述维护指南更新选择器 |
|
||||
| 图片上传失败 | 文件路径错误或格式不支持 | 检查图片路径,确保格式为 jpg/png/webp |
|
||||
| 发布按钮找不到 | 页面未完全加载 | 增加等待时间或手动点击发布 |
|
||||
|
||||
## CLI 用法
|
||||
|
||||
所有脚本位于 `scripts/` 目录。
|
||||
|
||||
### 方式 A: 统一 pipeline(推荐)
|
||||
|
||||
```bash
|
||||
# 无头模式(推荐)- 无浏览器窗口,更快
|
||||
python publish_pipeline.py --headless --title "标题" --content "正文" --image-urls URL1 URL2
|
||||
|
||||
# 无头模式 - 从文件读取标题和正文
|
||||
python publish_pipeline.py --headless --title-file title.txt --content-file body.txt --image-urls URL1
|
||||
|
||||
# 有窗口模式 - 用于调试或首次登录
|
||||
python publish_pipeline.py --title "标题" --content "正文" --image-urls URL1 URL2
|
||||
|
||||
# 使用本地图片文件
|
||||
python publish_pipeline.py --headless --title "标题" --content "正文" --images img1.jpg img2.jpg
|
||||
|
||||
# 填写并自动发布
|
||||
python publish_pipeline.py --headless --title "标题" --content "正文" --image-urls URL1 --auto-publish
|
||||
```
|
||||
|
||||
输出状态码:
|
||||
- 退出码 0 + `READY_TO_PUBLISH` = 表单已填写,等待确认
|
||||
- 退出码 0 + `PUBLISHED` = 已发布
|
||||
- 退出码 1 + `NOT_LOGGED_IN` = 未登录,需扫码(无头模式下会自动切换到有窗口模式)
|
||||
- 退出码 2 = 其他错误
|
||||
|
||||
### 方式 B: 分步调用(图文模式)
|
||||
|
||||
```bash
|
||||
# 1. 启动 Chrome(可选 --headless)
|
||||
python chrome_launcher.py
|
||||
python chrome_launcher.py --headless
|
||||
|
||||
# 2. 检查登录(退出码 0=已登录, 1=未登录)
|
||||
python cdp_publish.py check-login
|
||||
python cdp_publish.py --headless check-login
|
||||
|
||||
# 3. 填写表单
|
||||
python cdp_publish.py fill --title "标题" --content-file body.txt --images img1.jpg
|
||||
python cdp_publish.py --headless fill --title "标题" --content-file body.txt --images img1.jpg
|
||||
|
||||
# 4. 用户确认后点击发布
|
||||
python cdp_publish.py click-publish
|
||||
|
||||
# 或一步完成填写+发布
|
||||
python cdp_publish.py --headless publish --title "标题" --content-file body.txt --images img1.jpg
|
||||
```
|
||||
|
||||
### 方式 C: 分步调用(长文模式)
|
||||
|
||||
```bash
|
||||
# 1. 启动 Chrome
|
||||
python chrome_launcher.py
|
||||
|
||||
# 2. 检查登录
|
||||
python cdp_publish.py check-login
|
||||
|
||||
# 3. 填写长文 + 一键排版(输出包含 TEMPLATES JSON)
|
||||
python cdp_publish.py long-article --title-file title.txt --content-file content.txt
|
||||
|
||||
# 4. 选择模板
|
||||
python cdp_publish.py select-template --name "模板名称"
|
||||
|
||||
# 5. 下一步 + 填写发布页正文描述
|
||||
python cdp_publish.py click-next-step --content-file content.txt
|
||||
|
||||
# 6. 用户确认后点击发布
|
||||
python cdp_publish.py click-publish
|
||||
```
|
||||
|
||||
### 方式 D: Pipeline 长文模式
|
||||
|
||||
```bash
|
||||
# 长文模式(图片可选)
|
||||
python publish_pipeline.py --mode long-article --title-file title.txt --content-file content.txt
|
||||
python publish_pipeline.py --mode long-article --title "标题" --content "正文" --images img1.jpg
|
||||
```
|
||||
|
||||
### 账号管理
|
||||
|
||||
```bash
|
||||
# 首次登录或 session 过期 - 打开浏览器扫码登录
|
||||
python cdp_publish.py login
|
||||
|
||||
# 切换账号 - 清除 cookie 并打开登录页
|
||||
python cdp_publish.py switch-account
|
||||
|
||||
# 关闭 Chrome
|
||||
python chrome_launcher.py --kill
|
||||
|
||||
# 重启 Chrome(可选无头模式)
|
||||
python chrome_launcher.py --restart
|
||||
python chrome_launcher.py --restart --headless
|
||||
```
|
||||
|
||||
### Claude Code 集成
|
||||
|
||||
在 Claude Code 中通过 Bash 工具调用。推荐使用 pipeline 方式:
|
||||
|
||||
1. 将中文标题和正文写入临时文本文件(UTF-8 编码)
|
||||
2. 调用 `publish_pipeline.py --headless` 传入文件路径和图片 URL
|
||||
3. 根据输出状态码处理结果:
|
||||
- 未登录 → 脚本自动切换到有窗口模式,提示用户扫码
|
||||
- 已填写 → 请用户确认预览
|
||||
4. 用户确认后调用 `cdp_publish.py click-publish` 发布
|
||||
|
||||
**切换账号流程**:
|
||||
1. 调用 `cdp_publish.py switch-account`
|
||||
2. 等待用户扫码确认
|
||||
3. 继续正常发布流程
|
||||
309
skills/post-to-xhs/scripts/account_manager.py
Normal file
309
skills/post-to-xhs/scripts/account_manager.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Multi-account manager for Xiaohongshu publishing.
|
||||
|
||||
Manages multiple Xiaohongshu accounts with separate Chrome profiles:
|
||||
- Each account has its own user-data-dir for cookie isolation
|
||||
- Accounts are stored in a JSON config file
|
||||
- Supports add/remove/list/switch operations
|
||||
|
||||
Usage:
|
||||
python account_manager.py list
|
||||
python account_manager.py add <name> [--alias <alias>]
|
||||
python account_manager.py remove <name>
|
||||
python account_manager.py info <name>
|
||||
python account_manager.py set-default <name>
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from typing import Optional
|
||||
|
||||
# Config file location
|
||||
CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config")
|
||||
ACCOUNTS_FILE = os.path.join(CONFIG_DIR, "accounts.json")
|
||||
|
||||
# Base directory for account profiles
|
||||
PROFILES_BASE = os.path.join(os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
|
||||
"Google", "Chrome", "XiaohongshuProfiles")
|
||||
|
||||
# Default account name (for backward compatibility)
|
||||
DEFAULT_PROFILE_NAME = "default"
|
||||
|
||||
|
||||
def _ensure_config_dir():
|
||||
"""Ensure the config directory exists."""
|
||||
os.makedirs(CONFIG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _load_accounts() -> dict:
|
||||
"""Load accounts from config file."""
|
||||
_ensure_config_dir()
|
||||
if os.path.exists(ACCOUNTS_FILE):
|
||||
try:
|
||||
with open(ACCOUNTS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
# Default structure
|
||||
return {
|
||||
"default_account": DEFAULT_PROFILE_NAME,
|
||||
"accounts": {
|
||||
DEFAULT_PROFILE_NAME: {
|
||||
"alias": "默认账号",
|
||||
"profile_dir": os.path.join(PROFILES_BASE, DEFAULT_PROFILE_NAME),
|
||||
"created_at": None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _save_accounts(data: dict):
|
||||
"""Save accounts to config file."""
|
||||
_ensure_config_dir()
|
||||
with open(ACCOUNTS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def get_profile_dir(account_name: Optional[str] = None) -> str:
|
||||
"""
|
||||
Get the Chrome profile directory for a given account.
|
||||
|
||||
Args:
|
||||
account_name: Account name. If None, uses the default account.
|
||||
|
||||
Returns:
|
||||
Path to the Chrome user-data-dir for this account.
|
||||
"""
|
||||
data = _load_accounts()
|
||||
|
||||
if account_name is None:
|
||||
account_name = data.get("default_account", DEFAULT_PROFILE_NAME)
|
||||
|
||||
if account_name not in data["accounts"]:
|
||||
# Fallback to default
|
||||
account_name = DEFAULT_PROFILE_NAME
|
||||
if account_name not in data["accounts"]:
|
||||
# Create default account entry
|
||||
data["accounts"][account_name] = {
|
||||
"alias": "默认账号",
|
||||
"profile_dir": os.path.join(PROFILES_BASE, account_name),
|
||||
"created_at": None,
|
||||
}
|
||||
_save_accounts(data)
|
||||
|
||||
return data["accounts"][account_name]["profile_dir"]
|
||||
|
||||
|
||||
def get_default_account() -> str:
|
||||
"""Get the name of the default account."""
|
||||
data = _load_accounts()
|
||||
return data.get("default_account", DEFAULT_PROFILE_NAME)
|
||||
|
||||
|
||||
def set_default_account(account_name: str) -> bool:
|
||||
"""
|
||||
Set the default account.
|
||||
|
||||
Returns True if successful, False if account doesn't exist.
|
||||
"""
|
||||
data = _load_accounts()
|
||||
if account_name not in data["accounts"]:
|
||||
return False
|
||||
data["default_account"] = account_name
|
||||
_save_accounts(data)
|
||||
return True
|
||||
|
||||
|
||||
def list_accounts() -> list[dict]:
|
||||
"""
|
||||
List all registered accounts.
|
||||
|
||||
Returns a list of dicts with account info.
|
||||
"""
|
||||
data = _load_accounts()
|
||||
default = data.get("default_account", DEFAULT_PROFILE_NAME)
|
||||
result = []
|
||||
for name, info in data["accounts"].items():
|
||||
result.append({
|
||||
"name": name,
|
||||
"alias": info.get("alias", ""),
|
||||
"profile_dir": info.get("profile_dir", ""),
|
||||
"is_default": name == default,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def add_account(name: str, alias: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Add a new account.
|
||||
|
||||
Args:
|
||||
name: Unique account name (used as identifier)
|
||||
alias: Display name / description
|
||||
|
||||
Returns True if added, False if name already exists.
|
||||
"""
|
||||
data = _load_accounts()
|
||||
if name in data["accounts"]:
|
||||
return False
|
||||
|
||||
from datetime import datetime
|
||||
profile_dir = os.path.join(PROFILES_BASE, name)
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
|
||||
data["accounts"][name] = {
|
||||
"alias": alias or name,
|
||||
"profile_dir": profile_dir,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
_save_accounts(data)
|
||||
return True
|
||||
|
||||
|
||||
def remove_account(name: str, delete_profile: bool = False) -> bool:
|
||||
"""
|
||||
Remove an account.
|
||||
|
||||
Args:
|
||||
name: Account name to remove
|
||||
delete_profile: If True, also delete the Chrome profile directory
|
||||
|
||||
Returns True if removed, False if not found or is default.
|
||||
"""
|
||||
data = _load_accounts()
|
||||
if name not in data["accounts"]:
|
||||
return False
|
||||
|
||||
# Don't allow removing the default account if it's the only one
|
||||
if name == data.get("default_account") and len(data["accounts"]) == 1:
|
||||
return False
|
||||
|
||||
profile_dir = data["accounts"][name].get("profile_dir", "")
|
||||
del data["accounts"][name]
|
||||
|
||||
# If we removed the default, set a new default
|
||||
if name == data.get("default_account"):
|
||||
data["default_account"] = next(iter(data["accounts"].keys()))
|
||||
|
||||
_save_accounts(data)
|
||||
|
||||
# Optionally delete the profile directory
|
||||
if delete_profile and profile_dir and os.path.isdir(profile_dir):
|
||||
try:
|
||||
shutil.rmtree(profile_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_account_info(name: str) -> Optional[dict]:
|
||||
"""Get info for a specific account."""
|
||||
data = _load_accounts()
|
||||
if name not in data["accounts"]:
|
||||
return None
|
||||
info = data["accounts"][name].copy()
|
||||
info["name"] = name
|
||||
info["is_default"] = name == data.get("default_account")
|
||||
return info
|
||||
|
||||
|
||||
def account_exists(name: str) -> bool:
|
||||
"""Check if an account exists."""
|
||||
data = _load_accounts()
|
||||
return name in data["accounts"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Xiaohongshu Account Manager")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# list
|
||||
sub.add_parser("list", help="List all accounts")
|
||||
|
||||
# add
|
||||
p_add = sub.add_parser("add", help="Add a new account")
|
||||
p_add.add_argument("name", help="Account name (unique identifier)")
|
||||
p_add.add_argument("--alias", help="Display name / description")
|
||||
|
||||
# remove
|
||||
p_rm = sub.add_parser("remove", help="Remove an account")
|
||||
p_rm.add_argument("name", help="Account name to remove")
|
||||
p_rm.add_argument("--delete-profile", action="store_true",
|
||||
help="Also delete the Chrome profile directory")
|
||||
|
||||
# info
|
||||
p_info = sub.add_parser("info", help="Show account info")
|
||||
p_info.add_argument("name", help="Account name")
|
||||
|
||||
# set-default
|
||||
p_def = sub.add_parser("set-default", help="Set the default account")
|
||||
p_def.add_argument("name", help="Account name to set as default")
|
||||
|
||||
# get-profile-dir (for internal use)
|
||||
p_dir = sub.add_parser("get-profile-dir", help="Get profile directory for an account")
|
||||
p_dir.add_argument("--account", help="Account name (default: default account)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "list":
|
||||
accounts = list_accounts()
|
||||
if not accounts:
|
||||
print("No accounts configured.")
|
||||
return
|
||||
print(f"{'Name':<20} {'Alias':<20} {'Default':<10}")
|
||||
print("-" * 50)
|
||||
for acc in accounts:
|
||||
default_mark = "*" if acc["is_default"] else ""
|
||||
print(f"{acc['name']:<20} {acc['alias']:<20} {default_mark:<10}")
|
||||
|
||||
elif args.command == "add":
|
||||
if add_account(args.name, args.alias):
|
||||
print(f"Account '{args.name}' added.")
|
||||
print(f"Profile dir: {get_profile_dir(args.name)}")
|
||||
print("\nTo log in to this account, run:")
|
||||
print(f" python cdp_publish.py --account {args.name} login")
|
||||
else:
|
||||
print(f"Error: Account '{args.name}' already exists.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "remove":
|
||||
if remove_account(args.name, args.delete_profile):
|
||||
print(f"Account '{args.name}' removed.")
|
||||
else:
|
||||
print(f"Error: Cannot remove account '{args.name}'.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "info":
|
||||
info = get_account_info(args.name)
|
||||
if info:
|
||||
print(f"Name: {info['name']}")
|
||||
print(f"Alias: {info.get('alias', '')}")
|
||||
print(f"Profile dir: {info.get('profile_dir', '')}")
|
||||
print(f"Default: {'Yes' if info.get('is_default') else 'No'}")
|
||||
print(f"Created: {info.get('created_at', 'Unknown')}")
|
||||
else:
|
||||
print(f"Error: Account '{args.name}' not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "set-default":
|
||||
if set_default_account(args.name):
|
||||
print(f"Default account set to '{args.name}'.")
|
||||
else:
|
||||
print(f"Error: Account '{args.name}' not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "get-profile-dir":
|
||||
print(get_profile_dir(args.account))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1056
skills/post-to-xhs/scripts/cdp_publish.py
Normal file
1056
skills/post-to-xhs/scripts/cdp_publish.py
Normal file
File diff suppressed because it is too large
Load Diff
296
skills/post-to-xhs/scripts/chrome_launcher.py
Normal file
296
skills/post-to-xhs/scripts/chrome_launcher.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Chrome launcher with CDP remote debugging support.
|
||||
|
||||
Manages a dedicated Chrome instance for Xiaohongshu publishing:
|
||||
- Detects if Chrome is already listening on the debug port
|
||||
- Launches Chrome with a dedicated user-data-dir for login persistence
|
||||
- Waits for the debug port to become available
|
||||
- Supports headless mode for automated publishing without GUI
|
||||
- Supports switching between headless and headed mode (e.g. for login)
|
||||
- Supports multiple accounts with separate profile directories
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import subprocess
|
||||
import platform
|
||||
import signal
|
||||
from typing import Optional
|
||||
|
||||
CDP_PORT = 9222
|
||||
PROFILE_DIR_NAME = "XiaohongshuProfile"
|
||||
STARTUP_TIMEOUT = 15 # seconds to wait for Chrome to start
|
||||
|
||||
# Track the Chrome process we launched so we can kill it later
|
||||
_chrome_process: subprocess.Popen | None = None
|
||||
# Track the current account being used
|
||||
_current_account: Optional[str] = None
|
||||
|
||||
|
||||
def get_chrome_path() -> str:
|
||||
"""Find Chrome executable on Windows."""
|
||||
candidates = []
|
||||
|
||||
# Standard install locations
|
||||
for env_var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
|
||||
base = os.environ.get(env_var, "")
|
||||
if base:
|
||||
candidates.append(os.path.join(base, "Google", "Chrome", "Application", "chrome.exe"))
|
||||
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
|
||||
# Fallback: check PATH
|
||||
import shutil
|
||||
found = shutil.which("chrome") or shutil.which("chrome.exe")
|
||||
if found:
|
||||
return found
|
||||
|
||||
raise FileNotFoundError(
|
||||
"Chrome not found. Please install Google Chrome or set its path manually."
|
||||
)
|
||||
|
||||
|
||||
def get_user_data_dir(account: Optional[str] = None) -> str:
|
||||
"""
|
||||
Return the Chrome profile directory path for a given account.
|
||||
|
||||
Args:
|
||||
account: Account name. If None, uses the default account from account_manager.
|
||||
|
||||
Returns:
|
||||
Path to the Chrome user-data-dir for this account.
|
||||
"""
|
||||
try:
|
||||
from account_manager import get_profile_dir
|
||||
return get_profile_dir(account)
|
||||
except ImportError:
|
||||
# Fallback if account_manager not available
|
||||
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
||||
if not local_app_data:
|
||||
local_app_data = os.path.expanduser("~")
|
||||
return os.path.join(local_app_data, "Google", "Chrome", PROFILE_DIR_NAME)
|
||||
|
||||
|
||||
def is_port_open(port: int, host: str = "127.0.0.1") -> bool:
|
||||
"""Check if a TCP port is accepting connections."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
return True
|
||||
except (ConnectionRefusedError, socket.timeout, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def launch_chrome(port: int = CDP_PORT, headless: bool = False, account: Optional[str] = None) -> subprocess.Popen | None:
|
||||
"""
|
||||
Launch Chrome with remote debugging enabled.
|
||||
|
||||
Args:
|
||||
port: CDP remote debugging port.
|
||||
headless: If True, launch Chrome in headless mode (no GUI window).
|
||||
account: Account name to use. If None, uses the default account.
|
||||
|
||||
Returns the Popen object if a new process was started, or None if Chrome
|
||||
was already running on the target port.
|
||||
"""
|
||||
global _chrome_process, _current_account
|
||||
|
||||
if is_port_open(port):
|
||||
print(f"[chrome_launcher] Chrome already running on port {port}.")
|
||||
return None
|
||||
|
||||
chrome_path = get_chrome_path()
|
||||
user_data_dir = get_user_data_dir(account)
|
||||
_current_account = account
|
||||
|
||||
cmd = [
|
||||
chrome_path,
|
||||
f"--remote-debugging-port={port}",
|
||||
f"--user-data-dir={user_data_dir}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
]
|
||||
|
||||
if headless:
|
||||
cmd.append("--headless=new")
|
||||
|
||||
mode_label = "headless" if headless else "headed"
|
||||
account_label = account or "default"
|
||||
print(f"[chrome_launcher] Launching Chrome ({mode_label}, account: {account_label})...")
|
||||
print(f" executable : {chrome_path}")
|
||||
print(f" profile dir: {user_data_dir}")
|
||||
print(f" debug port : {port}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
_chrome_process = proc
|
||||
|
||||
# Wait for the debug port to become available
|
||||
deadline = time.time() + STARTUP_TIMEOUT
|
||||
while time.time() < deadline:
|
||||
if is_port_open(port):
|
||||
print(f"[chrome_launcher] Chrome is ready on port {port}.")
|
||||
return proc
|
||||
time.sleep(0.5)
|
||||
|
||||
print(
|
||||
f"[chrome_launcher] WARNING: Chrome started but port {port} not responding "
|
||||
f"after {STARTUP_TIMEOUT}s. It may still be initializing.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return proc
|
||||
|
||||
|
||||
def kill_chrome(port: int = CDP_PORT):
|
||||
"""
|
||||
Kill the Chrome instance on the given debug port.
|
||||
|
||||
Tries multiple strategies:
|
||||
1. Send CDP Browser.close command via HTTP
|
||||
2. Terminate the tracked subprocess
|
||||
3. Kill by port on Windows (taskkill)
|
||||
"""
|
||||
global _chrome_process
|
||||
|
||||
# Strategy 1: CDP Browser.close
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
|
||||
if resp.ok:
|
||||
ws_url = resp.json().get("webSocketDebuggerUrl")
|
||||
if ws_url:
|
||||
import websockets.sync.client as ws_client
|
||||
ws = ws_client.connect(ws_url)
|
||||
ws.send('{"id":1,"method":"Browser.close"}')
|
||||
try:
|
||||
ws.recv(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
ws.close()
|
||||
print("[chrome_launcher] Sent Browser.close via CDP.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Wait briefly for Chrome to shut down
|
||||
time.sleep(1)
|
||||
|
||||
# Strategy 2: Terminate tracked subprocess
|
||||
if _chrome_process and _chrome_process.poll() is None:
|
||||
try:
|
||||
_chrome_process.terminate()
|
||||
_chrome_process.wait(timeout=5)
|
||||
print("[chrome_launcher] Terminated tracked Chrome process.")
|
||||
except Exception:
|
||||
try:
|
||||
_chrome_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_chrome_process = None
|
||||
|
||||
# Strategy 3: Windows taskkill by port (fallback)
|
||||
if sys.platform == "win32" and is_port_open(port):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["netstat", "-ano"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
if f":{port}" in line and "LISTENING" in line:
|
||||
pid = line.strip().split()[-1]
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/PID", pid],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
print(f"[chrome_launcher] Killed process {pid} via taskkill.")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Wait for port to be released
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
if not is_port_open(port):
|
||||
return
|
||||
time.sleep(0.5)
|
||||
|
||||
if is_port_open(port):
|
||||
print(f"[chrome_launcher] WARNING: port {port} still open after kill attempt.",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
def restart_chrome(port: int = CDP_PORT, headless: bool = False, account: Optional[str] = None) -> subprocess.Popen | None:
|
||||
"""
|
||||
Kill the current Chrome instance and relaunch with the specified mode.
|
||||
|
||||
Useful for switching between headless and headed mode (e.g. when login
|
||||
is needed during a headless session), or switching accounts.
|
||||
|
||||
Args:
|
||||
port: CDP remote debugging port.
|
||||
headless: If True, relaunch in headless mode.
|
||||
account: Account name to use. If None, uses the default account.
|
||||
|
||||
Returns the Popen object for the new Chrome process.
|
||||
"""
|
||||
account_label = account or "default"
|
||||
print(f"[chrome_launcher] Restarting Chrome ({'headless' if headless else 'headed'}, account: {account_label})...")
|
||||
kill_chrome(port)
|
||||
time.sleep(1)
|
||||
return launch_chrome(port, headless=headless, account=account)
|
||||
|
||||
|
||||
def ensure_chrome(port: int = CDP_PORT, headless: bool = False, account: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Ensure Chrome is running with remote debugging on the given port.
|
||||
|
||||
Args:
|
||||
port: CDP remote debugging port.
|
||||
headless: If True, launch in headless mode when starting a new instance.
|
||||
If Chrome is already running, this parameter is ignored.
|
||||
account: Account name to use. If None, uses the default account.
|
||||
|
||||
Returns True if Chrome is available, False otherwise.
|
||||
"""
|
||||
if is_port_open(port):
|
||||
return True
|
||||
try:
|
||||
launch_chrome(port, headless=headless, account=account)
|
||||
return is_port_open(port)
|
||||
except FileNotFoundError as e:
|
||||
print(f"[chrome_launcher] Error: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def get_current_account() -> Optional[str]:
|
||||
"""Get the name of the currently active account."""
|
||||
return _current_account
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Chrome Launcher for CDP")
|
||||
parser.add_argument("--headless", action="store_true", help="Launch in headless mode")
|
||||
parser.add_argument("--kill", action="store_true", help="Kill the running Chrome instance")
|
||||
parser.add_argument("--restart", action="store_true", help="Restart Chrome")
|
||||
parser.add_argument("--account", help="Account name to use (default: default account)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.kill:
|
||||
kill_chrome()
|
||||
print("[chrome_launcher] Chrome killed.")
|
||||
elif args.restart:
|
||||
restart_chrome(headless=args.headless, account=args.account)
|
||||
print("[chrome_launcher] Chrome restarted.")
|
||||
elif ensure_chrome(headless=args.headless, account=args.account):
|
||||
print("[chrome_launcher] Chrome is ready for CDP connections.")
|
||||
else:
|
||||
print("[chrome_launcher] Failed to start Chrome.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
5
skills/post-to-xhs/scripts/content.txt
Normal file
5
skills/post-to-xhs/scripts/content.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
微软向 Canary 通道推送了 Windows 11 Insider Preview Build 28020.1546 更新,补丁编号 KB5074176。
|
||||
|
||||
本次更新为常规改进与修复,属于小幅迭代更新,没有重大功能变化。
|
||||
|
||||
Canary 通道是 Windows Insider 最前沿的测试分支,适合愿意尝鲜和接受不稳定性的用户。
|
||||
141
skills/post-to-xhs/scripts/image_downloader.py
Normal file
141
skills/post-to-xhs/scripts/image_downloader.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Image downloader for Xiaohongshu publishing.
|
||||
|
||||
Downloads images from URLs to a local temp directory for upload,
|
||||
and cleans up after publishing is complete.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
import uuid
|
||||
from urllib.parse import urlparse, unquote
|
||||
|
||||
import requests
|
||||
|
||||
DEFAULT_TIMEOUT = 30 # seconds per download
|
||||
TEMP_DIR_PREFIX = "xhs_images_"
|
||||
|
||||
|
||||
class ImageDownloader:
|
||||
"""Download images from URLs and manage a temporary directory for them."""
|
||||
|
||||
def __init__(self, temp_dir: str | None = None):
|
||||
if temp_dir:
|
||||
self.temp_dir = temp_dir
|
||||
os.makedirs(self.temp_dir, exist_ok=True)
|
||||
self._owns_dir = False
|
||||
else:
|
||||
self.temp_dir = tempfile.mkdtemp(prefix=TEMP_DIR_PREFIX)
|
||||
self._owns_dir = True
|
||||
self.downloaded_files: list[str] = []
|
||||
|
||||
def _guess_extension(self, url: str, content_type: str | None) -> str:
|
||||
"""Guess file extension from URL path or Content-Type header."""
|
||||
# Try URL path first
|
||||
path = urlparse(url).path
|
||||
_, ext = os.path.splitext(unquote(path))
|
||||
if ext and ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"):
|
||||
return ext.lower()
|
||||
|
||||
# Fall back to Content-Type
|
||||
ct_map = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp",
|
||||
}
|
||||
if content_type:
|
||||
for mime, ext in ct_map.items():
|
||||
if mime in content_type:
|
||||
return ext
|
||||
|
||||
return ".jpg" # safe default
|
||||
|
||||
def download(self, url: str, referer: str | None = None) -> str:
|
||||
"""
|
||||
Download a single image and return the local file path.
|
||||
|
||||
Args:
|
||||
url: Image URL to download
|
||||
referer: Optional Referer header. If None, auto-generates from URL domain.
|
||||
|
||||
Raises requests.RequestException on network errors.
|
||||
"""
|
||||
# Build headers with Referer to bypass hotlink protection
|
||||
parsed = urlparse(url)
|
||||
if referer is None:
|
||||
referer = f"{parsed.scheme}://{parsed.netloc}/"
|
||||
|
||||
headers = {
|
||||
"Referer": referer,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
resp = requests.get(url, timeout=DEFAULT_TIMEOUT, stream=True, headers=headers)
|
||||
resp.raise_for_status()
|
||||
|
||||
ext = self._guess_extension(url, resp.headers.get("Content-Type"))
|
||||
filename = f"{uuid.uuid4().hex[:12]}{ext}"
|
||||
filepath = os.path.join(self.temp_dir, filename)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
self.downloaded_files.append(filepath)
|
||||
print(f"[image_downloader] Downloaded: {url}")
|
||||
print(f" -> {filepath} ({os.path.getsize(filepath)} bytes)")
|
||||
return filepath
|
||||
|
||||
def download_all(self, urls: list[str]) -> list[str]:
|
||||
"""
|
||||
Download multiple images. Returns list of local file paths.
|
||||
|
||||
Skips URLs that fail to download (logs the error, continues).
|
||||
"""
|
||||
paths = []
|
||||
for url in urls:
|
||||
try:
|
||||
path = self.download(url)
|
||||
paths.append(path)
|
||||
except Exception as e:
|
||||
print(f"[image_downloader] Failed to download {url}: {e}", file=sys.stderr)
|
||||
return paths
|
||||
|
||||
def cleanup(self):
|
||||
"""Remove all downloaded files and the temp directory."""
|
||||
if self._owns_dir and os.path.isdir(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
print(f"[image_downloader] Cleaned up temp dir: {self.temp_dir}")
|
||||
else:
|
||||
for f in self.downloaded_files:
|
||||
try:
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"[image_downloader] Cleaned up {len(self.downloaded_files)} files.")
|
||||
self.downloaded_files.clear()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test: download URLs passed as command-line arguments
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python image_downloader.py <url1> [url2] ...")
|
||||
sys.exit(1)
|
||||
|
||||
dl = ImageDownloader()
|
||||
paths = dl.download_all(sys.argv[1:])
|
||||
print(f"\nDownloaded {len(paths)} image(s):")
|
||||
for p in paths:
|
||||
print(f" {p}")
|
||||
print(f"Temp dir: {dl.temp_dir}")
|
||||
print("Files will remain until manually cleaned up.")
|
||||
238
skills/post-to-xhs/scripts/publish_pipeline.py
Normal file
238
skills/post-to-xhs/scripts/publish_pipeline.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Unified publish pipeline for Xiaohongshu.
|
||||
|
||||
Single CLI entry point that orchestrates:
|
||||
chrome_launcher → login check → image download → form fill → (optional) publish
|
||||
|
||||
Usage:
|
||||
# Fill form only (default) - review in browser before publishing
|
||||
python publish_pipeline.py --title "标题" --content "正文" --image-urls URL1 URL2
|
||||
python publish_pipeline.py --title-file t.txt --content-file body.txt --image-urls URL1
|
||||
|
||||
# Headless mode (no GUI window) - faster for automated publishing
|
||||
python publish_pipeline.py --headless --title-file t.txt --content-file body.txt --image-urls URL1
|
||||
|
||||
# Publish to a specific account
|
||||
python publish_pipeline.py --account myaccount --title "标题" --content "正文" --image-urls URL1
|
||||
|
||||
# Fill and auto-publish in one step
|
||||
python publish_pipeline.py --title "标题" --content "正文" --image-urls URL1 --auto-publish
|
||||
|
||||
# Use local image files instead of URLs
|
||||
python publish_pipeline.py --title "标题" --content "正文" --images img1.jpg img2.jpg
|
||||
|
||||
# Long article mode (images optional)
|
||||
python publish_pipeline.py --mode long-article --title "标题" --content "正文"
|
||||
python publish_pipeline.py --mode long-article --title "标题" --content "正文" --images img1.jpg
|
||||
|
||||
Exit codes:
|
||||
0 = success (READY_TO_PUBLISH or PUBLISHED)
|
||||
1 = not logged in (NOT_LOGGED_IN) - headless auto-fallback will restart headed
|
||||
2 = error (see stderr)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure UTF-8 output on Windows consoles
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add scripts dir to path so sibling modules can be imported
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
|
||||
from chrome_launcher import ensure_chrome, restart_chrome
|
||||
from cdp_publish import XiaohongshuPublisher, CDPError
|
||||
from image_downloader import ImageDownloader
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Xiaohongshu publish pipeline - unified entry point"
|
||||
)
|
||||
|
||||
# Title
|
||||
title_group = parser.add_mutually_exclusive_group(required=True)
|
||||
title_group.add_argument("--title", help="Article title text")
|
||||
title_group.add_argument("--title-file", help="Read title from UTF-8 file")
|
||||
|
||||
# Content
|
||||
content_group = parser.add_mutually_exclusive_group(required=True)
|
||||
content_group.add_argument("--content", help="Article body text")
|
||||
content_group.add_argument("--content-file", help="Read content from UTF-8 file")
|
||||
|
||||
# Mode
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["image-text", "long-article"],
|
||||
default="image-text",
|
||||
help="Publish mode: 'image-text' (default) or 'long-article'",
|
||||
)
|
||||
|
||||
# Images (required for image-text, optional for long-article)
|
||||
img_group = parser.add_mutually_exclusive_group(required=False)
|
||||
img_group.add_argument(
|
||||
"--image-urls", nargs="+", help="Image URLs to download"
|
||||
)
|
||||
img_group.add_argument(
|
||||
"--images", nargs="+", help="Local image file paths"
|
||||
)
|
||||
|
||||
# Publish mode
|
||||
parser.add_argument(
|
||||
"--auto-publish",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Click publish button after filling (default: fill only)",
|
||||
)
|
||||
|
||||
# Headless mode
|
||||
parser.add_argument(
|
||||
"--headless",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run Chrome in headless mode (no GUI). Auto-falls back to headed if login is needed.",
|
||||
)
|
||||
|
||||
# Optional temp dir for downloaded images
|
||||
parser.add_argument(
|
||||
"--temp-dir",
|
||||
default=None,
|
||||
help="Directory for downloaded images (default: auto-created temp dir)",
|
||||
)
|
||||
|
||||
# Account selection
|
||||
parser.add_argument(
|
||||
"--account",
|
||||
default=None,
|
||||
help="Account name to publish to (default: default account)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
headless = args.headless
|
||||
account = args.account
|
||||
|
||||
# --- Resolve title ---
|
||||
if args.title_file:
|
||||
with open(args.title_file, encoding="utf-8") as f:
|
||||
title = f.read().strip()
|
||||
else:
|
||||
title = args.title
|
||||
|
||||
if not title:
|
||||
print("Error: title is empty.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# --- Resolve content ---
|
||||
if args.content_file:
|
||||
with open(args.content_file, encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
else:
|
||||
content = args.content
|
||||
|
||||
if not content:
|
||||
print("Error: content is empty.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# --- Step 1: Ensure Chrome is running ---
|
||||
mode_label = "headless" if headless else "headed"
|
||||
account_label = account or "default"
|
||||
print(f"[pipeline] Step 1: Ensuring Chrome is running ({mode_label}, account: {account_label})...")
|
||||
if not ensure_chrome(headless=headless, account=account):
|
||||
print("Error: Failed to start Chrome.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# --- Step 2: Connect and check login ---
|
||||
print("[pipeline] Step 2: Checking login status...")
|
||||
publisher = XiaohongshuPublisher()
|
||||
try:
|
||||
publisher.connect()
|
||||
logged_in = publisher.check_login()
|
||||
if not logged_in:
|
||||
publisher.disconnect()
|
||||
if headless:
|
||||
# Auto-fallback: restart Chrome in headed mode for QR login
|
||||
print("[pipeline] Headless mode: not logged in. Switching to headed mode for login...")
|
||||
restart_chrome(headless=False, account=account)
|
||||
publisher.connect()
|
||||
publisher.open_login_page()
|
||||
print("NOT_LOGGED_IN")
|
||||
sys.exit(1)
|
||||
except CDPError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# --- Step 3: Prepare images ---
|
||||
image_paths = []
|
||||
downloader = None
|
||||
|
||||
if args.image_urls:
|
||||
print(f"[pipeline] Step 3: Downloading {len(args.image_urls)} image(s)...")
|
||||
downloader = ImageDownloader(temp_dir=args.temp_dir)
|
||||
image_paths = downloader.download_all(args.image_urls)
|
||||
if not image_paths:
|
||||
print("Error: All image downloads failed.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
elif args.images:
|
||||
image_paths = args.images
|
||||
# Verify local files exist
|
||||
for p in image_paths:
|
||||
if not os.path.isfile(p):
|
||||
print(f"Error: Image file not found: {p}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(f"[pipeline] Step 3: Using {len(image_paths)} local image(s).")
|
||||
elif args.mode == "image-text":
|
||||
print("Error: Images are required for image-text mode. Use --image-urls or --images.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
else:
|
||||
print("[pipeline] Step 3: No images (optional for long-article mode).")
|
||||
|
||||
# --- Step 4: Fill form ---
|
||||
print("[pipeline] Step 4: Filling form...")
|
||||
try:
|
||||
if args.mode == "long-article":
|
||||
publisher.publish_long_article(
|
||||
title=title,
|
||||
content=content,
|
||||
image_paths=image_paths or None,
|
||||
)
|
||||
print("LONG_ARTICLE_STATUS: TEMPLATE_SELECTION")
|
||||
else:
|
||||
publisher.publish(title=title, content=content, image_paths=image_paths)
|
||||
print("FILL_STATUS: READY_TO_PUBLISH")
|
||||
except CDPError as e:
|
||||
print(f"Error during form fill: {e}", file=sys.stderr)
|
||||
if downloader:
|
||||
downloader.cleanup()
|
||||
sys.exit(2)
|
||||
|
||||
# --- Step 5: Publish (optional, image-text mode only) ---
|
||||
if args.auto_publish and args.mode == "image-text":
|
||||
print("[pipeline] Step 5: Clicking publish button...")
|
||||
try:
|
||||
publisher._click_publish()
|
||||
print("PUBLISH_STATUS: PUBLISHED")
|
||||
except CDPError as e:
|
||||
print(f"Error clicking publish: {e}", file=sys.stderr)
|
||||
if downloader:
|
||||
downloader.cleanup()
|
||||
sys.exit(2)
|
||||
|
||||
# --- Cleanup ---
|
||||
publisher.disconnect()
|
||||
if downloader:
|
||||
downloader.cleanup()
|
||||
|
||||
print("[pipeline] Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
skills/post-to-xhs/scripts/title.txt
Normal file
1
skills/post-to-xhs/scripts/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Win11 Build 28020 Canary通道更新
|
||||
Reference in New Issue
Block a user