diff --git a/skills/post-to-xhs/SKILL.md b/skills/post-to-xhs/SKILL.md index 61a1d9d..f9f4d97 100644 --- a/skills/post-to-xhs/SKILL.md +++ b/skills/post-to-xhs/SKILL.md @@ -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` 更新 diff --git a/skills/post-to-xhs/references/publish-workflow.md b/skills/post-to-xhs/references/publish-workflow.md index d708dc3..bdeddb1 100644 --- a/skills/post-to-xhs/references/publish-workflow.md +++ b/skills/post-to-xhs/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 diff --git a/skills/post-to-xhs/scripts/__pycache__/account_manager.cpython-312.pyc b/skills/post-to-xhs/scripts/__pycache__/account_manager.cpython-312.pyc deleted file mode 100644 index c7d6826..0000000 Binary files a/skills/post-to-xhs/scripts/__pycache__/account_manager.cpython-312.pyc and /dev/null differ diff --git a/skills/post-to-xhs/scripts/__pycache__/cdp_publish.cpython-312.pyc b/skills/post-to-xhs/scripts/__pycache__/cdp_publish.cpython-312.pyc deleted file mode 100644 index e909d92..0000000 Binary files a/skills/post-to-xhs/scripts/__pycache__/cdp_publish.cpython-312.pyc and /dev/null differ diff --git a/skills/post-to-xhs/scripts/__pycache__/chrome_launcher.cpython-312.pyc b/skills/post-to-xhs/scripts/__pycache__/chrome_launcher.cpython-312.pyc deleted file mode 100644 index 34c3ef2..0000000 Binary files a/skills/post-to-xhs/scripts/__pycache__/chrome_launcher.cpython-312.pyc and /dev/null differ diff --git a/skills/post-to-xhs/scripts/__pycache__/image_downloader.cpython-312.pyc b/skills/post-to-xhs/scripts/__pycache__/image_downloader.cpython-312.pyc deleted file mode 100644 index 9d340a8..0000000 Binary files a/skills/post-to-xhs/scripts/__pycache__/image_downloader.cpython-312.pyc and /dev/null differ diff --git a/skills/post-to-xhs/scripts/cdp_publish.py b/skills/post-to-xhs/scripts/cdp_publish.py index 3d5c696..3d47efe 100644 --- a/skills/post-to-xhs/scripts/cdp_publish.py +++ b/skills/post-to-xhs/scripts/cdp_publish.py @@ -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() diff --git a/skills/post-to-xhs/scripts/publish_pipeline.py b/skills/post-to-xhs/scripts/publish_pipeline.py index b83ae9f..d393efa 100644 --- a/skills/post-to-xhs/scripts/publish_pipeline.py +++ b/skills/post-to-xhs/scripts/publish_pipeline.py @@ -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,20 +189,33 @@ 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: - publisher.publish(title=title, content=content, image_paths=image_paths) - print("FILL_STATUS: READY_TO_PUBLISH") + 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) --- - 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()