feat: add long article publish mode to post-to-xhs skill
Add "写长文" workflow with template selection support: - cdp_publish.py: new commands (long-article, select-template, click-next-step) - publish_pipeline.py: add --mode parameter (image-text / long-article) - SKILL.md: document long article flow (B.1-B.5 steps) - publish-workflow.md: add long article selectors, CLI usage, detailed steps
This commit is contained in:
@@ -1,25 +1,30 @@
|
||||
---
|
||||
name: post-to-xhs
|
||||
description: >
|
||||
小红书内容发布技能。支持两种输入方式:(1) 用户提供完整内容和图片/图片URL,直接发布;
|
||||
(2) 用户提供网页URL,自动提取内容和图片,适当总结后发布。如果从URL提取不到图片,
|
||||
提示用户手动下载并提供。适用于任何类型的内容发布。
|
||||
小红书内容发布技能。支持两种发布模式:(1) 上传图文模式 - 图片+短文;(2) 写长文模式 - 长篇文章+排版模板。
|
||||
支持两种输入方式:用户提供完整内容和图片/图片URL,直接发布;或提供网页URL,自动提取内容和图片。
|
||||
用户说"发长文"时使用长文模式,否则默认图文模式。
|
||||
---
|
||||
|
||||
# 小红书内容发布
|
||||
|
||||
根据用户输入自动判断发布方式,简化发布流程。
|
||||
根据用户输入自动判断发布方式和发布模式,简化发布流程。
|
||||
|
||||
## 发布模式
|
||||
|
||||
- **上传图文**(默认):图片 + 短文,适合日常分享
|
||||
- **写长文**:长篇文章 + 排版模板选择,适合深度内容。用户明确说"发长文"时使用
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
用户输入
|
||||
│
|
||||
├─ 完整内容 + 图片/图片URL → 直接进入发布流程
|
||||
├─ 完整内容 + 图片/图片URL → 判断模式 → 发布流程
|
||||
│
|
||||
└─ 网页 URL → WebFetch 提取内容和图片
|
||||
│
|
||||
├─ 有图片 → 适当总结内容 → 发布流程
|
||||
├─ 有图片 → 适当总结内容 → 判断模式 → 发布流程
|
||||
│
|
||||
└─ 无图片 → 提示用户手动下载图片
|
||||
│
|
||||
@@ -116,7 +121,9 @@ AskUserQuestion 示例:
|
||||
|
||||
将标题和正文写入临时 UTF-8 文本文件。不要在 `python -c` 中内联中文文本。
|
||||
|
||||
### 4.4 运行 Pipeline
|
||||
### 4.4 运行发布(根据模式分流)
|
||||
|
||||
#### A. 上传图文模式(默认)
|
||||
|
||||
根据用户选择的模式执行发布脚本:
|
||||
|
||||
@@ -144,11 +151,44 @@ python ... --images "C:\path\to\image.jpg"
|
||||
- `READY_TO_PUBLISH` (exit code 0) → 根据模式进入下一步
|
||||
- Exit code 2 → 报告错误
|
||||
|
||||
### 4.5 用户预览确认(仅有窗口模式)
|
||||
#### B. 写长文模式
|
||||
|
||||
**仅当用户选择有窗口模式时**,使用 `AskUserQuestion` 请用户在浏览器中检查预览,确认后再发布。
|
||||
**Step B.1 — 填写长文内容 + 一键排版:**
|
||||
|
||||
无头模式跳过此步骤,直接进入 4.6。
|
||||
```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 点击发布
|
||||
|
||||
@@ -164,8 +204,10 @@ python "C:\Users\admin\AI\.claude\skills\post-to-xhs\scripts\cdp_publish.py" cli
|
||||
|
||||
## 重要提示
|
||||
|
||||
- **绝不自动发布** - 必须在 Step 4.4 获得用户确认
|
||||
- **图片必须有** - 小红书发布必须有图片,没有图片不能发布
|
||||
- **绝不自动发布** - 必须获得用户确认
|
||||
- **图片要求** - 上传图文模式必须有图片;写长文模式图片可选
|
||||
- **长文模式** - 必须让用户选择模板,不要自动选择
|
||||
- **正文描述** - 长文模式的发布页有独立正文描述框,超过 1000 字需压缩到 800 字左右
|
||||
- **无头模式**:使用 `--headless` 参数自动化发布。如需登录,脚本自动切换到有窗口模式
|
||||
- 如果页面结构变化导致选择器失效,参考 `references/publish-workflow.md` 更新
|
||||
|
||||
|
||||
@@ -10,10 +10,16 @@
|
||||
|
||||
## 流程概览
|
||||
|
||||
**上传图文模式**:
|
||||
```
|
||||
生成文案 → 用户确认 → 启动 Chrome → 检查登录 → 导航发布页 → 上传图片 → 填写标题 → 填写正文 → 用户确认发布
|
||||
```
|
||||
|
||||
**写长文模式**:
|
||||
```
|
||||
生成文案 → 用户确认 → 启动 Chrome → 检查登录 → 导航发布页 → 点击"写长文"tab → 点击"新的创作" → 填写标题 → 填写正文 → 一键排版 → 用户选择模板 → 下一步 → 填写发布页正文描述 → 用户确认发布
|
||||
```
|
||||
|
||||
## 详细步骤
|
||||
|
||||
### 1. 启动 / 连接 Chrome
|
||||
@@ -78,6 +84,64 @@
|
||||
- 用户确认后,脚本点击发布按钮
|
||||
- 或用户选择手动点击发布按钮
|
||||
|
||||
## 写长文模式详细步骤
|
||||
|
||||
### 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` 字典。
|
||||
@@ -85,9 +149,16 @@
|
||||
| 元素 | 主选择器 | 备选选择器 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 图片上传 | `input.upload-input` | `input[type="file"]` | 隐藏的文件输入,通过 CDP 直接操作 |
|
||||
| 标题输入 | `input[placeholder*="填写标题"]` | `input.d-text` | 标题输入框 |
|
||||
| 标题输入(图文) | `input[placeholder*="填写标题"]` | `input.d-text` | 图文模式标题输入框 |
|
||||
| 标题输入(长文) | `textarea.d-text[placeholder="输入标题"]` | - | 长文模式 textarea 标题 |
|
||||
| 正文编辑 | `div.tiptap.ProseMirror` | `div.ProseMirror[contenteditable="true"]` | TipTap/ProseMirror 富文本编辑器 |
|
||||
| 发布按钮 | 文本匹配"发布"(`button` + `.d-button-content .d-text`) | - | 通过遍历按钮文本定位 |
|
||||
| 发布按钮 | 文本匹配"发布" | - | 通过遍历按钮文本定位 |
|
||||
| 写长文 tab | 文本匹配"写长文"(`div.creator-tab`) | - | 长文模式入口 |
|
||||
| 新的创作按钮 | 文本匹配"新的创作" | - | 长文编辑器入口 |
|
||||
| 一键排版按钮 | 文本匹配"一键排版" | - | 触发模板选择 |
|
||||
| 模板卡片 | `.template-card` | `.template-card.selected`(已选) | 排版模板列表 |
|
||||
| 模板名称 | `.template-card .template-title` | - | 模板卡片内的名称 span |
|
||||
| 下一步按钮 | 文本匹配"下一步" | - | 模板选择后进入发布页 |
|
||||
| 登录检测 | URL 包含 "login" | `.user-info, .creator-header` | 重定向检测 + DOM 元素检测 |
|
||||
|
||||
## 选择器维护指南
|
||||
@@ -140,7 +211,7 @@ python publish_pipeline.py --headless --title "标题" --content "正文" --imag
|
||||
- 退出码 1 + `NOT_LOGGED_IN` = 未登录,需扫码(无头模式下会自动切换到有窗口模式)
|
||||
- 退出码 2 = 其他错误
|
||||
|
||||
### 方式 B: 分步调用
|
||||
### 方式 B: 分步调用(图文模式)
|
||||
|
||||
```bash
|
||||
# 1. 启动 Chrome(可选 --headless)
|
||||
@@ -162,6 +233,36 @@ 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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,12 +5,16 @@ Connects to a Chrome instance via Chrome DevTools Protocol to automate
|
||||
publishing articles on Xiaohongshu (RED) creator center.
|
||||
|
||||
CLI usage:
|
||||
# Basic commands
|
||||
# Basic commands (image-text mode)
|
||||
python cdp_publish.py check-login [--headless] [--account NAME]
|
||||
python cdp_publish.py fill --title "标题" --content "正文" --images img1.jpg [--headless] [--account NAME]
|
||||
python cdp_publish.py publish --title "标题" --content "正文" --images img1.jpg [--headless] [--account NAME]
|
||||
python cdp_publish.py click-publish [--headless] [--account NAME]
|
||||
|
||||
# Long article mode
|
||||
python cdp_publish.py long-article --title "标题" --content "正文" [--images img1.jpg] [--account NAME]
|
||||
python cdp_publish.py click-next-step [--account NAME]
|
||||
|
||||
# Account management
|
||||
python cdp_publish.py login [--account NAME] # open browser for QR login
|
||||
python cdp_publish.py re-login [--account NAME] # clear cookies and re-login same account
|
||||
@@ -81,6 +85,13 @@ SELECTORS = {
|
||||
"publish_button_text": "发布",
|
||||
# Login indicator - URL-based check (redirect to /login if not logged in)
|
||||
"login_indicator": '.user-info, .creator-header, [class*="user"]',
|
||||
# Long article mode
|
||||
"long_article_tab_text": "写长文",
|
||||
"new_creation_btn_text": "新的创作",
|
||||
"long_title_input": 'textarea.d-text[placeholder="输入标题"]',
|
||||
"auto_format_btn_text": "一键排版",
|
||||
"next_step_btn_text": "下一步",
|
||||
"template_card": ".template-card",
|
||||
}
|
||||
|
||||
# Timing
|
||||
@@ -88,6 +99,8 @@ PAGE_LOAD_WAIT = 3 # seconds to wait after navigation
|
||||
TAB_CLICK_WAIT = 2 # seconds to wait after clicking tab
|
||||
UPLOAD_WAIT = 6 # seconds to wait after image upload for editor to appear
|
||||
ACTION_INTERVAL = 1 # seconds between actions
|
||||
AUTO_FORMAT_WAIT = 5 # seconds to wait after clicking auto-format
|
||||
TEMPLATE_WAIT = 10 # seconds max to wait for template cards to appear
|
||||
|
||||
|
||||
class CDPError(Exception):
|
||||
@@ -450,7 +463,305 @@ class XiaohongshuPublisher:
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main publish workflow
|
||||
# Long article actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _click_long_article_tab(self):
|
||||
"""Click the '写长文' tab to switch to long article mode."""
|
||||
print("[cdp_publish] Clicking '写长文' tab...")
|
||||
tab_text = SELECTORS["long_article_tab_text"]
|
||||
selector = SELECTORS["image_text_tab"] # same container: div.creator-tab
|
||||
|
||||
clicked = self._evaluate(f"""
|
||||
(function() {{
|
||||
var tabs = document.querySelectorAll('{selector}');
|
||||
for (var i = 0; i < tabs.length; i++) {{
|
||||
if (tabs[i].textContent.trim() === '{tab_text}') {{
|
||||
tabs[i].click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}})();
|
||||
""")
|
||||
|
||||
if not clicked:
|
||||
raise CDPError(
|
||||
f"Could not find '{tab_text}' tab. "
|
||||
"The page structure may have changed."
|
||||
)
|
||||
|
||||
print("[cdp_publish] '写长文' tab clicked.")
|
||||
time.sleep(TAB_CLICK_WAIT)
|
||||
|
||||
def _click_new_creation(self):
|
||||
"""Click the '新的创作' button to start a new long article."""
|
||||
print("[cdp_publish] Clicking '新的创作' button...")
|
||||
btn_text = SELECTORS["new_creation_btn_text"]
|
||||
|
||||
clicked = self._evaluate(f"""
|
||||
(function() {{
|
||||
// Search all elements for text match
|
||||
var candidates = document.querySelectorAll(
|
||||
'.center span, .center div, .center button, .center a, '
|
||||
+ 'button, [role="button"], [class*="btn"], [class*="creation"]'
|
||||
);
|
||||
for (var i = 0; i < candidates.length; i++) {{
|
||||
if (candidates[i].textContent.trim() === '{btn_text}') {{
|
||||
candidates[i].click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}})();
|
||||
""")
|
||||
|
||||
if not clicked:
|
||||
raise CDPError(
|
||||
f"Could not find '{btn_text}' button. "
|
||||
"The page structure may have changed."
|
||||
)
|
||||
|
||||
print("[cdp_publish] '新的创作' button clicked.")
|
||||
time.sleep(PAGE_LOAD_WAIT)
|
||||
|
||||
def _fill_long_title(self, title: str):
|
||||
"""Fill in the long article title (textarea element)."""
|
||||
print(f"[cdp_publish] Setting long article title: {title[:40]}...")
|
||||
time.sleep(ACTION_INTERVAL)
|
||||
|
||||
selector = SELECTORS["long_title_input"]
|
||||
found = self._evaluate(f"!!document.querySelector('{selector}')")
|
||||
if not found:
|
||||
raise CDPError(
|
||||
f"Could not find long title textarea ('{selector}'). "
|
||||
"The page structure may have changed."
|
||||
)
|
||||
|
||||
escaped_title = json.dumps(title)
|
||||
self._evaluate(f"""
|
||||
(function() {{
|
||||
var el = document.querySelector('{selector}');
|
||||
var nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype, 'value'
|
||||
).set;
|
||||
el.focus();
|
||||
nativeSetter.call(el, {escaped_title});
|
||||
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
||||
}})();
|
||||
""")
|
||||
print("[cdp_publish] Long article title set.")
|
||||
|
||||
def _click_auto_format(self):
|
||||
"""Click the '一键排版' button."""
|
||||
print("[cdp_publish] Clicking '一键排版' button...")
|
||||
btn_text = SELECTORS["auto_format_btn_text"]
|
||||
|
||||
clicked = self._evaluate(f"""
|
||||
(function() {{
|
||||
var elems = document.querySelectorAll(
|
||||
'button, [role="button"], span, div, a, [class*="btn"]'
|
||||
);
|
||||
for (var i = 0; i < elems.length; i++) {{
|
||||
if (elems[i].textContent.trim() === '{btn_text}') {{
|
||||
elems[i].click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}})();
|
||||
""")
|
||||
|
||||
if not clicked:
|
||||
raise CDPError(
|
||||
f"Could not find '{btn_text}' button. "
|
||||
"The page structure may have changed."
|
||||
)
|
||||
|
||||
print("[cdp_publish] '一键排版' button clicked. Waiting for templates...")
|
||||
time.sleep(AUTO_FORMAT_WAIT)
|
||||
|
||||
def _wait_for_templates(self) -> bool:
|
||||
"""Wait for template cards to appear after clicking auto-format."""
|
||||
print("[cdp_publish] Waiting for template cards to load...")
|
||||
selector = SELECTORS["template_card"]
|
||||
|
||||
for attempt in range(TEMPLATE_WAIT):
|
||||
found = self._evaluate(
|
||||
f"document.querySelectorAll('{selector}').length"
|
||||
)
|
||||
if found and found > 0:
|
||||
print(f"[cdp_publish] Found {found} template card(s).")
|
||||
return True
|
||||
time.sleep(1)
|
||||
|
||||
print("[cdp_publish] Warning: No template cards found within timeout.")
|
||||
return False
|
||||
|
||||
def get_template_names(self) -> list[str]:
|
||||
"""Get the list of available template names from the page."""
|
||||
selector = SELECTORS["template_card"]
|
||||
names = self._evaluate(f"""
|
||||
(function() {{
|
||||
var cards = document.querySelectorAll('{selector}');
|
||||
var names = [];
|
||||
for (var i = 0; i < cards.length; i++) {{
|
||||
var title = cards[i].querySelector('.template-title');
|
||||
names.push(title ? title.textContent.trim() : 'Template ' + i);
|
||||
}}
|
||||
return names;
|
||||
}})();
|
||||
""")
|
||||
return names or []
|
||||
|
||||
def select_template(self, name: str) -> bool:
|
||||
"""Select a template by clicking the card with the matching name."""
|
||||
print(f"[cdp_publish] Selecting template: {name}...")
|
||||
selector = SELECTORS["template_card"]
|
||||
|
||||
clicked = self._evaluate(f"""
|
||||
(function() {{
|
||||
var cards = document.querySelectorAll('{selector}');
|
||||
for (var i = 0; i < cards.length; i++) {{
|
||||
var title = cards[i].querySelector('.template-title');
|
||||
if (title && title.textContent.trim() === {json.dumps(name)}) {{
|
||||
cards[i].click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}})();
|
||||
""")
|
||||
|
||||
if clicked:
|
||||
print(f"[cdp_publish] Template '{name}' selected.")
|
||||
time.sleep(ACTION_INTERVAL)
|
||||
else:
|
||||
print(f"[cdp_publish] Warning: Template '{name}' not found.")
|
||||
|
||||
return bool(clicked)
|
||||
|
||||
def _click_next_step(self):
|
||||
"""Click the '下一步' button."""
|
||||
print("[cdp_publish] Clicking '下一步' button...")
|
||||
btn_text = SELECTORS["next_step_btn_text"]
|
||||
|
||||
clicked = self._evaluate(f"""
|
||||
(function() {{
|
||||
var elems = document.querySelectorAll(
|
||||
'button, [role="button"], span, div, a, [class*="btn"]'
|
||||
);
|
||||
for (var i = 0; i < elems.length; i++) {{
|
||||
if (elems[i].textContent.trim() === '{btn_text}') {{
|
||||
elems[i].click();
|
||||
return true;
|
||||
}}
|
||||
}}
|
||||
return false;
|
||||
}})();
|
||||
""")
|
||||
|
||||
if not clicked:
|
||||
raise CDPError(
|
||||
f"Could not find '{btn_text}' button. "
|
||||
"The page structure may have changed."
|
||||
)
|
||||
|
||||
print("[cdp_publish] '下一步' button clicked.")
|
||||
time.sleep(PAGE_LOAD_WAIT)
|
||||
|
||||
def publish_long_article(
|
||||
self,
|
||||
title: str,
|
||||
content: str,
|
||||
image_paths: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Execute the full long article publish workflow:
|
||||
1. Navigate to creator publish page
|
||||
2. Click '写长文' tab
|
||||
3. Click '新的创作' button
|
||||
4. Fill title (textarea)
|
||||
5. Fill content (TipTap editor)
|
||||
6. (Optional) Insert images into editor
|
||||
7. Click '一键排版'
|
||||
8. Wait for templates
|
||||
|
||||
Returns list of available template names for the caller to
|
||||
present to the user for selection.
|
||||
|
||||
Args:
|
||||
title: Article title
|
||||
content: Article body text (paragraphs separated by newlines)
|
||||
image_paths: Optional list of local file paths to images
|
||||
"""
|
||||
if not self.ws:
|
||||
raise CDPError("Not connected. Call connect() first.")
|
||||
|
||||
# Step 1: Navigate to publish page
|
||||
self._navigate(XHS_CREATOR_URL)
|
||||
time.sleep(2)
|
||||
|
||||
# Step 2: Click '写长文' tab
|
||||
self._click_long_article_tab()
|
||||
|
||||
# Step 3: Click '新的创作'
|
||||
self._click_new_creation()
|
||||
|
||||
# Step 4: Fill title
|
||||
self._fill_long_title(title)
|
||||
|
||||
# Step 5: Fill content
|
||||
self._fill_content(content)
|
||||
|
||||
# Step 6: Upload images into editor (if provided)
|
||||
if image_paths:
|
||||
print(f"[cdp_publish] Inserting {len(image_paths)} image(s) into editor...")
|
||||
for img_path in image_paths:
|
||||
normalized = img_path.replace("\\", "/")
|
||||
self._evaluate(f"""
|
||||
(function() {{
|
||||
var editor = document.querySelector('{SELECTORS["content_editor"]}');
|
||||
if (!editor) return false;
|
||||
var img = document.createElement('img');
|
||||
img.src = 'file:///{normalized}';
|
||||
editor.appendChild(img);
|
||||
editor.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
return true;
|
||||
}})();
|
||||
""")
|
||||
time.sleep(ACTION_INTERVAL)
|
||||
|
||||
# Step 7: Click '一键排版'
|
||||
self._click_auto_format()
|
||||
|
||||
# Step 8: Wait for templates and return names
|
||||
self._wait_for_templates()
|
||||
template_names = self.get_template_names()
|
||||
|
||||
print(
|
||||
"\n[cdp_publish] Templates loaded.\n"
|
||||
" Available templates: " + ", ".join(template_names) + "\n"
|
||||
)
|
||||
return template_names
|
||||
|
||||
def click_next_and_prepare_publish(self, content: str = ""):
|
||||
"""After user selects a template, click '下一步' and fill the publish page description."""
|
||||
self._click_next_step()
|
||||
|
||||
# The publish page has a separate content editor for the post description
|
||||
if content:
|
||||
time.sleep(ACTION_INTERVAL)
|
||||
self._fill_content(content)
|
||||
|
||||
print(
|
||||
"\n[cdp_publish] Ready to publish.\n"
|
||||
" Please review in the browser before confirming publish.\n"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main publish workflow (image-text mode)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def publish(
|
||||
@@ -532,6 +843,23 @@ def main():
|
||||
p_pub.add_argument("--content-file", default=None, help="Read content from file")
|
||||
p_pub.add_argument("--images", nargs="+", required=True)
|
||||
|
||||
# long-article - long article mode
|
||||
p_long = sub.add_parser("long-article", help="Fill long article content with auto-format and template selection")
|
||||
p_long.add_argument("--title", default=None)
|
||||
p_long.add_argument("--title-file", default=None, help="Read title from file")
|
||||
p_long.add_argument("--content", default=None)
|
||||
p_long.add_argument("--content-file", default=None, help="Read content from file")
|
||||
p_long.add_argument("--images", nargs="+", default=None, help="Optional image file paths")
|
||||
|
||||
# select-template - select a template by name
|
||||
p_tpl = sub.add_parser("select-template", help="Select a long article template by name")
|
||||
p_tpl.add_argument("--name", required=True, help="Template name to select")
|
||||
|
||||
# click-next-step - click next step button (for long article after template selection)
|
||||
p_next = sub.add_parser("click-next-step", help="Click '下一步' button after template selection")
|
||||
p_next.add_argument("--content", default=None, help="Post description text")
|
||||
p_next.add_argument("--content-file", default=None, help="Read post description from file")
|
||||
|
||||
# click-publish - just click the publish button on current page
|
||||
sub.add_parser("click-publish", help="Click publish button on already-filled page")
|
||||
|
||||
@@ -648,6 +976,48 @@ def main():
|
||||
publisher._click_publish()
|
||||
print("PUBLISH_STATUS: PUBLISHED")
|
||||
|
||||
elif args.command == "long-article":
|
||||
title = args.title
|
||||
if args.title_file:
|
||||
with open(args.title_file, encoding="utf-8") as f:
|
||||
title = f.read().strip()
|
||||
if not title:
|
||||
print("Error: --title or --title-file required.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
content = args.content
|
||||
if args.content_file:
|
||||
with open(args.content_file, encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
if not content:
|
||||
print("Error: --content or --content-file required.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
publisher.connect()
|
||||
template_names = publisher.publish_long_article(
|
||||
title=title, content=content, image_paths=args.images,
|
||||
)
|
||||
# Print template names as JSON for programmatic consumption
|
||||
print("TEMPLATES: " + json.dumps(template_names, ensure_ascii=False))
|
||||
print("LONG_ARTICLE_STATUS: TEMPLATE_SELECTION")
|
||||
|
||||
elif args.command == "select-template":
|
||||
publisher.connect(target_url_prefix="https://creator.xiaohongshu.com/publish")
|
||||
if publisher.select_template(args.name):
|
||||
print(f"TEMPLATE_SELECTED: {args.name}")
|
||||
else:
|
||||
print(f"Error: Template '{args.name}' not found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif args.command == "click-next-step":
|
||||
content = getattr(args, 'content', None)
|
||||
if getattr(args, 'content_file', None):
|
||||
with open(args.content_file, encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
publisher.connect(target_url_prefix="https://creator.xiaohongshu.com/publish")
|
||||
publisher.click_next_and_prepare_publish(content=content or "")
|
||||
print("LONG_ARTICLE_STATUS: READY_TO_PUBLISH")
|
||||
|
||||
elif args.command == "click-publish":
|
||||
publisher.connect(target_url_prefix="https://creator.xiaohongshu.com/publish")
|
||||
publisher._click_publish()
|
||||
|
||||
@@ -21,6 +21,10 @@ Usage:
|
||||
# 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
|
||||
@@ -65,8 +69,16 @@ def main():
|
||||
content_group.add_argument("--content", help="Article body text")
|
||||
content_group.add_argument("--content-file", help="Read content from UTF-8 file")
|
||||
|
||||
# Images
|
||||
img_group = parser.add_mutually_exclusive_group(required=True)
|
||||
# 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"
|
||||
)
|
||||
@@ -169,7 +181,7 @@ def main():
|
||||
if not image_paths:
|
||||
print("Error: All image downloads failed.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
else:
|
||||
elif args.images:
|
||||
image_paths = args.images
|
||||
# Verify local files exist
|
||||
for p in image_paths:
|
||||
@@ -177,10 +189,23 @@ def main():
|
||||
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:
|
||||
@@ -189,8 +214,8 @@ def main():
|
||||
downloader.cleanup()
|
||||
sys.exit(2)
|
||||
|
||||
# --- Step 5: Publish (optional) ---
|
||||
if args.auto_publish:
|
||||
# --- 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()
|
||||
|
||||
Reference in New Issue
Block a user