From b50f0aa6333c6e778709a7fff23b39c8fe81f65b Mon Sep 17 00:00:00 2001 From: Angiin Date: Thu, 5 Feb 2026 19:25:53 +0800 Subject: [PATCH 1/2] add: post-to-xhs skills --- skills/post-to-xhs/SKILL.md | 209 ++++++ skills/post-to-xhs/config/accounts.json | 10 + .../references/publish-workflow.md | 196 +++++ .../account_manager.cpython-312.pyc | Bin 0 -> 12940 bytes .../__pycache__/cdp_publish.cpython-312.pyc | Bin 0 -> 29625 bytes .../chrome_launcher.cpython-312.pyc | Bin 0 -> 13052 bytes .../image_downloader.cpython-312.pyc | Bin 0 -> 7120 bytes skills/post-to-xhs/scripts/account_manager.py | 309 ++++++++ skills/post-to-xhs/scripts/cdp_publish.py | 686 ++++++++++++++++++ skills/post-to-xhs/scripts/chrome_launcher.py | 296 ++++++++ skills/post-to-xhs/scripts/content.txt | 5 + .../post-to-xhs/scripts/image_downloader.py | 141 ++++ .../post-to-xhs/scripts/publish_pipeline.py | 213 ++++++ skills/post-to-xhs/scripts/title.txt | 1 + 14 files changed, 2066 insertions(+) create mode 100644 skills/post-to-xhs/SKILL.md create mode 100644 skills/post-to-xhs/config/accounts.json create mode 100644 skills/post-to-xhs/references/publish-workflow.md create mode 100644 skills/post-to-xhs/scripts/__pycache__/account_manager.cpython-312.pyc create mode 100644 skills/post-to-xhs/scripts/__pycache__/cdp_publish.cpython-312.pyc create mode 100644 skills/post-to-xhs/scripts/__pycache__/chrome_launcher.cpython-312.pyc create mode 100644 skills/post-to-xhs/scripts/__pycache__/image_downloader.cpython-312.pyc create mode 100644 skills/post-to-xhs/scripts/account_manager.py create mode 100644 skills/post-to-xhs/scripts/cdp_publish.py create mode 100644 skills/post-to-xhs/scripts/chrome_launcher.py create mode 100644 skills/post-to-xhs/scripts/content.txt create mode 100644 skills/post-to-xhs/scripts/image_downloader.py create mode 100644 skills/post-to-xhs/scripts/publish_pipeline.py create mode 100644 skills/post-to-xhs/scripts/title.txt diff --git a/skills/post-to-xhs/SKILL.md b/skills/post-to-xhs/SKILL.md new file mode 100644 index 0000000..61a1d9d --- /dev/null +++ b/skills/post-to-xhs/SKILL.md @@ -0,0 +1,209 @@ +--- +name: post-to-xhs +description: > + 小红书内容发布技能。支持两种输入方式:(1) 用户提供完整内容和图片/图片URL,直接发布; + (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 运行 Pipeline + +根据用户选择的模式执行发布脚本: + +**无头模式**(添加 `--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 → 报告错误 + +### 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 报告结果 + +根据命令输出告知用户发布是否成功。 + +## 重要提示 + +- **绝不自动发布** - 必须在 Step 4.4 获得用户确认 +- **图片必须有** - 小红书发布必须有图片,没有图片不能发布 +- **无头模式**:使用 `--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 +``` diff --git a/skills/post-to-xhs/config/accounts.json b/skills/post-to-xhs/config/accounts.json new file mode 100644 index 0000000..8679cbe --- /dev/null +++ b/skills/post-to-xhs/config/accounts.json @@ -0,0 +1,10 @@ +{ + "default_account": "default", + "accounts": { + "default": { + "alias": "默认账号", + "profile_dir": "C:\\Users\\admin\\AppData\\Local\\Google\\Chrome\\XiaohongshuProfiles\\default", + "created_at": null + } + } +} diff --git a/skills/post-to-xhs/references/publish-workflow.md b/skills/post-to-xhs/references/publish-workflow.md new file mode 100644 index 0000000..d708dc3 --- /dev/null +++ b/skills/post-to-xhs/references/publish-workflow.md @@ -0,0 +1,196 @@ +# 小红书发布流程参考 + +本文档描述通过 CDP(Chrome DevTools Protocol)自动发布内容到小红书创作者中心的完整流程。 + +## 前置条件 + +1. **Chrome 浏览器已安装** - 标准 Google Chrome +2. **Python 依赖已安装** - `websockets`、`requests` +3. **首次登录已完成** - 至少登录过一次小红书(cookie 持久化在专用 profile 中) + +## 流程概览 + +``` +生成文案 → 用户确认 → 启动 Chrome → 检查登录 → 导航发布页 → 上传图片 → 填写标题 → 填写正文 → 用户确认发布 +``` + +## 详细步骤 + +### 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) +- 将正文按段落拆分,包裹为 `

` 标签写入 innerHTML,段落之间插入 `


` 空行 +- 触发 `input` 事件 + +### 7. 用户确认并发布 + +- 脚本填写完成后暂停,提示用户在浏览器中检查预览 +- 用户确认后,脚本点击发布按钮 +- 或用户选择手动点击发布按钮 + +## DOM 选择器参考 + +> **注意**: 小红书前端可能随时更新,以下选择器基于编写时的页面结构。如果自动化失败,需要在浏览器 DevTools 中重新抓取选择器,并更新 `cdp_publish.py` 中的 `SELECTORS` 字典。 + +| 元素 | 主选择器 | 备选选择器 | 说明 | +|---|---|---|---| +| 图片上传 | `input.upload-input` | `input[type="file"]` | 隐藏的文件输入,通过 CDP 直接操作 | +| 标题输入 | `input[placeholder*="填写标题"]` | `input.d-text` | 标题输入框 | +| 正文编辑 | `div.tiptap.ProseMirror` | `div.ProseMirror[contenteditable="true"]` | TipTap/ProseMirror 富文本编辑器 | +| 发布按钮 | 文本匹配"发布"(`button` + `.d-button-content .d-text`) | - | 通过遍历按钮文本定位 | +| 登录检测 | 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 +``` + +### 账号管理 + +```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. 继续正常发布流程 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 new file mode 100644 index 0000000000000000000000000000000000000000..c7d6826458ada66b193314b8a4b30325532f1360 GIT binary patch literal 12940 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 literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..34c3ef2f96bd6b1e53664e9811c74116b383185d GIT binary patch literal 13052 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 CGm)SG literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..9d340a8cdaab235ac7d18886674c108825ff19a0 GIT binary patch literal 7120 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 [--alias ] + python account_manager.py remove + python account_manager.py info + python account_manager.py set-default +""" + +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() diff --git a/skills/post-to-xhs/scripts/cdp_publish.py b/skills/post-to-xhs/scripts/cdp_publish.py new file mode 100644 index 0000000..3d5c696 --- /dev/null +++ b/skills/post-to-xhs/scripts/cdp_publish.py @@ -0,0 +1,686 @@ +""" +CDP-based Xiaohongshu publisher. + +Connects to a Chrome instance via Chrome DevTools Protocol to automate +publishing articles on Xiaohongshu (RED) creator center. + +CLI usage: + # Basic commands + 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] + + # 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 + python cdp_publish.py switch-account [--account NAME] # clear cookies + open login for new account + python cdp_publish.py list-accounts # list all configured accounts + python cdp_publish.py add-account NAME [--alias ALIAS] # add a new account + python cdp_publish.py remove-account NAME # remove an account + +Library usage: + from cdp_publish import XiaohongshuPublisher + + publisher = XiaohongshuPublisher() + publisher.connect() + publisher.check_login() + publisher.publish( + title="Article title", + content="Article body text", + image_paths=["/path/to/img1.jpg", "/path/to/img2.jpg"], + ) +""" + +import json +import os +import time +import sys +from typing import Any + +# 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 + +import requests +import websockets.sync.client as ws_client + +# --------------------------------------------------------------------------- +# Configuration - centralised selectors and URLs for easy maintenance +# --------------------------------------------------------------------------- + +CDP_HOST = "127.0.0.1" +CDP_PORT = 9222 + +# Xiaohongshu URLs +XHS_CREATOR_URL = "https://creator.xiaohongshu.com/publish/publish" +XHS_HOME_URL = "https://www.xiaohongshu.com" +XHS_LOGIN_CHECK_URL = "https://creator.xiaohongshu.com" + +# DOM selectors (update these when Xiaohongshu changes their page structure) +# Last verified: 2026-02 +SELECTORS = { + # "上传图文" tab - must click before uploading images + "image_text_tab": "div.creator-tab", + "image_text_tab_text": "上传图文", + # Upload area - the file input element for images (visible after clicking tab) + "upload_input": "input.upload-input", + "upload_input_alt": 'input[type="file"]', + # Title input field (visible after image upload) + "title_input": 'input[placeholder*="填写标题"]', + "title_input_alt": "input.d-text", + # Content editor area - TipTap/ProseMirror contenteditable div + "content_editor": "div.tiptap.ProseMirror", + "content_editor_alt": 'div.ProseMirror[contenteditable="true"]', + # Publish button + "publish_button_text": "发布", + # Login indicator - URL-based check (redirect to /login if not logged in) + "login_indicator": '.user-info, .creator-header, [class*="user"]', +} + +# Timing +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 + + +class CDPError(Exception): + """Error communicating with Chrome via CDP.""" + + +class XiaohongshuPublisher: + """Automates publishing to Xiaohongshu via CDP.""" + + def __init__(self, host: str = CDP_HOST, port: int = CDP_PORT): + self.host = host + self.port = port + self.ws = None + self._msg_id = 0 + + # ------------------------------------------------------------------ + # CDP connection management + # ------------------------------------------------------------------ + + def _get_targets(self) -> list[dict]: + """Get list of available browser targets (tabs). Retries once on failure.""" + url = f"http://{self.host}:{self.port}/json" + for attempt in range(2): + try: + resp = requests.get(url, timeout=5) + resp.raise_for_status() + return resp.json() + except Exception as e: + if attempt == 0: + print(f"[cdp_publish] CDP connection failed ({e}), restarting Chrome...") + from chrome_launcher import ensure_chrome + ensure_chrome(self.port) + time.sleep(2) + else: + raise CDPError(f"Cannot reach Chrome on {self.host}:{self.port}: {e}") + + def _find_or_create_tab(self, target_url_prefix: str = "") -> str: + """Find an existing tab matching the URL prefix, or return the first page tab.""" + targets = self._get_targets() + pages = [t for t in targets if t.get("type") == "page"] + + if target_url_prefix: + for t in pages: + if t.get("url", "").startswith(target_url_prefix): + return t["webSocketDebuggerUrl"] + + # Create a new tab + resp = requests.put( + f"http://{self.host}:{self.port}/json/new?{XHS_CREATOR_URL}", + timeout=5, + ) + if resp.ok: + return resp.json().get("webSocketDebuggerUrl", "") + + # Fallback: use first available page + if pages: + return pages[0]["webSocketDebuggerUrl"] + + raise CDPError("No browser tabs available.") + + def connect(self, target_url_prefix: str = ""): + """Connect to a Chrome tab via WebSocket.""" + ws_url = self._find_or_create_tab(target_url_prefix) + if not ws_url: + raise CDPError("Could not obtain WebSocket URL for any tab.") + + print(f"[cdp_publish] Connecting to {ws_url}") + self.ws = ws_client.connect(ws_url) + print("[cdp_publish] Connected to Chrome tab.") + + def disconnect(self): + """Close the WebSocket connection.""" + if self.ws: + self.ws.close() + self.ws = None + + # ------------------------------------------------------------------ + # CDP command helpers + # ------------------------------------------------------------------ + + def _send(self, method: str, params: dict | None = None) -> dict: + """Send a CDP command and return the result.""" + if not self.ws: + raise CDPError("Not connected. Call connect() first.") + + self._msg_id += 1 + msg = {"id": self._msg_id, "method": method} + if params: + msg["params"] = params + + self.ws.send(json.dumps(msg)) + + # Wait for the matching response + while True: + raw = self.ws.recv() + data = json.loads(raw) + if data.get("id") == self._msg_id: + if "error" in data: + raise CDPError(f"CDP error: {data['error']}") + return data.get("result", {}) + # else: it's an event, skip it + + def _evaluate(self, expression: str) -> Any: + """Execute JavaScript in the page and return the result value.""" + result = self._send("Runtime.evaluate", { + "expression": expression, + "returnByValue": True, + "awaitPromise": True, + }) + remote_obj = result.get("result", {}) + if remote_obj.get("subtype") == "error": + raise CDPError(f"JS error: {remote_obj.get('description', remote_obj)}") + return remote_obj.get("value") + + def _navigate(self, url: str): + """Navigate the current tab to the given URL and wait for load.""" + print(f"[cdp_publish] Navigating to {url}") + self._send("Page.enable") + self._send("Page.navigate", {"url": url}) + time.sleep(PAGE_LOAD_WAIT) + + # ------------------------------------------------------------------ + # Login check + # ------------------------------------------------------------------ + + def check_login(self) -> bool: + """ + Navigate to Xiaohongshu creator center and check if the user is logged in. + + Returns True if logged in. If not logged in, prints instructions + and returns False. + """ + self._navigate(XHS_LOGIN_CHECK_URL) + time.sleep(2) + + # Check if we got redirected to a login page + current_url = self._evaluate("window.location.href") + print(f"[cdp_publish] Current URL: {current_url}") + + if "login" in current_url.lower(): + print( + "\n[cdp_publish] NOT LOGGED IN.\n" + " Please scan the QR code in the Chrome window to log in,\n" + " then run this script again.\n" + ) + return False + + print("[cdp_publish] Login confirmed.") + return True + + def clear_cookies(self, domain: str = ".xiaohongshu.com"): + """ + Clear all cookies for the given domain to force re-login. + + Used when switching accounts. + """ + print(f"[cdp_publish] Clearing cookies for {domain}...") + self._send("Network.enable") + self._send("Network.clearBrowserCookies") + # Also clear storage + self._send("Storage.clearDataForOrigin", { + "origin": "https://www.xiaohongshu.com", + "storageTypes": "cookies,local_storage,session_storage", + }) + self._send("Storage.clearDataForOrigin", { + "origin": "https://creator.xiaohongshu.com", + "storageTypes": "cookies,local_storage,session_storage", + }) + print("[cdp_publish] Cookies and storage cleared.") + + def open_login_page(self): + """ + Navigate to the Xiaohongshu login page for QR code scanning. + + Used for initial login or after clearing cookies for account switch. + """ + self._navigate(XHS_LOGIN_CHECK_URL) + time.sleep(2) + current_url = self._evaluate("window.location.href") + if "login" not in current_url.lower(): + # Already logged in, navigate to login page explicitly + self._navigate("https://creator.xiaohongshu.com/login") + time.sleep(2) + print( + "\n[cdp_publish] Login page is open.\n" + " Please scan the QR code in the Chrome window to log in.\n" + ) + + # ------------------------------------------------------------------ + # Publishing actions + # ------------------------------------------------------------------ + + def _click_image_text_tab(self): + """Click the '上传图文' tab to switch to image+text publish mode.""" + print("[cdp_publish] Clicking '上传图文' tab...") + tab_text = SELECTORS["image_text_tab_text"] + selector = SELECTORS["image_text_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, waiting for upload area...") + time.sleep(TAB_CLICK_WAIT) + + def _upload_images(self, image_paths: list[str]): + """Upload images via the file input element.""" + if not image_paths: + print("[cdp_publish] No images to upload, skipping.") + return + + # Normalize paths (forward slashes for CDP) + normalized = [p.replace("\\", "/") for p in image_paths] + + print(f"[cdp_publish] Uploading {len(image_paths)} image(s)...") + + # Enable DOM domain + self._send("DOM.enable") + + # Get the document root + doc = self._send("DOM.getDocument") + root_id = doc["root"]["nodeId"] + + # Try primary selector, then fallback + node_id = 0 + for selector in (SELECTORS["upload_input"], SELECTORS["upload_input_alt"]): + result = self._send("DOM.querySelector", { + "nodeId": root_id, + "selector": selector, + }) + node_id = result.get("nodeId", 0) + if node_id: + break + + if not node_id: + raise CDPError( + "Could not find file input element.\n" + "The page structure may have changed. Check references/publish-workflow.md." + ) + + # Use DOM.setFileInputFiles to set the files + self._send("DOM.setFileInputFiles", { + "nodeId": node_id, + "files": normalized, + }) + + print("[cdp_publish] Images uploaded. Waiting for editor to appear...") + time.sleep(UPLOAD_WAIT) + + def _fill_title(self, title: str): + """Fill in the article title.""" + print(f"[cdp_publish] Setting title: {title[:40]}...") + time.sleep(ACTION_INTERVAL) + + for selector in (SELECTORS["title_input"], SELECTORS["title_input_alt"]): + found = self._evaluate(f"!!document.querySelector('{selector}')") + if found: + escaped_title = json.dumps(title) + self._evaluate(f""" + (function() {{ + var el = document.querySelector('{selector}'); + var nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.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] Title set.") + return + + raise CDPError("Could not find title input element.") + + def _fill_content(self, content: str): + """Fill in the article body content using the TipTap/ProseMirror editor.""" + print(f"[cdp_publish] Setting content ({len(content)} chars)...") + time.sleep(ACTION_INTERVAL) + + for selector in (SELECTORS["content_editor"], SELECTORS["content_editor_alt"]): + found = self._evaluate(f"!!document.querySelector('{selector}')") + if found: + escaped = json.dumps(content) + self._evaluate(f""" + (function() {{ + var el = document.querySelector('{selector}'); + el.focus(); + var text = {escaped}; + var paragraphs = text.split('\\n').filter(function(p) {{ return p.trim(); }}); + var html = []; + for (var i = 0; i < paragraphs.length; i++) {{ + html.push('

' + paragraphs[i] + '

'); + if (i < paragraphs.length - 1) {{ + html.push('


'); + }} + }} + el.innerHTML = html.join(''); + el.dispatchEvent(new Event('input', {{ bubbles: true }})); + }})(); + """) + print("[cdp_publish] Content set.") + return + + raise CDPError("Could not find content editor element.") + + def _click_publish(self): + """Click the publish button (found by text content).""" + print("[cdp_publish] Clicking publish button...") + time.sleep(ACTION_INTERVAL) + + btn_text = SELECTORS["publish_button_text"] + clicked = self._evaluate(f""" + (function() {{ + // Strategy 1: search