feat: 迁移至浏览器扩展架构,移除 CDP/多账号/指纹模块
## 新增 - extension/: Chrome 扩展(XHS Bridge),通过用户真实浏览器执行操作 - scripts/bridge_server.py: 本地 WebSocket 中继服务 - scripts/xhs/bridge.py: BridgePage 客户端,与 CDP Page 接口兼容 ## 核心改动 - scripts/cli.py: 全面迁移至 BridgePage,自动启动 bridge server 和 Chrome - scripts/xhs/publish.py: 修复标签输入(execCommand focus)、正文/tags 空行逻辑 ## 删除 - scripts/chrome_launcher.py: Chrome 进程管理(不再需要) - scripts/account_manager.py: 多账号管理(移除该功能) - scripts/xhs/stealth.py: 指纹/反检测模块(移除该功能) - scripts/publish_pipeline.py: 旧 CDP 发布流水线 - scripts/test_headless_login.py, tests/test_account_manager.py: 对应废弃测试 ## 文档 - README.md: 重写安装说明(加扩展安装步骤),突出真实账号/浏览器,加风控提示 - CLAUDE.md: 更新架构描述 - skills/*: 移除多账号命令、headless/CDP 相关内容,各技能加风控频率约束 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
21 changed files
with
1505 additions
and
2162 deletions
| 1 | # xiaohongshu-skills | 1 | # xiaohongshu-skills |
| 2 | 2 | ||
| 3 | -小红书自动化 Claude Code Skills,基于 Python CDP 浏览器自动化引擎。 | 3 | +小红书自动化 Claude Code Skills,使用用户的真实浏览器和账号信息操作小红书。 |
| 4 | 4 | ||
| 5 | ## Git 工作流 | 5 | ## Git 工作流 |
| 6 | 6 | ||
| @@ -18,11 +18,12 @@ uv run pytest # 运行测试 | @@ -18,11 +18,12 @@ uv run pytest # 运行测试 | ||
| 18 | 18 | ||
| 19 | ## 架构 | 19 | ## 架构 |
| 20 | 20 | ||
| 21 | -双层结构:`scripts/` 是 Python CDP 自动化引擎,`skills/` 是 Claude Code Skills 定义(SKILL.md 格式)。 | 21 | +双层结构:`scripts/` 是 Python 自动化引擎,`skills/` 是 Claude Code Skills 定义(SKILL.md 格式)。 |
| 22 | 22 | ||
| 23 | - `scripts/xhs/` — 核心自动化库(模块化,每个功能一个文件) | 23 | - `scripts/xhs/` — 核心自动化库(模块化,每个功能一个文件) |
| 24 | -- `scripts/cli.py` — 统一 CLI 入口,23 个子命令,JSON 结构化输出 | ||
| 25 | -- `scripts/publish_pipeline.py` — 发布编排器(含图片下载和登录检查) | 24 | +- `scripts/cli.py` — 统一 CLI 入口,JSON 结构化输出,自动启动 bridge server 和浏览器 |
| 25 | +- `scripts/bridge_server.py` — 本地通信服务(连接 CLI 与浏览器扩展) | ||
| 26 | +- `extension/` — Chrome 扩展,在用户的真实浏览器中执行操作 | ||
| 26 | - `skills/*/SKILL.md` — 指导 Claude 如何调用 scripts/ | 27 | - `skills/*/SKILL.md` — 指导 Claude 如何调用 scripts/ |
| 27 | 28 | ||
| 28 | ### 调用方式 | 29 | ### 调用方式 |
| @@ -31,9 +32,10 @@ uv run pytest # 运行测试 | @@ -31,9 +32,10 @@ uv run pytest # 运行测试 | ||
| 31 | python scripts/cli.py check-login | 32 | python scripts/cli.py check-login |
| 32 | python scripts/cli.py search-feeds --keyword "关键词" | 33 | python scripts/cli.py search-feeds --keyword "关键词" |
| 33 | python scripts/cli.py publish --title-file t.txt --content-file c.txt --images pic.jpg | 34 | python scripts/cli.py publish --title-file t.txt --content-file c.txt --images pic.jpg |
| 34 | -python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --images URL1 | ||
| 35 | ``` | 35 | ``` |
| 36 | 36 | ||
| 37 | +> CLI 会自动检测环境,若浏览器未打开也会自动启动 Chrome。 | ||
| 38 | + | ||
| 37 | ## 代码规范 | 39 | ## 代码规范 |
| 38 | 40 | ||
| 39 | - 行长度上限 100 字符 | 41 | - 行长度上限 100 字符 |
| @@ -48,7 +50,6 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima | @@ -48,7 +50,6 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima | ||
| 48 | - 发布类操作必须有用户确认机制 | 50 | - 发布类操作必须有用户确认机制 |
| 49 | - 文件路径必须使用绝对路径 | 51 | - 文件路径必须使用绝对路径 |
| 50 | - 敏感内容通过文件传递,不内联到命令行参数 | 52 | - 敏感内容通过文件传递,不内联到命令行参数 |
| 51 | -- Chrome Profile 目录隔离账号 cookies | ||
| 52 | 53 | ||
| 53 | ## CLI 子命令对照表 | 54 | ## CLI 子命令对照表 |
| 54 | 55 | ||
| @@ -74,7 +75,3 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima | @@ -74,7 +75,3 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima | ||
| 74 | | `long-article` | — | 长文发布(填写+排版) | | 75 | | `long-article` | — | 长文发布(填写+排版) | |
| 75 | | `select-template` | — | 长文发布(选择模板) | | 76 | | `select-template` | — | 长文发布(选择模板) | |
| 76 | | `next-step` | — | 长文发布(下一步+描述) | | 77 | | `next-step` | — | 长文发布(下一步+描述) | |
| 77 | -| `add-account` | — | 账号管理(添加,自动分配端口) | | ||
| 78 | -| `list-accounts` | — | 账号管理(列出所有) | | ||
| 79 | -| `remove-account` | — | 账号管理(删除) | | ||
| 80 | -| `set-default-account` | — | 账号管理(设置默认) | |
| 1 | # xiaohongshu-skills | 1 | # xiaohongshu-skills |
| 2 | 2 | ||
| 3 | -小红书自动化 Skills,基于 Python CDP 浏览器自动化引擎。 | 3 | +小红书自动化 Skills,直接使用你已登录的浏览器和真实账号,以普通用户的方式操作小红书。 |
| 4 | 4 | ||
| 5 | 支持 [OpenClaw](https://github.com/anthropics/openclaw) 及所有兼容 `SKILL.md` 格式的 AI Agent 平台(如 Claude Code)。 | 5 | 支持 [OpenClaw](https://github.com/anthropics/openclaw) 及所有兼容 `SKILL.md` 格式的 AI Agent 平台(如 Claude Code)。 |
| 6 | 6 | ||
| 7 | +> **⚠️ 使用建议**:虽然本项目使用真实的用户浏览器和账号环境,但仍建议**控制使用频率**,避免短时间内大量操作。频繁的自动化行为可能触发小红书的风控机制,导致账号受限。 | ||
| 8 | + | ||
| 7 | ## 功能概览 | 9 | ## 功能概览 |
| 8 | 10 | ||
| 9 | | 技能 | 说明 | 核心能力 | | 11 | | 技能 | 说明 | 核心能力 | |
| 10 | |------|------|----------| | 12 | |------|------|----------| |
| 11 | -| **xhs-auth** | 认证管理 | 登录检查、扫码登录、多账号切换 | | 13 | +| **xhs-auth** | 认证管理 | 登录检查、扫码登录、手机验证码登录 | |
| 12 | | **xhs-publish** | 内容发布 | 图文 / 视频 / 长文发布、定时发布、分步预览 | | 14 | | **xhs-publish** | 内容发布 | 图文 / 视频 / 长文发布、定时发布、分步预览 | |
| 13 | | **xhs-explore** | 内容发现 | 关键词搜索、笔记详情、用户主页、首页推荐 | | 15 | | **xhs-explore** | 内容发现 | 关键词搜索、笔记详情、用户主页、首页推荐 | |
| 14 | | **xhs-interact** | 社交互动 | 评论、回复、点赞、收藏 | | 16 | | **xhs-interact** | 社交互动 | 评论、回复、点赞、收藏 | |
| @@ -28,12 +30,11 @@ Agent 会自动执行:搜索 → 筛选图文 → 按点赞排序 → 收藏 | @@ -28,12 +30,11 @@ Agent 会自动执行:搜索 → 筛选图文 → 按点赞排序 → 收藏 | ||
| 28 | - [uv](https://docs.astral.sh/uv/) 包管理器 | 30 | - [uv](https://docs.astral.sh/uv/) 包管理器 |
| 29 | - Google Chrome 浏览器 | 31 | - Google Chrome 浏览器 |
| 30 | 32 | ||
| 31 | -### 方法一:下载 ZIP 安装(推荐) | 33 | +### 第一步:安装项目 |
| 32 | 34 | ||
| 33 | -最简单稳妥的方式,适用于 OpenClaw 及所有支持 `SKILL.md` 的 Agent 平台。 | 35 | +**方法一:下载 ZIP(推荐)** |
| 34 | 36 | ||
| 35 | -1. 在 GitHub 仓库页面点击 **Code → Download ZIP**,下载项目压缩包。 | ||
| 36 | -2. 解压到你的 Agent 的 skills 目录下: | 37 | +1. 在 GitHub 仓库页面点击 **Code → Download ZIP**,下载并解压到你的 Agent skills 目录: |
| 37 | 38 | ||
| 38 | ``` | 39 | ``` |
| 39 | # OpenClaw 示例 | 40 | # OpenClaw 示例 |
| @@ -43,30 +44,30 @@ Agent 会自动执行:搜索 → 筛选图文 → 按点赞排序 → 收藏 | @@ -43,30 +44,30 @@ Agent 会自动执行:搜索 → 筛选图文 → 按点赞排序 → 收藏 | ||
| 43 | <your-project>/.claude/skills/xiaohongshu-skills/ | 44 | <your-project>/.claude/skills/xiaohongshu-skills/ |
| 44 | ``` | 45 | ``` |
| 45 | 46 | ||
| 46 | -3. 安装 Python 依赖: | 47 | +**方法二:Git Clone** |
| 47 | 48 | ||
| 48 | ```bash | 49 | ```bash |
| 49 | -cd xiaohongshu-skills | ||
| 50 | -uv sync | 50 | +cd <your-agent-project>/skills/ |
| 51 | +git clone https://github.com/autoclaw-cc/xiaohongshu-skills.git | ||
| 51 | ``` | 52 | ``` |
| 52 | 53 | ||
| 53 | -安装完成后,Agent 会自动识别 `SKILL.md` 并加载小红书技能。 | ||
| 54 | - | ||
| 55 | -### 方法二:Git Clone | 54 | +2. 安装 Python 依赖: |
| 56 | 55 | ||
| 57 | ```bash | 56 | ```bash |
| 58 | -# 进入 skills 目录 | ||
| 59 | -cd <your-agent-project>/skills/ | ||
| 60 | - | ||
| 61 | -# 克隆项目 | ||
| 62 | -git clone https://github.com/autoclaw-cc/xiaohongshu-skills.git | ||
| 63 | cd xiaohongshu-skills | 57 | cd xiaohongshu-skills |
| 64 | - | ||
| 65 | -# 安装依赖 | ||
| 66 | uv sync | 58 | uv sync |
| 67 | ``` | 59 | ``` |
| 68 | 60 | ||
| 69 | -> 其他支持 SKILL.md 格式的 Agent 框架安装方式类似 — 将本项目放入其 skills 目录即可。 | 61 | +### 第二步:安装浏览器扩展 |
| 62 | + | ||
| 63 | +扩展让 AI 能够在你的浏览器中以你的身份操作小红书,使用的是你真实的登录状态和账号信息。 | ||
| 64 | + | ||
| 65 | +1. 打开 Chrome,地址栏输入 `chrome://extensions/` | ||
| 66 | +2. 右上角开启**开发者模式** | ||
| 67 | +3. 点击**加载已解压的扩展程序**,选择本项目的 `extension/` 目录 | ||
| 68 | +4. 确认扩展 **XHS Bridge** 已启用 | ||
| 69 | + | ||
| 70 | +安装完成后即可使用 — 所有操作都发生在你自己的浏览器里,使用你的真实账号和浏览器环境。 | ||
| 70 | 71 | ||
| 71 | ## 使用方式 | 72 | ## 使用方式 |
| 72 | 73 | ||
| @@ -93,29 +94,14 @@ uv sync | @@ -93,29 +94,14 @@ uv sync | ||
| 93 | 94 | ||
| 94 | 所有功能也可以通过命令行直接调用,输出 JSON 格式,便于脚本集成。 | 95 | 所有功能也可以通过命令行直接调用,输出 JSON 格式,便于脚本集成。 |
| 95 | 96 | ||
| 96 | -#### 1. 启动 Chrome | ||
| 97 | - | ||
| 98 | -```bash | ||
| 99 | -# 有窗口模式(首次登录必须) | ||
| 100 | -python scripts/chrome_launcher.py | ||
| 101 | - | ||
| 102 | -# 无头模式 | ||
| 103 | -python scripts/chrome_launcher.py --headless | ||
| 104 | -``` | ||
| 105 | - | ||
| 106 | -#### 2. 登录 | ||
| 107 | - | ||
| 108 | ```bash | 97 | ```bash |
| 109 | -# 检查登录状态(已登录时返回用户昵称和小红书号) | 98 | +# 检查登录状态 |
| 110 | python scripts/cli.py check-login | 99 | python scripts/cli.py check-login |
| 111 | 100 | ||
| 112 | # 扫码登录 | 101 | # 扫码登录 |
| 113 | python scripts/cli.py login | 102 | python scripts/cli.py login |
| 114 | -``` | ||
| 115 | - | ||
| 116 | -#### 3. 搜索笔记 | ||
| 117 | 103 | ||
| 118 | -```bash | 104 | +# 搜索笔记 |
| 119 | python scripts/cli.py search-feeds --keyword "关键词" | 105 | python scripts/cli.py search-feeds --keyword "关键词" |
| 120 | 106 | ||
| 121 | # 带筛选条件 | 107 | # 带筛选条件 |
| @@ -123,29 +109,24 @@ python scripts/cli.py search-feeds \ | @@ -123,29 +109,24 @@ python scripts/cli.py search-feeds \ | ||
| 123 | --keyword "关键词" \ | 109 | --keyword "关键词" \ |
| 124 | --sort-by "最多点赞" \ | 110 | --sort-by "最多点赞" \ |
| 125 | --note-type "图文" | 111 | --note-type "图文" |
| 126 | -``` | ||
| 127 | - | ||
| 128 | -#### 4. 查看笔记详情 | ||
| 129 | 112 | ||
| 130 | -```bash | 113 | +# 查看笔记详情 |
| 131 | python scripts/cli.py get-feed-detail \ | 114 | python scripts/cli.py get-feed-detail \ |
| 132 | --feed-id FEED_ID --xsec-token XSEC_TOKEN | 115 | --feed-id FEED_ID --xsec-token XSEC_TOKEN |
| 133 | -``` | ||
| 134 | - | ||
| 135 | -#### 5. 发布内容 | ||
| 136 | 116 | ||
| 137 | -```bash | ||
| 138 | -# 图文发布(分步:填写 → 预览 → 确认发布) | 117 | +# 图文发布(分步:填写 → 预览 → 确认) |
| 139 | python scripts/cli.py fill-publish \ | 118 | python scripts/cli.py fill-publish \ |
| 140 | --title-file title.txt \ | 119 | --title-file title.txt \ |
| 141 | --content-file content.txt \ | 120 | --content-file content.txt \ |
| 142 | --images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg" | 121 | --images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg" |
| 143 | - | ||
| 144 | -# 用户在浏览器中预览确认后 | ||
| 145 | python scripts/cli.py click-publish | 122 | python scripts/cli.py click-publish |
| 146 | 123 | ||
| 147 | -# 或保存为草稿 | ||
| 148 | -python scripts/cli.py save-draft | 124 | +# 一步发布图文 |
| 125 | +python scripts/cli.py publish \ | ||
| 126 | + --title-file title.txt \ | ||
| 127 | + --content-file content.txt \ | ||
| 128 | + --images "/abs/path/pic1.jpg" \ | ||
| 129 | + --tags "标签1" "标签2" | ||
| 149 | 130 | ||
| 150 | # 视频发布 | 131 | # 视频发布 |
| 151 | python scripts/cli.py publish-video \ | 132 | python scripts/cli.py publish-video \ |
| @@ -153,41 +134,21 @@ python scripts/cli.py publish-video \ | @@ -153,41 +134,21 @@ python scripts/cli.py publish-video \ | ||
| 153 | --content-file content.txt \ | 134 | --content-file content.txt \ |
| 154 | --video "/abs/path/video.mp4" | 135 | --video "/abs/path/video.mp4" |
| 155 | 136 | ||
| 156 | -# 长文发布 | ||
| 157 | -python scripts/cli.py long-article \ | ||
| 158 | - --title-file title.txt \ | ||
| 159 | - --content-file content.txt | 137 | +# 点赞 / 收藏 / 评论 |
| 138 | +python scripts/cli.py like-feed --feed-id FEED_ID --xsec-token XSEC_TOKEN | ||
| 139 | +python scripts/cli.py favorite-feed --feed-id FEED_ID --xsec-token XSEC_TOKEN | ||
| 140 | +python scripts/cli.py post-comment --feed-id FEED_ID --xsec-token XSEC_TOKEN --content "评论内容" | ||
| 160 | ``` | 141 | ``` |
| 161 | 142 | ||
| 162 | -#### 6. 社交互动 | ||
| 163 | - | ||
| 164 | -```bash | ||
| 165 | -# 评论 | ||
| 166 | -python scripts/cli.py post-comment \ | ||
| 167 | - --feed-id FEED_ID --xsec-token XSEC_TOKEN \ | ||
| 168 | - --content "评论内容" | ||
| 169 | - | ||
| 170 | -# 点赞 | ||
| 171 | -python scripts/cli.py like-feed \ | ||
| 172 | - --feed-id FEED_ID --xsec-token XSEC_TOKEN | ||
| 173 | - | ||
| 174 | -# 收藏 | ||
| 175 | -python scripts/cli.py favorite-feed \ | ||
| 176 | - --feed-id FEED_ID --xsec-token XSEC_TOKEN | ||
| 177 | -``` | 143 | +> 第一次运行时,若 Chrome 未打开,CLI 会自动启动它。 |
| 178 | 144 | ||
| 179 | ## CLI 命令参考 | 145 | ## CLI 命令参考 |
| 180 | 146 | ||
| 181 | -全局选项: | ||
| 182 | -- `--host HOST` — Chrome 调试主机(默认 127.0.0.1) | ||
| 183 | -- `--port PORT` — Chrome 调试端口(默认 9222) | ||
| 184 | -- `--account NAME` — 指定账号 | ||
| 185 | - | ||
| 186 | | 子命令 | 说明 | | 147 | | 子命令 | 说明 | |
| 187 | |--------|------| | 148 | |--------|------| |
| 188 | | `check-login` | 检查登录状态,返回用户昵称和小红书号 | | 149 | | `check-login` | 检查登录状态,返回用户昵称和小红书号 | |
| 189 | | `login` | 获取登录二维码,等待扫码,登录后返回用户信息 | | 150 | | `login` | 获取登录二维码,等待扫码,登录后返回用户信息 | |
| 190 | -| `delete-cookies` | 清除 cookies(退出/切换账号) | | 151 | +| `delete-cookies` | 清除 cookies(退出登录) | |
| 191 | | `list-feeds` | 获取首页推荐 Feed | | 152 | | `list-feeds` | 获取首页推荐 Feed | |
| 192 | | `search-feeds` | 关键词搜索笔记(支持排序/类型/时间/范围/位置筛选) | | 153 | | `search-feeds` | 关键词搜索笔记(支持排序/类型/时间/范围/位置筛选) | |
| 193 | | `get-feed-detail` | 获取笔记完整内容和评论 | | 154 | | `get-feed-detail` | 获取笔记完整内容和评论 | |
| @@ -212,11 +173,14 @@ python scripts/cli.py favorite-feed \ | @@ -212,11 +173,14 @@ python scripts/cli.py favorite-feed \ | ||
| 212 | 173 | ||
| 213 | ``` | 174 | ``` |
| 214 | xiaohongshu-skills/ | 175 | xiaohongshu-skills/ |
| 215 | -├── scripts/ # Python CDP 自动化引擎 | 176 | +├── extension/ # Chrome 扩展 |
| 177 | +│ ├── manifest.json | ||
| 178 | +│ ├── background.js | ||
| 179 | +│ └── content.js | ||
| 180 | +├── scripts/ # Python 自动化引擎 | ||
| 216 | │ ├── xhs/ # 核心自动化包 | 181 | │ ├── xhs/ # 核心自动化包 |
| 217 | -│ │ ├── cdp.py # CDP WebSocket 客户端 | ||
| 218 | -│ │ ├── stealth.py # 反检测保护 | ||
| 219 | -│ │ ├── selectors.py # CSS 选择器(集中管理,改版时只改此文件) | 182 | +│ │ ├── bridge.py # 扩展通信客户端 |
| 183 | +│ │ ├── selectors.py # CSS 选择器(集中管理) | ||
| 220 | │ │ ├── login.py # 登录 + 用户信息获取 | 184 | │ │ ├── login.py # 登录 + 用户信息获取 |
| 221 | │ │ ├── feeds.py # 首页 Feed | 185 | │ │ ├── feeds.py # 首页 Feed |
| 222 | │ │ ├── search.py # 搜索 + 筛选 | 186 | │ │ ├── search.py # 搜索 + 筛选 |
| @@ -231,14 +195,12 @@ xiaohongshu-skills/ | @@ -231,14 +195,12 @@ xiaohongshu-skills/ | ||
| 231 | │ │ ├── errors.py # 异常体系 | 195 | │ │ ├── errors.py # 异常体系 |
| 232 | │ │ ├── urls.py # URL 常量 | 196 | │ │ ├── urls.py # URL 常量 |
| 233 | │ │ ├── cookies.py # Cookie 持久化 | 197 | │ │ ├── cookies.py # Cookie 持久化 |
| 234 | -│ │ └── human.py # 人类行为模拟 | ||
| 235 | -│ ├── cli.py # 统一 CLI 入口(20 个子命令) | ||
| 236 | -│ ├── chrome_launcher.py # Chrome 进程管理 | ||
| 237 | -│ ├── account_manager.py # 多账号管理 | 198 | +│ │ └── human.py # 行为模拟 |
| 199 | +│ ├── cli.py # 统一 CLI 入口 | ||
| 200 | +│ ├── bridge_server.py # 本地通信服务 | ||
| 238 | │ ├── image_downloader.py # 媒体下载(SHA256 缓存) | 201 | │ ├── image_downloader.py # 媒体下载(SHA256 缓存) |
| 239 | │ ├── title_utils.py # UTF-16 标题长度计算 | 202 | │ ├── title_utils.py # UTF-16 标题长度计算 |
| 240 | -│ ├── run_lock.py # 单实例锁 | ||
| 241 | -│ └── publish_pipeline.py # 发布编排器 | 203 | +│ └── run_lock.py # 单实例锁 |
| 242 | ├── skills/ # Claude Code Skills 定义 | 204 | ├── skills/ # Claude Code Skills 定义 |
| 243 | │ ├── xhs-auth/SKILL.md | 205 | │ ├── xhs-auth/SKILL.md |
| 244 | │ ├── xhs-publish/SKILL.md | 206 | │ ├── xhs-publish/SKILL.md |
| @@ -247,29 +209,10 @@ xiaohongshu-skills/ | @@ -247,29 +209,10 @@ xiaohongshu-skills/ | ||
| 247 | │ └── xhs-content-ops/SKILL.md | 209 | │ └── xhs-content-ops/SKILL.md |
| 248 | ├── SKILL.md # 技能统一入口(路由到子技能) | 210 | ├── SKILL.md # 技能统一入口(路由到子技能) |
| 249 | ├── CLAUDE.md # 项目开发指南 | 211 | ├── CLAUDE.md # 项目开发指南 |
| 250 | -├── pyproject.toml # uv 项目配置 | 212 | +├── pyproject.toml |
| 251 | └── README.md | 213 | └── README.md |
| 252 | ``` | 214 | ``` |
| 253 | 215 | ||
| 254 | -## 技术架构 | ||
| 255 | - | ||
| 256 | -### 双层设计 | ||
| 257 | - | ||
| 258 | -``` | ||
| 259 | -用户 ──→ AI Agent ──→ SKILL.md(意图路由)──→ CLI ──→ CDP 引擎 ──→ Chrome ──→ 小红书 | ||
| 260 | -``` | ||
| 261 | - | ||
| 262 | -1. **Skills 层**(`skills/` + `SKILL.md`)— AI Agent 的能力定义,描述何时触发、如何调用、如何处理失败。Agent 读取 SKILL.md 后自动获得小红书操作能力。 | ||
| 263 | - | ||
| 264 | -2. **引擎层**(`scripts/`)— Python CDP 自动化引擎,通过 Chrome DevTools Protocol 直接控制浏览器。内置反检测保护、人类行为模拟、JSON 结构化输出。 | ||
| 265 | - | ||
| 266 | -### 关键设计 | ||
| 267 | - | ||
| 268 | -- **数据提取**:通过 `window.__INITIAL_STATE__` 读取页面数据,与小红书前端框架对齐 | ||
| 269 | -- **反检测**:Stealth JS 注入 + CDP 真实输入事件(`isTrusted=true`)+ 随机延迟 | ||
| 270 | -- **选择器集中管理**:所有 CSS 选择器在 `xhs/selectors.py` 统一维护,小红书改版时只需改一个文件 | ||
| 271 | -- **分步发布**:fill → 预览 → confirm 三步流程,确保用户始终掌控发布内容 | ||
| 272 | - | ||
| 273 | ## 开发 | 216 | ## 开发 |
| 274 | 217 | ||
| 275 | ```bash | 218 | ```bash |
| @@ -283,8 +226,6 @@ uv run pytest # 运行测试 | @@ -283,8 +226,6 @@ uv run pytest # 运行测试 | ||
| 283 | 226 | ||
| 284 | MIT | 227 | MIT |
| 285 | 228 | ||
| 286 | -## Trend | ||
| 287 | - | ||
| 288 | ## Star History | 229 | ## Star History |
| 289 | 230 | ||
| 290 | [](https://www.star-history.com/?repos=autoclaw-cc%2Fxiaohongshu-skills&type=date&legend=top-left) | 231 | [](https://www.star-history.com/?repos=autoclaw-cc%2Fxiaohongshu-skills&type=date&legend=top-left) |
extension/background.js
0 → 100644
| 1 | +/** | ||
| 2 | + * XHS Bridge - Background Service Worker | ||
| 3 | + * | ||
| 4 | + * 连接 Python bridge server(ws://localhost:9333),接收命令并执行: | ||
| 5 | + * - navigate / wait_for_load: chrome.tabs.update + onUpdated | ||
| 6 | + * - evaluate / has_element 等: chrome.scripting.executeScript (MAIN world) | ||
| 7 | + * - click / input 等 DOM 操作: chrome.tabs.sendMessage → content.js | ||
| 8 | + * - screenshot: chrome.tabs.captureVisibleTab | ||
| 9 | + * - get_cookies: chrome.cookies.getAll | ||
| 10 | + */ | ||
| 11 | + | ||
| 12 | +const BRIDGE_URL = "ws://localhost:9333"; | ||
| 13 | +let ws = null; | ||
| 14 | + | ||
| 15 | +// 保持 service worker 存活:有开放的 WebSocket 连接时 Chrome 不会终止 SW | ||
| 16 | +// 额外加 alarm 作为保底 | ||
| 17 | +chrome.alarms.create("keepAlive", { periodInMinutes: 0.4 }); | ||
| 18 | +chrome.alarms.onAlarm.addListener(() => { | ||
| 19 | + if (!ws || ws.readyState !== WebSocket.OPEN) connect(); | ||
| 20 | +}); | ||
| 21 | + | ||
| 22 | +// ───────────────────────── WebSocket ───────────────────────── | ||
| 23 | + | ||
| 24 | +function connect() { | ||
| 25 | + if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return; | ||
| 26 | + | ||
| 27 | + ws = new WebSocket(BRIDGE_URL); | ||
| 28 | + | ||
| 29 | + ws.onopen = () => { | ||
| 30 | + console.log("[XHS Bridge] 已连接到 bridge server"); | ||
| 31 | + ws.send(JSON.stringify({ role: "extension" })); | ||
| 32 | + }; | ||
| 33 | + | ||
| 34 | + ws.onmessage = async (event) => { | ||
| 35 | + let msg; | ||
| 36 | + try { | ||
| 37 | + msg = JSON.parse(event.data); | ||
| 38 | + } catch { | ||
| 39 | + return; | ||
| 40 | + } | ||
| 41 | + try { | ||
| 42 | + const result = await handleCommand(msg); | ||
| 43 | + ws.send(JSON.stringify({ id: msg.id, result: result ?? null })); | ||
| 44 | + } catch (err) { | ||
| 45 | + ws.send(JSON.stringify({ id: msg.id, error: String(err.message || err) })); | ||
| 46 | + } | ||
| 47 | + }; | ||
| 48 | + | ||
| 49 | + ws.onclose = () => { | ||
| 50 | + console.log("[XHS Bridge] 连接断开,3s 后重连..."); | ||
| 51 | + setTimeout(connect, 3000); | ||
| 52 | + }; | ||
| 53 | + | ||
| 54 | + ws.onerror = (e) => { | ||
| 55 | + console.error("[XHS Bridge] WS 错误", e); | ||
| 56 | + }; | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// ───────────────────────── 命令路由 ───────────────────────── | ||
| 60 | + | ||
| 61 | +async function handleCommand(msg) { | ||
| 62 | + const { method, params = {} } = msg; | ||
| 63 | + | ||
| 64 | + switch (method) { | ||
| 65 | + // ── 导航 ── | ||
| 66 | + case "navigate": | ||
| 67 | + return await cmdNavigate(params); | ||
| 68 | + | ||
| 69 | + case "wait_for_load": | ||
| 70 | + return await cmdWaitForLoad(params); | ||
| 71 | + | ||
| 72 | + // ── 截图 ── | ||
| 73 | + case "screenshot_element": | ||
| 74 | + return await cmdScreenshot(params); | ||
| 75 | + | ||
| 76 | + case "set_file_input": | ||
| 77 | + return await cmdSetFileInputViaDebugger(params); | ||
| 78 | + | ||
| 79 | + // ── Cookies ── | ||
| 80 | + case "get_cookies": | ||
| 81 | + return await cmdGetCookies(params); | ||
| 82 | + | ||
| 83 | + // ── 在页面主 world 执行 JS(可访问 window.__INITIAL_STATE__ 等) ── | ||
| 84 | + case "evaluate": | ||
| 85 | + case "wait_dom_stable": | ||
| 86 | + case "wait_for_selector": | ||
| 87 | + case "has_element": | ||
| 88 | + case "get_elements_count": | ||
| 89 | + case "get_element_text": | ||
| 90 | + case "get_element_attribute": | ||
| 91 | + case "get_scroll_top": | ||
| 92 | + case "get_viewport_height": | ||
| 93 | + case "get_url": | ||
| 94 | + return await cmdEvaluateInMainWorld(method, params); | ||
| 95 | + | ||
| 96 | + // ── DOM 操作(在页面 MAIN world 执行,无需 content script 就绪) ── | ||
| 97 | + default: | ||
| 98 | + return await cmdDomInMainWorld(method, params); | ||
| 99 | + } | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +// ───────────────────────── 导航 ───────────────────────── | ||
| 103 | + | ||
| 104 | +async function cmdNavigate({ url }) { | ||
| 105 | + const tab = await getOrOpenXhsTab(); | ||
| 106 | + await chrome.tabs.update(tab.id, { url }); | ||
| 107 | + await waitForTabComplete(tab.id, url, 60000); | ||
| 108 | + return null; | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +async function cmdWaitForLoad({ timeout = 60000 }) { | ||
| 112 | + const tab = await getOrOpenXhsTab(); | ||
| 113 | + await waitForTabComplete(tab.id, null, timeout); | ||
| 114 | + return null; | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +async function waitForTabComplete(tabId, expectedUrlPrefix, timeout) { | ||
| 118 | + return new Promise((resolve, reject) => { | ||
| 119 | + const deadline = Date.now() + timeout; | ||
| 120 | + | ||
| 121 | + function listener(id, info, updatedTab) { | ||
| 122 | + if (id !== tabId) return; | ||
| 123 | + if (info.status !== "complete") return; | ||
| 124 | + if (expectedUrlPrefix && !updatedTab.url?.startsWith(expectedUrlPrefix.slice(0, 20))) return; | ||
| 125 | + chrome.tabs.onUpdated.removeListener(listener); | ||
| 126 | + resolve(); | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + chrome.tabs.onUpdated.addListener(listener); | ||
| 130 | + | ||
| 131 | + // 轮询兜底:若事件在监听前已触发 | ||
| 132 | + const poll = async () => { | ||
| 133 | + if (Date.now() > deadline) { | ||
| 134 | + chrome.tabs.onUpdated.removeListener(listener); | ||
| 135 | + reject(new Error("页面加载超时")); | ||
| 136 | + return; | ||
| 137 | + } | ||
| 138 | + const tab = await chrome.tabs.get(tabId).catch(() => null); | ||
| 139 | + if (tab && tab.status === "complete") { | ||
| 140 | + chrome.tabs.onUpdated.removeListener(listener); | ||
| 141 | + resolve(); | ||
| 142 | + return; | ||
| 143 | + } | ||
| 144 | + setTimeout(poll, 400); | ||
| 145 | + }; | ||
| 146 | + setTimeout(poll, 600); | ||
| 147 | + }); | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +// ───────────────────────── 截图 ───────────────────────── | ||
| 151 | + | ||
| 152 | +async function cmdScreenshot() { | ||
| 153 | + const tab = await getOrOpenXhsTab(); | ||
| 154 | + const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" }); | ||
| 155 | + return { data: dataUrl.split(",")[1] }; | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +// ───────────────────────── Cookies ───────────────────────── | ||
| 159 | + | ||
| 160 | +async function cmdGetCookies({ domain = "xiaohongshu.com" }) { | ||
| 161 | + return await chrome.cookies.getAll({ domain }); | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +// ───────────────────────── MAIN world JS 执行 ───────────────────────── | ||
| 165 | + | ||
| 166 | +async function cmdEvaluateInMainWorld(method, params) { | ||
| 167 | + const tab = await getOrOpenXhsTab(); | ||
| 168 | + const results = await chrome.scripting.executeScript({ | ||
| 169 | + target: { tabId: tab.id }, | ||
| 170 | + world: "MAIN", | ||
| 171 | + func: mainWorldExecutor, | ||
| 172 | + args: [method, params], | ||
| 173 | + }); | ||
| 174 | + const r = results?.[0]?.result; | ||
| 175 | + if (r && typeof r === "object" && "__xhs_error" in r) { | ||
| 176 | + throw new Error(r.__xhs_error); | ||
| 177 | + } | ||
| 178 | + return r; | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +/** | ||
| 182 | + * 在页面主 world 运行,可访问 window.__INITIAL_STATE__ 等页面全局变量。 | ||
| 183 | + * 注意:此函数被序列化后注入页面,不能引用外部变量。 | ||
| 184 | + */ | ||
| 185 | +function mainWorldExecutor(method, params) { | ||
| 186 | + function poll(check, interval, timeout) { | ||
| 187 | + return new Promise((resolve, reject) => { | ||
| 188 | + const start = Date.now(); | ||
| 189 | + (function tick() { | ||
| 190 | + const result = check(); | ||
| 191 | + if (result !== false && result !== null && result !== undefined) { | ||
| 192 | + resolve(result); | ||
| 193 | + return; | ||
| 194 | + } | ||
| 195 | + if (Date.now() - start >= timeout) { | ||
| 196 | + reject(new Error("超时")); | ||
| 197 | + return; | ||
| 198 | + } | ||
| 199 | + setTimeout(tick, interval); | ||
| 200 | + })(); | ||
| 201 | + }); | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + switch (method) { | ||
| 205 | + case "evaluate": { | ||
| 206 | + try { | ||
| 207 | + // eslint-disable-next-line no-new-func | ||
| 208 | + return Function(`"use strict"; return (${params.expression})`)(); | ||
| 209 | + } catch (e) { | ||
| 210 | + return { __xhs_error: `JS执行错误: ${e.message}` }; | ||
| 211 | + } | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + case "has_element": | ||
| 215 | + return document.querySelector(params.selector) !== null; | ||
| 216 | + | ||
| 217 | + case "get_elements_count": | ||
| 218 | + return document.querySelectorAll(params.selector).length; | ||
| 219 | + | ||
| 220 | + case "get_element_text": { | ||
| 221 | + const el = document.querySelector(params.selector); | ||
| 222 | + return el ? el.textContent : null; | ||
| 223 | + } | ||
| 224 | + | ||
| 225 | + case "get_element_attribute": { | ||
| 226 | + const el = document.querySelector(params.selector); | ||
| 227 | + return el ? el.getAttribute(params.attr) : null; | ||
| 228 | + } | ||
| 229 | + | ||
| 230 | + case "get_scroll_top": | ||
| 231 | + return window.pageYOffset || document.documentElement.scrollTop || 0; | ||
| 232 | + | ||
| 233 | + case "get_viewport_height": | ||
| 234 | + return window.innerHeight; | ||
| 235 | + | ||
| 236 | + case "get_url": | ||
| 237 | + return window.location.href; | ||
| 238 | + | ||
| 239 | + case "wait_dom_stable": { | ||
| 240 | + const timeout = params.timeout || 10000; | ||
| 241 | + const interval = params.interval || 500; | ||
| 242 | + return new Promise((resolve) => { | ||
| 243 | + let last = -1; | ||
| 244 | + const start = Date.now(); | ||
| 245 | + (function tick() { | ||
| 246 | + const size = document.body ? document.body.innerHTML.length : 0; | ||
| 247 | + if (size === last && size > 0) { resolve(null); return; } | ||
| 248 | + last = size; | ||
| 249 | + if (Date.now() - start >= timeout) { resolve(null); return; } | ||
| 250 | + setTimeout(tick, interval); | ||
| 251 | + })(); | ||
| 252 | + }); | ||
| 253 | + } | ||
| 254 | + | ||
| 255 | + case "wait_for_selector": { | ||
| 256 | + const timeout = params.timeout || 30000; | ||
| 257 | + return poll( | ||
| 258 | + () => document.querySelector(params.selector) ? true : false, | ||
| 259 | + 200, | ||
| 260 | + timeout, | ||
| 261 | + ).catch(() => { throw new Error(`等待元素超时: ${params.selector}`); }); | ||
| 262 | + } | ||
| 263 | + | ||
| 264 | + default: | ||
| 265 | + return { __xhs_error: `未知 MAIN world 方法: ${method}` }; | ||
| 266 | + } | ||
| 267 | +} | ||
| 268 | + | ||
| 269 | +// ───────────────────────── 文件上传(chrome.debugger + CDP) ───────── | ||
| 270 | + | ||
| 271 | +async function cmdSetFileInputViaDebugger({ selector, files }) { | ||
| 272 | + const tab = await getOrOpenXhsTab(); | ||
| 273 | + const target = { tabId: tab.id }; | ||
| 274 | + | ||
| 275 | + await chrome.debugger.attach(target, "1.3"); | ||
| 276 | + try { | ||
| 277 | + const { root } = await chrome.debugger.sendCommand(target, "DOM.getDocument", { depth: 0 }); | ||
| 278 | + const { nodeId } = await chrome.debugger.sendCommand(target, "DOM.querySelector", { | ||
| 279 | + nodeId: root.nodeId, | ||
| 280 | + selector, | ||
| 281 | + }); | ||
| 282 | + if (!nodeId) throw new Error(`文件输入框不存在: ${selector}`); | ||
| 283 | + await chrome.debugger.sendCommand(target, "DOM.setFileInputFiles", { | ||
| 284 | + nodeId, | ||
| 285 | + files, // 本地文件路径数组,由 Python 侧提供 | ||
| 286 | + }); | ||
| 287 | + } finally { | ||
| 288 | + await chrome.debugger.detach(target).catch(() => {}); | ||
| 289 | + } | ||
| 290 | + return null; | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +// ───────────────────────── DOM 操作(MAIN world) ──────────────────── | ||
| 294 | + | ||
| 295 | +async function cmdDomInMainWorld(method, params) { | ||
| 296 | + const tab = await getOrOpenXhsTab(); | ||
| 297 | + const results = await chrome.scripting.executeScript({ | ||
| 298 | + target: { tabId: tab.id }, | ||
| 299 | + world: "MAIN", | ||
| 300 | + func: domExecutor, | ||
| 301 | + args: [method, params], | ||
| 302 | + }); | ||
| 303 | + const r = results?.[0]?.result; | ||
| 304 | + if (r && typeof r === "object" && "__xhs_error" in r) { | ||
| 305 | + throw new Error(r.__xhs_error); | ||
| 306 | + } | ||
| 307 | + return r ?? null; | ||
| 308 | +} | ||
| 309 | + | ||
| 310 | +/** | ||
| 311 | + * DOM 操作执行器,在页面 MAIN world 运行。 | ||
| 312 | + * 不能引用外部变量,所有逻辑自包含。 | ||
| 313 | + */ | ||
| 314 | +function domExecutor(method, params) { | ||
| 315 | + function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | ||
| 316 | + | ||
| 317 | + function requireEl(selector) { | ||
| 318 | + const el = document.querySelector(selector); | ||
| 319 | + if (!el) return { __xhs_error: `元素不存在: ${selector}` }; | ||
| 320 | + return el; | ||
| 321 | + } | ||
| 322 | + | ||
| 323 | + switch (method) { | ||
| 324 | + case "click_element": { | ||
| 325 | + const el = requireEl(params.selector); | ||
| 326 | + if (el.__xhs_error) return el; | ||
| 327 | + el.scrollIntoView({ block: "center" }); | ||
| 328 | + el.focus(); | ||
| 329 | + el.click(); | ||
| 330 | + return null; | ||
| 331 | + } | ||
| 332 | + | ||
| 333 | + case "input_text": { | ||
| 334 | + const el = requireEl(params.selector); | ||
| 335 | + if (el.__xhs_error) return el; | ||
| 336 | + el.focus(); | ||
| 337 | + el.value = params.text; | ||
| 338 | + el.dispatchEvent(new Event("input", { bubbles: true })); | ||
| 339 | + el.dispatchEvent(new Event("change", { bubbles: true })); | ||
| 340 | + return null; | ||
| 341 | + } | ||
| 342 | + | ||
| 343 | + case "input_content_editable": { | ||
| 344 | + return new Promise(async (resolve) => { | ||
| 345 | + const el = document.querySelector(params.selector); | ||
| 346 | + if (!el) { resolve({ __xhs_error: `元素不存在: ${params.selector}` }); return; } | ||
| 347 | + el.focus(); | ||
| 348 | + document.execCommand("selectAll", false, null); | ||
| 349 | + document.execCommand("delete", false, null); | ||
| 350 | + await sleep(80); | ||
| 351 | + const lines = params.text.split("\n"); | ||
| 352 | + for (let i = 0; i < lines.length; i++) { | ||
| 353 | + if (lines[i]) document.execCommand("insertText", false, lines[i]); | ||
| 354 | + if (i < lines.length - 1) { | ||
| 355 | + // insertParagraph 才能在 contenteditable 里真正插入换行 | ||
| 356 | + document.execCommand("insertParagraph", false, null); | ||
| 357 | + await sleep(30); | ||
| 358 | + } | ||
| 359 | + } | ||
| 360 | + resolve(null); | ||
| 361 | + }); | ||
| 362 | + } | ||
| 363 | + | ||
| 364 | + case "set_file_input": { | ||
| 365 | + return new Promise((resolve) => { | ||
| 366 | + const el = document.querySelector(params.selector); | ||
| 367 | + if (!el) { resolve({ __xhs_error: `文件输入框不存在: ${params.selector}` }); return; } | ||
| 368 | + | ||
| 369 | + function makeFiles() { | ||
| 370 | + const dt = new DataTransfer(); | ||
| 371 | + for (const f of params.files) { | ||
| 372 | + const bytes = Uint8Array.from(atob(f.data), c => c.charCodeAt(0)); | ||
| 373 | + dt.items.add(new File([bytes], f.name, { type: f.type })); | ||
| 374 | + } | ||
| 375 | + return dt; | ||
| 376 | + } | ||
| 377 | + | ||
| 378 | + // 方法1: 覆盖 files 属性 + change 事件(标准 file input) | ||
| 379 | + try { | ||
| 380 | + const dt = makeFiles(); | ||
| 381 | + Object.defineProperty(el, "files", { value: dt.files, configurable: true, writable: true }); | ||
| 382 | + el.dispatchEvent(new Event("change", { bubbles: true })); | ||
| 383 | + el.dispatchEvent(new Event("input", { bubbles: true })); | ||
| 384 | + } catch (e) {} | ||
| 385 | + | ||
| 386 | + // 方法2: drag-drop 到上传区域(XHS 主要监听 drop 事件) | ||
| 387 | + const dropTarget = | ||
| 388 | + el.closest('[class*="upload"]') || | ||
| 389 | + el.closest('[class*="Upload"]') || | ||
| 390 | + el.parentElement; | ||
| 391 | + if (dropTarget) { | ||
| 392 | + try { | ||
| 393 | + const dt2 = makeFiles(); | ||
| 394 | + dropTarget.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: dt2 })); | ||
| 395 | + dropTarget.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer: dt2 })); | ||
| 396 | + dropTarget.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt2 })); | ||
| 397 | + } catch (e) {} | ||
| 398 | + } | ||
| 399 | + | ||
| 400 | + resolve(null); | ||
| 401 | + }); | ||
| 402 | + } | ||
| 403 | + | ||
| 404 | + case "scroll_by": | ||
| 405 | + window.scrollBy(params.x || 0, params.y || 0); return null; | ||
| 406 | + case "scroll_to": | ||
| 407 | + window.scrollTo(params.x || 0, params.y || 0); return null; | ||
| 408 | + case "scroll_to_bottom": | ||
| 409 | + window.scrollTo(0, document.body.scrollHeight); return null; | ||
| 410 | + | ||
| 411 | + case "scroll_element_into_view": { | ||
| 412 | + const el = document.querySelector(params.selector); | ||
| 413 | + if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }); | ||
| 414 | + return null; | ||
| 415 | + } | ||
| 416 | + case "scroll_nth_element_into_view": { | ||
| 417 | + const els = document.querySelectorAll(params.selector); | ||
| 418 | + if (els[params.index]) els[params.index].scrollIntoView({ behavior: "smooth", block: "center" }); | ||
| 419 | + return null; | ||
| 420 | + } | ||
| 421 | + | ||
| 422 | + case "dispatch_wheel_event": { | ||
| 423 | + const target = document.querySelector(".note-scroller") || | ||
| 424 | + document.querySelector(".interaction-container") || document.documentElement; | ||
| 425 | + target.dispatchEvent(new WheelEvent("wheel", { deltaY: params.deltaY || 0, deltaMode: 0, bubbles: true, cancelable: true })); | ||
| 426 | + return null; | ||
| 427 | + } | ||
| 428 | + | ||
| 429 | + case "mouse_move": | ||
| 430 | + document.dispatchEvent(new MouseEvent("mousemove", { clientX: params.x, clientY: params.y, bubbles: true })); | ||
| 431 | + return null; | ||
| 432 | + | ||
| 433 | + case "mouse_click": { | ||
| 434 | + const el = document.elementFromPoint(params.x, params.y); | ||
| 435 | + if (el) { | ||
| 436 | + ["mousedown", "mouseup", "click"].forEach(t => | ||
| 437 | + el.dispatchEvent(new MouseEvent(t, { clientX: params.x, clientY: params.y, bubbles: true })) | ||
| 438 | + ); | ||
| 439 | + } | ||
| 440 | + return null; | ||
| 441 | + } | ||
| 442 | + | ||
| 443 | + case "press_key": { | ||
| 444 | + const active = document.activeElement || document.body; | ||
| 445 | + const inCE = active.isContentEditable; | ||
| 446 | + if (inCE && params.key === "Enter") { | ||
| 447 | + document.execCommand("insertParagraph", false, null); | ||
| 448 | + return null; | ||
| 449 | + } | ||
| 450 | + if (inCE && params.key === "ArrowDown") { | ||
| 451 | + // 将光标移到内容末尾(等价于多次下移到底) | ||
| 452 | + const sel = window.getSelection(); | ||
| 453 | + if (sel && active.childNodes.length) { | ||
| 454 | + sel.selectAllChildren(active); | ||
| 455 | + sel.collapseToEnd(); | ||
| 456 | + } | ||
| 457 | + return null; | ||
| 458 | + } | ||
| 459 | + const keyMap = { | ||
| 460 | + Enter: { key: "Enter", code: "Enter", keyCode: 13 }, | ||
| 461 | + ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }, | ||
| 462 | + Tab: { key: "Tab", code: "Tab", keyCode: 9 }, | ||
| 463 | + Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 }, | ||
| 464 | + }; | ||
| 465 | + const info = keyMap[params.key] || { key: params.key, code: params.key, keyCode: 0 }; | ||
| 466 | + active.dispatchEvent(new KeyboardEvent("keydown", { ...info, bubbles: true })); | ||
| 467 | + active.dispatchEvent(new KeyboardEvent("keyup", { ...info, bubbles: true })); | ||
| 468 | + return null; | ||
| 469 | + } | ||
| 470 | + | ||
| 471 | + case "type_text": { | ||
| 472 | + return new Promise(async (resolve) => { | ||
| 473 | + const active = document.activeElement || document.body; | ||
| 474 | + const inCE = active.isContentEditable; | ||
| 475 | + for (const char of params.text) { | ||
| 476 | + if (inCE) { | ||
| 477 | + document.execCommand("insertText", false, char); | ||
| 478 | + } else { | ||
| 479 | + active.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true })); | ||
| 480 | + active.dispatchEvent(new KeyboardEvent("keypress", { key: char, bubbles: true })); | ||
| 481 | + active.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true })); | ||
| 482 | + } | ||
| 483 | + await sleep(params.delayMs || 50); | ||
| 484 | + } | ||
| 485 | + resolve(null); | ||
| 486 | + }); | ||
| 487 | + } | ||
| 488 | + | ||
| 489 | + case "remove_element": { | ||
| 490 | + const el = document.querySelector(params.selector); | ||
| 491 | + if (el) el.remove(); | ||
| 492 | + return null; | ||
| 493 | + } | ||
| 494 | + | ||
| 495 | + case "hover_element": { | ||
| 496 | + const el = document.querySelector(params.selector); | ||
| 497 | + if (el) { | ||
| 498 | + const rect = el.getBoundingClientRect(); | ||
| 499 | + const x = rect.left + rect.width / 2, y = rect.top + rect.height / 2; | ||
| 500 | + el.dispatchEvent(new MouseEvent("mouseover", { clientX: x, clientY: y, bubbles: true })); | ||
| 501 | + el.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true })); | ||
| 502 | + } | ||
| 503 | + return null; | ||
| 504 | + } | ||
| 505 | + | ||
| 506 | + case "select_all_text": { | ||
| 507 | + const el = document.querySelector(params.selector); | ||
| 508 | + if (el) { el.focus(); if (el.select) el.select(); else document.execCommand("selectAll"); } | ||
| 509 | + return null; | ||
| 510 | + } | ||
| 511 | + | ||
| 512 | + default: | ||
| 513 | + return { __xhs_error: `未知 DOM 命令: ${method}` }; | ||
| 514 | + } | ||
| 515 | +} | ||
| 516 | + | ||
| 517 | +// ───────────────────────── Tab 管理 ───────────────────────── | ||
| 518 | + | ||
| 519 | +async function getOrOpenXhsTab() { | ||
| 520 | + const tabs = await chrome.tabs.query({ | ||
| 521 | + url: [ | ||
| 522 | + "https://www.xiaohongshu.com/*", | ||
| 523 | + "https://xiaohongshu.com/*", | ||
| 524 | + "https://creator.xiaohongshu.com/*", | ||
| 525 | + ], | ||
| 526 | + }); | ||
| 527 | + if (tabs.length > 0) return tabs[0]; | ||
| 528 | + // 没有已打开的 XHS 页面,新建一个 | ||
| 529 | + const tab = await chrome.tabs.create({ url: "https://www.xiaohongshu.com/" }); | ||
| 530 | + await waitForTabComplete(tab.id, null, 30000); | ||
| 531 | + return tab; | ||
| 532 | +} | ||
| 533 | + | ||
| 534 | +// ───────────────────────── 启动 ───────────────────────── | ||
| 535 | + | ||
| 536 | +connect(); |
extension/content.js
0 → 100644
| 1 | +/** | ||
| 2 | + * XHS Bridge - Content Script(隔离 world) | ||
| 3 | + * | ||
| 4 | + * 接收来自 background.js 的 DOM 操作命令并执行。 | ||
| 5 | + * evaluate / has_element 等需要访问页面 JS 变量的命令由 background.js | ||
| 6 | + * 通过 chrome.scripting.executeScript(world:"MAIN") 直接处理,不经过这里。 | ||
| 7 | + */ | ||
| 8 | + | ||
| 9 | +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { | ||
| 10 | + handleDomCommand(msg.method, msg.params || {}) | ||
| 11 | + .then((result) => sendResponse({ result: result ?? null })) | ||
| 12 | + .catch((err) => sendResponse({ error: String(err.message || err) })); | ||
| 13 | + return true; // 异步响应 | ||
| 14 | +}); | ||
| 15 | + | ||
| 16 | +async function handleDomCommand(method, params) { | ||
| 17 | + switch (method) { | ||
| 18 | + case "click_element": | ||
| 19 | + return cmdClickElement(params); | ||
| 20 | + | ||
| 21 | + case "input_text": | ||
| 22 | + return cmdInputText(params); | ||
| 23 | + | ||
| 24 | + case "input_content_editable": | ||
| 25 | + return cmdInputContentEditable(params); | ||
| 26 | + | ||
| 27 | + case "scroll_by": | ||
| 28 | + window.scrollBy(params.x || 0, params.y || 0); | ||
| 29 | + return null; | ||
| 30 | + | ||
| 31 | + case "scroll_to": | ||
| 32 | + window.scrollTo(params.x || 0, params.y || 0); | ||
| 33 | + return null; | ||
| 34 | + | ||
| 35 | + case "scroll_to_bottom": | ||
| 36 | + window.scrollTo(0, document.body.scrollHeight); | ||
| 37 | + return null; | ||
| 38 | + | ||
| 39 | + case "scroll_element_into_view": { | ||
| 40 | + const el = document.querySelector(params.selector); | ||
| 41 | + if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }); | ||
| 42 | + return null; | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + case "scroll_nth_element_into_view": { | ||
| 46 | + const els = document.querySelectorAll(params.selector); | ||
| 47 | + if (els[params.index]) els[params.index].scrollIntoView({ behavior: "smooth", block: "center" }); | ||
| 48 | + return null; | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + case "dispatch_wheel_event": { | ||
| 52 | + const target = | ||
| 53 | + document.querySelector(".note-scroller") || | ||
| 54 | + document.querySelector(".interaction-container") || | ||
| 55 | + document.documentElement; | ||
| 56 | + target.dispatchEvent( | ||
| 57 | + new WheelEvent("wheel", { | ||
| 58 | + deltaY: params.deltaY || 0, | ||
| 59 | + deltaMode: 0, | ||
| 60 | + bubbles: true, | ||
| 61 | + cancelable: true, | ||
| 62 | + view: window, | ||
| 63 | + }), | ||
| 64 | + ); | ||
| 65 | + return null; | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + case "mouse_move": { | ||
| 69 | + document.dispatchEvent( | ||
| 70 | + new MouseEvent("mousemove", { clientX: params.x, clientY: params.y, bubbles: true }), | ||
| 71 | + ); | ||
| 72 | + return null; | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + case "mouse_click": { | ||
| 76 | + const el = document.elementFromPoint(params.x, params.y); | ||
| 77 | + if (el) { | ||
| 78 | + el.dispatchEvent(new MouseEvent("mousedown", { clientX: params.x, clientY: params.y, bubbles: true })); | ||
| 79 | + el.dispatchEvent(new MouseEvent("mouseup", { clientX: params.x, clientY: params.y, bubbles: true })); | ||
| 80 | + el.dispatchEvent(new MouseEvent("click", { clientX: params.x, clientY: params.y, bubbles: true })); | ||
| 81 | + } | ||
| 82 | + return null; | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + case "press_key": { | ||
| 86 | + const keyMap = { | ||
| 87 | + Enter: { key: "Enter", code: "Enter", keyCode: 13 }, | ||
| 88 | + ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }, | ||
| 89 | + Tab: { key: "Tab", code: "Tab", keyCode: 9 }, | ||
| 90 | + Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 }, | ||
| 91 | + }; | ||
| 92 | + const info = keyMap[params.key] || { key: params.key, code: params.key, keyCode: 0 }; | ||
| 93 | + const active = document.activeElement || document.body; | ||
| 94 | + active.dispatchEvent(new KeyboardEvent("keydown", { ...info, bubbles: true })); | ||
| 95 | + active.dispatchEvent(new KeyboardEvent("keyup", { ...info, bubbles: true })); | ||
| 96 | + return null; | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + case "type_text": { | ||
| 100 | + const active = document.activeElement || document.body; | ||
| 101 | + const delay = params.delayMs || 50; | ||
| 102 | + for (const char of params.text) { | ||
| 103 | + active.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true })); | ||
| 104 | + active.dispatchEvent(new KeyboardEvent("keypress", { key: char, bubbles: true })); | ||
| 105 | + active.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true })); | ||
| 106 | + await sleep(delay); | ||
| 107 | + } | ||
| 108 | + return null; | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + case "remove_element": { | ||
| 112 | + const el = document.querySelector(params.selector); | ||
| 113 | + if (el) el.remove(); | ||
| 114 | + return null; | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + case "hover_element": { | ||
| 118 | + const el = document.querySelector(params.selector); | ||
| 119 | + if (el) { | ||
| 120 | + const rect = el.getBoundingClientRect(); | ||
| 121 | + const x = rect.left + rect.width / 2; | ||
| 122 | + const y = rect.top + rect.height / 2; | ||
| 123 | + el.dispatchEvent(new MouseEvent("mouseover", { clientX: x, clientY: y, bubbles: true })); | ||
| 124 | + el.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true })); | ||
| 125 | + } | ||
| 126 | + return null; | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + case "select_all_text": { | ||
| 130 | + const el = document.querySelector(params.selector); | ||
| 131 | + if (el) { | ||
| 132 | + el.focus(); | ||
| 133 | + if (el.select) el.select(); | ||
| 134 | + else document.execCommand("selectAll"); | ||
| 135 | + } | ||
| 136 | + return null; | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + case "set_file_input": | ||
| 140 | + return cmdSetFileInput(params); | ||
| 141 | + | ||
| 142 | + default: | ||
| 143 | + throw new Error(`content.js: 未知命令 ${method}`); | ||
| 144 | + } | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +// ───────────────────────── 具体实现 ───────────────────────── | ||
| 148 | + | ||
| 149 | +function cmdClickElement({ selector }) { | ||
| 150 | + const el = document.querySelector(selector); | ||
| 151 | + if (!el) throw new Error(`元素不存在: ${selector}`); | ||
| 152 | + el.scrollIntoView({ block: "center" }); | ||
| 153 | + el.click(); | ||
| 154 | + return null; | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +function cmdInputText({ selector, text }) { | ||
| 158 | + const el = document.querySelector(selector); | ||
| 159 | + if (!el) throw new Error(`元素不存在: ${selector}`); | ||
| 160 | + el.focus(); | ||
| 161 | + el.value = text; | ||
| 162 | + el.dispatchEvent(new Event("input", { bubbles: true })); | ||
| 163 | + el.dispatchEvent(new Event("change", { bubbles: true })); | ||
| 164 | + return null; | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +async function cmdInputContentEditable({ selector, text }) { | ||
| 168 | + const el = document.querySelector(selector); | ||
| 169 | + if (!el) throw new Error(`元素不存在: ${selector}`); | ||
| 170 | + el.focus(); | ||
| 171 | + // 全选清空 | ||
| 172 | + document.execCommand("selectAll", false, null); | ||
| 173 | + document.execCommand("delete", false, null); | ||
| 174 | + await sleep(80); | ||
| 175 | + // 逐行插入(换行转为 Enter 键事件) | ||
| 176 | + const lines = text.split("\n"); | ||
| 177 | + for (let i = 0; i < lines.length; i++) { | ||
| 178 | + if (lines[i]) document.execCommand("insertText", false, lines[i]); | ||
| 179 | + if (i < lines.length - 1) { | ||
| 180 | + el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true })); | ||
| 181 | + el.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true })); | ||
| 182 | + await sleep(40); | ||
| 183 | + } | ||
| 184 | + } | ||
| 185 | + return null; | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +async function cmdSetFileInput({ selector, files }) { | ||
| 189 | + const el = document.querySelector(selector); | ||
| 190 | + if (!el) throw new Error(`文件输入框不存在: ${selector}`); | ||
| 191 | + | ||
| 192 | + const dt = new DataTransfer(); | ||
| 193 | + for (const f of files) { | ||
| 194 | + const bytes = Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0)); | ||
| 195 | + const blob = new Blob([bytes], { type: f.type }); | ||
| 196 | + dt.items.add(new File([blob], f.name, { type: f.type })); | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + Object.defineProperty(el, "files", { | ||
| 200 | + value: dt.files, | ||
| 201 | + configurable: true, | ||
| 202 | + writable: true, | ||
| 203 | + }); | ||
| 204 | + | ||
| 205 | + el.dispatchEvent(new Event("change", { bubbles: true })); | ||
| 206 | + el.dispatchEvent(new Event("input", { bubbles: true })); | ||
| 207 | + return null; | ||
| 208 | +} | ||
| 209 | + | ||
| 210 | +function sleep(ms) { | ||
| 211 | + return new Promise((r) => setTimeout(r, ms)); | ||
| 212 | +} |
extension/manifest.json
0 → 100644
| 1 | +{ | ||
| 2 | + "manifest_version": 3, | ||
| 3 | + "name": "XHS Bridge", | ||
| 4 | + "version": "1.0.0", | ||
| 5 | + "description": "小红书自动化 Bridge - 连接本地 Python CLI", | ||
| 6 | + "permissions": [ | ||
| 7 | + "tabs", | ||
| 8 | + "cookies", | ||
| 9 | + "scripting", | ||
| 10 | + "alarms", | ||
| 11 | + "debugger" | ||
| 12 | + ], | ||
| 13 | + "host_permissions": [ | ||
| 14 | + "https://www.xiaohongshu.com/*", | ||
| 15 | + "https://xiaohongshu.com/*", | ||
| 16 | + "https://creator.xiaohongshu.com/*", | ||
| 17 | + "ws://localhost/*" | ||
| 18 | + ], | ||
| 19 | + "background": { | ||
| 20 | + "service_worker": "background.js" | ||
| 21 | + }, | ||
| 22 | + "content_scripts": [ | ||
| 23 | + { | ||
| 24 | + "matches": [ | ||
| 25 | + "https://www.xiaohongshu.com/*", | ||
| 26 | + "https://xiaohongshu.com/*", | ||
| 27 | + "https://creator.xiaohongshu.com/*" | ||
| 28 | + ], | ||
| 29 | + "js": ["content.js"], | ||
| 30 | + "run_at": "document_idle" | ||
| 31 | + } | ||
| 32 | + ] | ||
| 33 | +} |
scripts/account_manager.py
deleted
100644 → 0
| 1 | -"""多账号管理,对应独立的账号配置管理。""" | ||
| 2 | - | ||
| 3 | -from __future__ import annotations | ||
| 4 | - | ||
| 5 | -import json | ||
| 6 | -import logging | ||
| 7 | -import os | ||
| 8 | -from pathlib import Path | ||
| 9 | - | ||
| 10 | -logger = logging.getLogger(__name__) | ||
| 11 | - | ||
| 12 | -# 账号配置文件路径 | ||
| 13 | -_CONFIG_DIR = Path.home() / ".xhs" | ||
| 14 | -_ACCOUNTS_FILE = _CONFIG_DIR / "accounts.json" | ||
| 15 | - | ||
| 16 | -# 命名账号端口起始值(默认账号使用 9222) | ||
| 17 | -_NAMED_PORT_START = 9223 | ||
| 18 | - | ||
| 19 | - | ||
| 20 | -def _load_config() -> dict: | ||
| 21 | - """加载账号配置。""" | ||
| 22 | - if not _ACCOUNTS_FILE.exists(): | ||
| 23 | - return {"default": "", "accounts": {}} | ||
| 24 | - with open(_ACCOUNTS_FILE, encoding="utf-8") as f: | ||
| 25 | - return json.load(f) | ||
| 26 | - | ||
| 27 | - | ||
| 28 | -def _save_config(config: dict) -> None: | ||
| 29 | - """保存账号配置。""" | ||
| 30 | - _CONFIG_DIR.mkdir(parents=True, exist_ok=True) | ||
| 31 | - with open(_ACCOUNTS_FILE, "w", encoding="utf-8") as f: | ||
| 32 | - json.dump(config, f, ensure_ascii=False, indent=2) | ||
| 33 | - | ||
| 34 | - | ||
| 35 | -def list_accounts() -> list[dict]: | ||
| 36 | - """列出所有账号。""" | ||
| 37 | - config = _load_config() | ||
| 38 | - default = config.get("default", "") | ||
| 39 | - accounts = config.get("accounts", {}) | ||
| 40 | - result = [] | ||
| 41 | - for name, info in accounts.items(): | ||
| 42 | - result.append( | ||
| 43 | - { | ||
| 44 | - "name": name, | ||
| 45 | - "description": info.get("description", ""), | ||
| 46 | - "is_default": name == default, | ||
| 47 | - "profile_dir": get_profile_dir(name), | ||
| 48 | - "port": info.get("port", _NAMED_PORT_START), | ||
| 49 | - } | ||
| 50 | - ) | ||
| 51 | - return result | ||
| 52 | - | ||
| 53 | - | ||
| 54 | -def add_account(name: str, description: str = "") -> None: | ||
| 55 | - """添加账号,自动分配独立端口(从 _NAMED_PORT_START 递增)。""" | ||
| 56 | - config = _load_config() | ||
| 57 | - accounts = config.setdefault("accounts", {}) | ||
| 58 | - if name in accounts: | ||
| 59 | - raise ValueError(f"账号 '{name}' 已存在") | ||
| 60 | - | ||
| 61 | - # 自动分配端口:取已有端口的最大值(至少 _NAMED_PORT_START - 1)加 1 | ||
| 62 | - existing_ports = {info.get("port", _NAMED_PORT_START) for info in accounts.values()} | ||
| 63 | - port = max(existing_ports | {_NAMED_PORT_START - 1}) + 1 | ||
| 64 | - | ||
| 65 | - accounts[name] = {"description": description, "port": port} | ||
| 66 | - | ||
| 67 | - # 如果是第一个账号,设为默认 | ||
| 68 | - if not config.get("default"): | ||
| 69 | - config["default"] = name | ||
| 70 | - | ||
| 71 | - _save_config(config) | ||
| 72 | - | ||
| 73 | - # 创建 Profile 目录 | ||
| 74 | - profile_dir = get_profile_dir(name) | ||
| 75 | - os.makedirs(profile_dir, exist_ok=True) | ||
| 76 | - | ||
| 77 | - logger.info("添加账号: %s (port=%d)", name, port) | ||
| 78 | - | ||
| 79 | - | ||
| 80 | -def remove_account(name: str) -> None: | ||
| 81 | - """删除账号。""" | ||
| 82 | - config = _load_config() | ||
| 83 | - accounts = config.get("accounts", {}) | ||
| 84 | - if name not in accounts: | ||
| 85 | - raise ValueError(f"账号 '{name}' 不存在") | ||
| 86 | - | ||
| 87 | - del accounts[name] | ||
| 88 | - | ||
| 89 | - # 如果删除的是默认账号,清除默认 | ||
| 90 | - if config.get("default") == name: | ||
| 91 | - config["default"] = next(iter(accounts), "") | ||
| 92 | - | ||
| 93 | - _save_config(config) | ||
| 94 | - logger.info("删除账号: %s", name) | ||
| 95 | - | ||
| 96 | - | ||
| 97 | -def set_default_account(name: str) -> None: | ||
| 98 | - """设置默认账号。""" | ||
| 99 | - config = _load_config() | ||
| 100 | - accounts = config.get("accounts", {}) | ||
| 101 | - if name not in accounts: | ||
| 102 | - raise ValueError(f"账号 '{name}' 不存在") | ||
| 103 | - | ||
| 104 | - config["default"] = name | ||
| 105 | - _save_config(config) | ||
| 106 | - logger.info("默认账号设置为: %s", name) | ||
| 107 | - | ||
| 108 | - | ||
| 109 | -def update_account_description(name: str, description: str) -> None: | ||
| 110 | - """更新账号描述(通常用于存储平台昵称)。""" | ||
| 111 | - config = _load_config() | ||
| 112 | - accounts = config.get("accounts", {}) | ||
| 113 | - if name not in accounts: | ||
| 114 | - raise ValueError(f"账号 '{name}' 不存在") | ||
| 115 | - accounts[name]["description"] = description | ||
| 116 | - _save_config(config) | ||
| 117 | - logger.info("账号 %s 描述已更新: %s", name, description) | ||
| 118 | - | ||
| 119 | - | ||
| 120 | -def get_default_account() -> str: | ||
| 121 | - """获取默认账号名称。""" | ||
| 122 | - config = _load_config() | ||
| 123 | - return config.get("default", "") | ||
| 124 | - | ||
| 125 | - | ||
| 126 | -def get_profile_dir(account: str) -> str: | ||
| 127 | - """获取账号的 Chrome Profile 目录。""" | ||
| 128 | - return str(_CONFIG_DIR / "accounts" / account / "chrome-profile") | ||
| 129 | - | ||
| 130 | - | ||
| 131 | -def _get_profile_dir(account: str) -> str: | ||
| 132 | - """获取账号的 Chrome Profile 目录(别名,向后兼容)。""" | ||
| 133 | - return get_profile_dir(account) | ||
| 134 | - | ||
| 135 | - | ||
| 136 | -def get_account_port(name: str) -> int: | ||
| 137 | - """获取指定账号的 Chrome 调试端口。""" | ||
| 138 | - config = _load_config() | ||
| 139 | - accounts = config.get("accounts", {}) | ||
| 140 | - if name not in accounts: | ||
| 141 | - raise ValueError(f"账号 '{name}' 不存在") | ||
| 142 | - return accounts[name].get("port", _NAMED_PORT_START) |
scripts/bridge_server.py
0 → 100644
| 1 | +"""XHS Extension Bridge Server | ||
| 2 | + | ||
| 3 | +Extension 连接到这里(WebSocket),CLI 命令通过同一端口发送(role=cli), | ||
| 4 | +Bridge 将命令路由给 Extension 并把结果返回给 CLI。 | ||
| 5 | + | ||
| 6 | +启动方式: | ||
| 7 | + python scripts/bridge_server.py | ||
| 8 | + | ||
| 9 | +端口:9333(可通过 --port 覆盖) | ||
| 10 | +""" | ||
| 11 | + | ||
| 12 | +from __future__ import annotations | ||
| 13 | + | ||
| 14 | +import argparse | ||
| 15 | +import asyncio | ||
| 16 | +import json | ||
| 17 | +import logging | ||
| 18 | +import sys | ||
| 19 | +import uuid | ||
| 20 | +from typing import Any | ||
| 21 | + | ||
| 22 | +import websockets | ||
| 23 | +from websockets.server import ServerConnection | ||
| 24 | + | ||
| 25 | +logger = logging.getLogger("xhs-bridge") | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +class BridgeServer: | ||
| 29 | + def __init__(self) -> None: | ||
| 30 | + self._extension_ws: ServerConnection | None = None | ||
| 31 | + self._pending: dict[str, asyncio.Future[Any]] = {} | ||
| 32 | + | ||
| 33 | + async def handle(self, ws: ServerConnection) -> None: | ||
| 34 | + try: | ||
| 35 | + raw = await asyncio.wait_for(ws.recv(), timeout=10) | ||
| 36 | + except (asyncio.TimeoutError, Exception) as e: | ||
| 37 | + logger.warning("握手超时或失败: %s", e) | ||
| 38 | + return | ||
| 39 | + | ||
| 40 | + try: | ||
| 41 | + msg = json.loads(raw) | ||
| 42 | + except json.JSONDecodeError: | ||
| 43 | + return | ||
| 44 | + | ||
| 45 | + role = msg.get("role") | ||
| 46 | + if role == "extension": | ||
| 47 | + await self._handle_extension(ws) | ||
| 48 | + elif role == "cli": | ||
| 49 | + await self._handle_cli(ws, msg) | ||
| 50 | + else: | ||
| 51 | + logger.warning("未知 role: %s", role) | ||
| 52 | + | ||
| 53 | + # ─── Extension 端(长连接) ─────────────────────────────────────── | ||
| 54 | + | ||
| 55 | + async def _handle_extension(self, ws: ServerConnection) -> None: | ||
| 56 | + logger.info("Extension 已连接") | ||
| 57 | + self._extension_ws = ws | ||
| 58 | + try: | ||
| 59 | + async for raw in ws: | ||
| 60 | + try: | ||
| 61 | + msg = json.loads(raw) | ||
| 62 | + except json.JSONDecodeError: | ||
| 63 | + continue | ||
| 64 | + msg_id = msg.get("id") | ||
| 65 | + if msg_id and msg_id in self._pending: | ||
| 66 | + future = self._pending.pop(msg_id) | ||
| 67 | + if not future.done(): | ||
| 68 | + future.set_result(msg) | ||
| 69 | + finally: | ||
| 70 | + self._extension_ws = None | ||
| 71 | + logger.info("Extension 已断开") | ||
| 72 | + # 唤醒所有等待中的 CLI 请求并报错 | ||
| 73 | + for future in self._pending.values(): | ||
| 74 | + if not future.done(): | ||
| 75 | + future.set_exception(ConnectionError("Extension 断开连接")) | ||
| 76 | + self._pending.clear() | ||
| 77 | + | ||
| 78 | + # ─── CLI 端(短连接,发一条命令,收一条回复) ───────────────────── | ||
| 79 | + | ||
| 80 | + async def _handle_cli(self, ws: ServerConnection, msg: dict) -> None: | ||
| 81 | + # 特殊命令:查询 server/extension 状态,无需转发 | ||
| 82 | + if msg.get("method") == "ping_server": | ||
| 83 | + await ws.send(json.dumps({ | ||
| 84 | + "result": {"extension_connected": self._extension_ws is not None} | ||
| 85 | + })) | ||
| 86 | + return | ||
| 87 | + | ||
| 88 | + if not self._extension_ws: | ||
| 89 | + await ws.send(json.dumps({"error": "Extension 未连接,请确认浏览器已安装并启用 XHS Bridge 扩展"})) | ||
| 90 | + return | ||
| 91 | + | ||
| 92 | + msg_id = str(uuid.uuid4()) | ||
| 93 | + msg["id"] = msg_id | ||
| 94 | + | ||
| 95 | + loop = asyncio.get_event_loop() | ||
| 96 | + future: asyncio.Future[Any] = loop.create_future() | ||
| 97 | + self._pending[msg_id] = future | ||
| 98 | + | ||
| 99 | + await self._extension_ws.send(json.dumps(msg)) | ||
| 100 | + | ||
| 101 | + try: | ||
| 102 | + result = await asyncio.wait_for(future, timeout=90.0) | ||
| 103 | + await ws.send(json.dumps(result)) | ||
| 104 | + except asyncio.TimeoutError: | ||
| 105 | + self._pending.pop(msg_id, None) | ||
| 106 | + await ws.send(json.dumps({"error": "命令执行超时(90s)"})) | ||
| 107 | + except ConnectionError as e: | ||
| 108 | + await ws.send(json.dumps({"error": str(e)})) | ||
| 109 | + | ||
| 110 | + | ||
| 111 | +async def main(port: int) -> None: | ||
| 112 | + server = BridgeServer() | ||
| 113 | + async with websockets.serve(server.handle, "localhost", port): | ||
| 114 | + logger.info("Bridge server 已启动: ws://localhost:%d", port) | ||
| 115 | + logger.info("等待浏览器扩展连接...") | ||
| 116 | + await asyncio.Future() # 永久运行 | ||
| 117 | + | ||
| 118 | + | ||
| 119 | +if __name__ == "__main__": | ||
| 120 | + logging.basicConfig( | ||
| 121 | + level=logging.INFO, | ||
| 122 | + format="%(asctime)s %(levelname)s %(name)s: %(message)s", | ||
| 123 | + ) | ||
| 124 | + if sys.stdout and hasattr(sys.stdout, "reconfigure"): | ||
| 125 | + sys.stdout.reconfigure(encoding="utf-8") | ||
| 126 | + | ||
| 127 | + parser = argparse.ArgumentParser(description="XHS Extension Bridge Server") | ||
| 128 | + parser.add_argument("--port", type=int, default=9333, help="监听端口(默认 9333)") | ||
| 129 | + args = parser.parse_args() | ||
| 130 | + | ||
| 131 | + asyncio.run(main(args.port)) |
scripts/chrome_launcher.py
deleted
100644 → 0
| 1 | -"""Chrome 进程管理(跨平台),对应 Go browser/browser.go 的进程管理部分。""" | ||
| 2 | - | ||
| 3 | -from __future__ import annotations | ||
| 4 | - | ||
| 5 | -import contextlib | ||
| 6 | -import json | ||
| 7 | -import logging | ||
| 8 | -import os | ||
| 9 | -import platform | ||
| 10 | -import shutil | ||
| 11 | -import socket | ||
| 12 | -import subprocess | ||
| 13 | -import sys | ||
| 14 | -import time | ||
| 15 | -from pathlib import Path | ||
| 16 | - | ||
| 17 | -from xhs.stealth import STEALTH_ARGS | ||
| 18 | - | ||
| 19 | -logger = logging.getLogger(__name__) | ||
| 20 | - | ||
| 21 | -# 默认远程调试端口 | ||
| 22 | -DEFAULT_PORT = 9222 | ||
| 23 | - | ||
| 24 | -# 全局进程追踪 | ||
| 25 | -_chrome_process: subprocess.Popen | None = None | ||
| 26 | - | ||
| 27 | -# 各平台 Chrome 默认路径 | ||
| 28 | -_CHROME_PATHS: dict[str, list[str]] = { | ||
| 29 | - "Darwin": [ | ||
| 30 | - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", | ||
| 31 | - "/Applications/Chromium.app/Contents/MacOS/Chromium", | ||
| 32 | - ], | ||
| 33 | - "Linux": [ | ||
| 34 | - "/usr/bin/google-chrome", | ||
| 35 | - "/usr/bin/google-chrome-stable", | ||
| 36 | - "/usr/bin/chromium", | ||
| 37 | - "/usr/bin/chromium-browser", | ||
| 38 | - "/snap/bin/chromium", | ||
| 39 | - ], | ||
| 40 | - "Windows": [ | ||
| 41 | - r"C:\Program Files\Google\Chrome\Application\chrome.exe", | ||
| 42 | - r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", | ||
| 43 | - ], | ||
| 44 | -} | ||
| 45 | - | ||
| 46 | - | ||
| 47 | -def _get_default_data_dir() -> str: | ||
| 48 | - """返回默认 Chrome Profile 目录路径。""" | ||
| 49 | - return str(Path.home() / ".xhs" / "chrome-profile") | ||
| 50 | - | ||
| 51 | - | ||
| 52 | -def is_port_open(port: int, host: str = "127.0.0.1") -> bool: | ||
| 53 | - """TCP socket 级端口检测(秒级响应)。""" | ||
| 54 | - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | ||
| 55 | - s.settimeout(1) | ||
| 56 | - try: | ||
| 57 | - s.connect((host, port)) | ||
| 58 | - return True | ||
| 59 | - except (ConnectionRefusedError, TimeoutError, OSError): | ||
| 60 | - return False | ||
| 61 | - | ||
| 62 | - | ||
| 63 | -def find_chrome() -> str | None: | ||
| 64 | - """查找 Chrome 可执行文件路径。""" | ||
| 65 | - # 环境变量优先 | ||
| 66 | - env_path = os.getenv("CHROME_BIN") | ||
| 67 | - if env_path and os.path.isfile(env_path): | ||
| 68 | - return env_path | ||
| 69 | - | ||
| 70 | - # which/where 查找(含 Windows chrome.exe) | ||
| 71 | - chrome = ( | ||
| 72 | - shutil.which("google-chrome") | ||
| 73 | - or shutil.which("chromium") | ||
| 74 | - or shutil.which("chrome") | ||
| 75 | - or shutil.which("chrome.exe") | ||
| 76 | - ) | ||
| 77 | - if chrome: | ||
| 78 | - return chrome | ||
| 79 | - | ||
| 80 | - # 平台默认路径 | ||
| 81 | - system = platform.system() | ||
| 82 | - | ||
| 83 | - # Windows: 额外检查环境变量路径 | ||
| 84 | - if system == "Windows": | ||
| 85 | - for env_var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"): | ||
| 86 | - base = os.environ.get(env_var, "") | ||
| 87 | - if base: | ||
| 88 | - candidate = os.path.join(base, "Google", "Chrome", "Application", "chrome.exe") | ||
| 89 | - if os.path.isfile(candidate): | ||
| 90 | - return candidate | ||
| 91 | - | ||
| 92 | - for path in _CHROME_PATHS.get(system, []): | ||
| 93 | - if os.path.isfile(path): | ||
| 94 | - return path | ||
| 95 | - | ||
| 96 | - return None | ||
| 97 | - | ||
| 98 | - | ||
| 99 | -def is_chrome_running(port: int = DEFAULT_PORT) -> bool: | ||
| 100 | - """检查指定端口的 Chrome 是否在运行(TCP 级检测)。""" | ||
| 101 | - return is_port_open(port) | ||
| 102 | - | ||
| 103 | - | ||
| 104 | -def launch_chrome( | ||
| 105 | - port: int = DEFAULT_PORT, | ||
| 106 | - headless: bool = False, | ||
| 107 | - user_data_dir: str | None = None, | ||
| 108 | - chrome_bin: str | None = None, | ||
| 109 | -) -> subprocess.Popen | None: | ||
| 110 | - """启动 Chrome 进程(带远程调试端口)。 | ||
| 111 | - | ||
| 112 | - Args: | ||
| 113 | - port: 远程调试端口。 | ||
| 114 | - headless: 是否无头模式。 | ||
| 115 | - user_data_dir: 用户数据目录(Profile 隔离),默认 ~/.xhs/chrome-profile。 | ||
| 116 | - chrome_bin: Chrome 可执行文件路径。 | ||
| 117 | - | ||
| 118 | - Returns: | ||
| 119 | - Chrome 子进程,若已在运行则返回 None。 | ||
| 120 | - | ||
| 121 | - Raises: | ||
| 122 | - FileNotFoundError: 未找到 Chrome。 | ||
| 123 | - """ | ||
| 124 | - global _chrome_process | ||
| 125 | - | ||
| 126 | - # 已在运行则跳过 | ||
| 127 | - if is_port_open(port): | ||
| 128 | - logger.info("Chrome 已在运行 (port=%d),跳过启动", port) | ||
| 129 | - return None | ||
| 130 | - | ||
| 131 | - if not chrome_bin: | ||
| 132 | - chrome_bin = find_chrome() | ||
| 133 | - if not chrome_bin: | ||
| 134 | - raise FileNotFoundError("未找到 Chrome,请设置 CHROME_BIN 环境变量或安装 Chrome") | ||
| 135 | - | ||
| 136 | - # 默认 user-data-dir | ||
| 137 | - if not user_data_dir: | ||
| 138 | - user_data_dir = _get_default_data_dir() | ||
| 139 | - | ||
| 140 | - args = [ | ||
| 141 | - chrome_bin, | ||
| 142 | - f"--remote-debugging-port={port}", | ||
| 143 | - f"--user-data-dir={user_data_dir}", | ||
| 144 | - *STEALTH_ARGS, | ||
| 145 | - ] | ||
| 146 | - | ||
| 147 | - if headless: | ||
| 148 | - args.append("--headless=new") | ||
| 149 | - | ||
| 150 | - # 代理 | ||
| 151 | - proxy = os.getenv("XHS_PROXY") | ||
| 152 | - if proxy: | ||
| 153 | - args.append(f"--proxy-server={proxy}") | ||
| 154 | - logger.info("使用代理: %s", _mask_proxy(proxy)) | ||
| 155 | - | ||
| 156 | - logger.info("启动 Chrome: port=%d, headless=%s, profile=%s", port, headless, user_data_dir) | ||
| 157 | - process = subprocess.Popen( | ||
| 158 | - args, | ||
| 159 | - stdout=subprocess.DEVNULL, | ||
| 160 | - stderr=subprocess.DEVNULL, | ||
| 161 | - ) | ||
| 162 | - _chrome_process = process | ||
| 163 | - | ||
| 164 | - # 等待 Chrome 准备就绪 | ||
| 165 | - _wait_for_chrome(port) | ||
| 166 | - return process | ||
| 167 | - | ||
| 168 | - | ||
| 169 | -def close_chrome(process: subprocess.Popen) -> None: | ||
| 170 | - """关闭 Chrome 进程。""" | ||
| 171 | - if process.poll() is not None: | ||
| 172 | - return | ||
| 173 | - | ||
| 174 | - try: | ||
| 175 | - process.terminate() | ||
| 176 | - process.wait(timeout=5) | ||
| 177 | - except (subprocess.TimeoutExpired, OSError): | ||
| 178 | - process.kill() | ||
| 179 | - process.wait(timeout=3) | ||
| 180 | - | ||
| 181 | - logger.info("Chrome 进程已关闭") | ||
| 182 | - | ||
| 183 | - | ||
| 184 | -def kill_chrome(port: int = DEFAULT_PORT) -> None: | ||
| 185 | - """关闭指定端口的 Chrome 实例。 | ||
| 186 | - | ||
| 187 | - 策略: CDP Browser.close → terminate 追踪进程 → 端口查找终止进程。 | ||
| 188 | - | ||
| 189 | - Args: | ||
| 190 | - port: Chrome 调试端口。 | ||
| 191 | - """ | ||
| 192 | - global _chrome_process | ||
| 193 | - | ||
| 194 | - # 策略1: 通过 CDP 关闭 | ||
| 195 | - try: | ||
| 196 | - import requests | ||
| 197 | - | ||
| 198 | - resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2) | ||
| 199 | - if resp.status_code == 200: | ||
| 200 | - ws_url = resp.json().get("webSocketDebuggerUrl") | ||
| 201 | - if ws_url: | ||
| 202 | - import websockets.sync.client | ||
| 203 | - | ||
| 204 | - ws = websockets.sync.client.connect(ws_url) | ||
| 205 | - ws.send(json.dumps({"id": 1, "method": "Browser.close"})) | ||
| 206 | - ws.close() | ||
| 207 | - logger.info("通过 CDP Browser.close 关闭 Chrome (port=%d)", port) | ||
| 208 | - time.sleep(1) | ||
| 209 | - except Exception: | ||
| 210 | - pass | ||
| 211 | - | ||
| 212 | - # 策略2: terminate 追踪的子进程 | ||
| 213 | - if _chrome_process and _chrome_process.poll() is None: | ||
| 214 | - try: | ||
| 215 | - _chrome_process.terminate() | ||
| 216 | - _chrome_process.wait(timeout=5) | ||
| 217 | - logger.info("通过 terminate 关闭追踪的 Chrome 进程") | ||
| 218 | - except Exception: | ||
| 219 | - with contextlib.suppress(Exception): | ||
| 220 | - _chrome_process.kill() | ||
| 221 | - _chrome_process = None | ||
| 222 | - | ||
| 223 | - # 策略3: 通过端口查找并终止进程(跨平台) | ||
| 224 | - if is_port_open(port): | ||
| 225 | - pids = _find_pids_by_port(port) | ||
| 226 | - if pids: | ||
| 227 | - for pid in pids: | ||
| 228 | - _kill_pid(pid) | ||
| 229 | - logger.info("通过进程终止关闭 Chrome (port=%d)", port) | ||
| 230 | - | ||
| 231 | - # 等待端口释放(最多 5s) | ||
| 232 | - deadline = time.monotonic() + 5 | ||
| 233 | - while time.monotonic() < deadline: | ||
| 234 | - if not is_port_open(port): | ||
| 235 | - return | ||
| 236 | - time.sleep(0.5) | ||
| 237 | - | ||
| 238 | - if is_port_open(port): | ||
| 239 | - logger.warning("端口 %d 仍被占用,kill 可能未完全生效", port) | ||
| 240 | - | ||
| 241 | - | ||
| 242 | -def ensure_chrome( | ||
| 243 | - port: int = DEFAULT_PORT, | ||
| 244 | - headless: bool = False, | ||
| 245 | - user_data_dir: str | None = None, | ||
| 246 | - chrome_bin: str | None = None, | ||
| 247 | -) -> bool: | ||
| 248 | - """确保 Chrome 在指定端口可用(一站式入口)。 | ||
| 249 | - | ||
| 250 | - 如果 Chrome 已在运行,直接返回 True。 | ||
| 251 | - 否则尝试启动 Chrome 并等待端口就绪。 | ||
| 252 | - | ||
| 253 | - Args: | ||
| 254 | - port: 远程调试端口。 | ||
| 255 | - headless: 是否无头模式(仅新启动时生效)。 | ||
| 256 | - user_data_dir: 用户数据目录。 | ||
| 257 | - chrome_bin: Chrome 可执行文件路径。 | ||
| 258 | - | ||
| 259 | - Returns: | ||
| 260 | - True 表示 Chrome 可用,False 表示启动失败。 | ||
| 261 | - """ | ||
| 262 | - if is_port_open(port): | ||
| 263 | - return True | ||
| 264 | - | ||
| 265 | - try: | ||
| 266 | - launch_chrome( | ||
| 267 | - port=port, headless=headless, user_data_dir=user_data_dir, chrome_bin=chrome_bin, | ||
| 268 | - ) | ||
| 269 | - return is_port_open(port) | ||
| 270 | - except FileNotFoundError as e: | ||
| 271 | - logger.error("启动 Chrome 失败: %s", e) | ||
| 272 | - return False | ||
| 273 | - | ||
| 274 | - | ||
| 275 | -def restart_chrome( | ||
| 276 | - port: int = DEFAULT_PORT, | ||
| 277 | - headless: bool = False, | ||
| 278 | - user_data_dir: str | None = None, | ||
| 279 | - chrome_bin: str | None = None, | ||
| 280 | -) -> subprocess.Popen | None: | ||
| 281 | - """重启 Chrome:关闭当前实例后以新模式重新启动。 | ||
| 282 | - | ||
| 283 | - Args: | ||
| 284 | - port: 远程调试端口。 | ||
| 285 | - headless: 是否无头模式。 | ||
| 286 | - user_data_dir: 用户数据目录。 | ||
| 287 | - chrome_bin: Chrome 可执行文件路径。 | ||
| 288 | - | ||
| 289 | - Returns: | ||
| 290 | - 新的 Chrome 子进程,或 None。 | ||
| 291 | - """ | ||
| 292 | - logger.info("重启 Chrome: port=%d, headless=%s", port, headless) | ||
| 293 | - kill_chrome(port) | ||
| 294 | - time.sleep(1) | ||
| 295 | - return launch_chrome( | ||
| 296 | - port=port, | ||
| 297 | - headless=headless, | ||
| 298 | - user_data_dir=user_data_dir, | ||
| 299 | - chrome_bin=chrome_bin, | ||
| 300 | - ) | ||
| 301 | - | ||
| 302 | - | ||
| 303 | -def _wait_for_chrome(port: int, timeout: float = 15.0) -> None: | ||
| 304 | - """等待 Chrome 调试端口就绪(TCP 级检测)。""" | ||
| 305 | - deadline = time.monotonic() + timeout | ||
| 306 | - while time.monotonic() < deadline: | ||
| 307 | - if is_port_open(port): | ||
| 308 | - logger.info("Chrome 已就绪 (port=%d)", port) | ||
| 309 | - return | ||
| 310 | - time.sleep(0.5) | ||
| 311 | - logger.warning("等待 Chrome 就绪超时 (port=%d)", port) | ||
| 312 | - | ||
| 313 | - | ||
| 314 | -def _find_pids_by_port(port: int) -> list[int]: | ||
| 315 | - """查找占用指定端口的进程 PID(跨平台)。""" | ||
| 316 | - try: | ||
| 317 | - if sys.platform == "win32": | ||
| 318 | - result = subprocess.run( | ||
| 319 | - ["netstat", "-ano", "-p", "TCP"], | ||
| 320 | - capture_output=True, | ||
| 321 | - text=True, | ||
| 322 | - timeout=5, | ||
| 323 | - ) | ||
| 324 | - if result.returncode != 0: | ||
| 325 | - return [] | ||
| 326 | - pids: list[int] = [] | ||
| 327 | - for line in result.stdout.splitlines(): | ||
| 328 | - if f":{port}" in line and "LISTENING" in line: | ||
| 329 | - parts = line.split() | ||
| 330 | - with contextlib.suppress(ValueError, IndexError): | ||
| 331 | - pids.append(int(parts[-1])) | ||
| 332 | - return list(set(pids)) | ||
| 333 | - else: | ||
| 334 | - result = subprocess.run( | ||
| 335 | - ["lsof", "-ti", f":{port}"], | ||
| 336 | - capture_output=True, | ||
| 337 | - text=True, | ||
| 338 | - timeout=5, | ||
| 339 | - ) | ||
| 340 | - if result.returncode != 0 or not result.stdout.strip(): | ||
| 341 | - return [] | ||
| 342 | - pids = [] | ||
| 343 | - for p in result.stdout.strip().split("\n"): | ||
| 344 | - with contextlib.suppress(ValueError): | ||
| 345 | - pids.append(int(p)) | ||
| 346 | - return pids | ||
| 347 | - except Exception: | ||
| 348 | - return [] | ||
| 349 | - | ||
| 350 | - | ||
| 351 | -def _kill_pid(pid: int) -> None: | ||
| 352 | - """终止指定 PID 的进程(跨平台)。""" | ||
| 353 | - try: | ||
| 354 | - if sys.platform == "win32": | ||
| 355 | - subprocess.run( | ||
| 356 | - ["taskkill", "/PID", str(pid), "/F"], | ||
| 357 | - capture_output=True, | ||
| 358 | - timeout=5, | ||
| 359 | - ) | ||
| 360 | - else: | ||
| 361 | - import signal | ||
| 362 | - | ||
| 363 | - os.kill(pid, signal.SIGTERM) | ||
| 364 | - except Exception: | ||
| 365 | - logger.debug("终止进程 %d 失败", pid) | ||
| 366 | - | ||
| 367 | - | ||
| 368 | -def _mask_proxy(proxy_url: str) -> str: | ||
| 369 | - """隐藏代理 URL 中的敏感信息。""" | ||
| 370 | - from urllib.parse import urlparse | ||
| 371 | - | ||
| 372 | - try: | ||
| 373 | - parsed = urlparse(proxy_url) | ||
| 374 | - if parsed.username: | ||
| 375 | - return proxy_url.replace(parsed.username, "***").replace(parsed.password or "", "***") | ||
| 376 | - except Exception: | ||
| 377 | - pass | ||
| 378 | - return proxy_url | ||
| 379 | - | ||
| 380 | - | ||
| 381 | -def has_display() -> bool: | ||
| 382 | - """检测当前环境是否有图形界面(用于自动选择登录方式)。""" | ||
| 383 | - system = platform.system() | ||
| 384 | - if system in ("Windows", "Darwin"): | ||
| 385 | - return True # Windows / macOS 默认有 GUI | ||
| 386 | - # Linux: 检查 DISPLAY 或 WAYLAND_DISPLAY 环境变量 | ||
| 387 | - return bool(os.getenv("DISPLAY") or os.getenv("WAYLAND_DISPLAY")) |
| 1 | -"""统一 CLI 入口,对应 Go MCP 工具的 13 个子命令。 | 1 | +"""统一 CLI 入口(Extension Bridge 版本) |
| 2 | + | ||
| 3 | +通过浏览器扩展 Bridge 连接用户已打开的浏览器,无需 Chrome 调试端口。 | ||
| 4 | +先启动 bridge_server.py,并在浏览器中安装 XHS Bridge 扩展,再运行此 CLI。 | ||
| 2 | 5 | ||
| 3 | -全局选项: --host, --port, --account | ||
| 4 | 输出: JSON(ensure_ascii=False) | 6 | 输出: JSON(ensure_ascii=False) |
| 5 | 退出码: 0=成功, 1=未登录, 2=错误 | 7 | 退出码: 0=成功, 1=未登录, 2=错误 |
| 6 | """ | 8 | """ |
| @@ -8,59 +10,10 @@ | @@ -8,59 +10,10 @@ | ||
| 8 | from __future__ import annotations | 10 | from __future__ import annotations |
| 9 | 11 | ||
| 10 | import argparse | 12 | import argparse |
| 11 | -import contextlib | ||
| 12 | import json | 13 | import json |
| 13 | import logging | 14 | import logging |
| 14 | import os | 15 | import os |
| 15 | import sys | 16 | import sys |
| 16 | -import tempfile | ||
| 17 | - | ||
| 18 | -def _session_tab_file(port: int) -> str: | ||
| 19 | - """返回指定端口的 session tab 文件路径(每账号独立隔离)。""" | ||
| 20 | - return os.path.join(tempfile.gettempdir(), "xhs", f"session_tab_{port}.txt") | ||
| 21 | - | ||
| 22 | - | ||
| 23 | -def _login_tab_file(port: int) -> str: | ||
| 24 | - """返回指定端口的 login tab 文件路径(每账号独立隔离)。""" | ||
| 25 | - return os.path.join(tempfile.gettempdir(), "xhs", f"login_tab_{port}.txt") | ||
| 26 | - | ||
| 27 | - | ||
| 28 | -def _save_login_tab(target_id: str, port: int) -> None: | ||
| 29 | - path = _login_tab_file(port) | ||
| 30 | - os.makedirs(os.path.dirname(path), exist_ok=True) | ||
| 31 | - with open(path, "w") as f: | ||
| 32 | - f.write(target_id) | ||
| 33 | - | ||
| 34 | - | ||
| 35 | -def _load_login_tab(port: int) -> str | None: | ||
| 36 | - with contextlib.suppress(FileNotFoundError): | ||
| 37 | - data = open(_login_tab_file(port)).read().strip() | ||
| 38 | - return data or None | ||
| 39 | - return None | ||
| 40 | - | ||
| 41 | - | ||
| 42 | -def _clear_login_tab(port: int) -> None: | ||
| 43 | - with contextlib.suppress(FileNotFoundError): | ||
| 44 | - os.remove(_login_tab_file(port)) | ||
| 45 | - | ||
| 46 | - | ||
| 47 | -def _save_session_tab(target_id: str, port: int) -> None: | ||
| 48 | - path = _session_tab_file(port) | ||
| 49 | - os.makedirs(os.path.dirname(path), exist_ok=True) | ||
| 50 | - with open(path, "w") as f: | ||
| 51 | - f.write(target_id) | ||
| 52 | - | ||
| 53 | - | ||
| 54 | -def _load_session_tab(port: int) -> str | None: | ||
| 55 | - with contextlib.suppress(FileNotFoundError): | ||
| 56 | - data = open(_session_tab_file(port)).read().strip() | ||
| 57 | - return data or None | ||
| 58 | - return None | ||
| 59 | - | ||
| 60 | - | ||
| 61 | -def _clear_session_tab(port: int) -> None: | ||
| 62 | - with contextlib.suppress(FileNotFoundError): | ||
| 63 | - os.remove(_session_tab_file(port)) | ||
| 64 | 17 | ||
| 65 | # Windows 控制台默认编码(如 cp1252)不支持中文,强制 UTF-8 | 18 | # Windows 控制台默认编码(如 cp1252)不支持中文,强制 UTF-8 |
| 66 | if sys.stdout and hasattr(sys.stdout, "reconfigure"): | 19 | if sys.stdout and hasattr(sys.stdout, "reconfigure"): |
| @@ -75,19 +28,16 @@ logging.basicConfig( | @@ -75,19 +28,16 @@ logging.basicConfig( | ||
| 75 | logger = logging.getLogger("xhs-cli") | 28 | logger = logging.getLogger("xhs-cli") |
| 76 | 29 | ||
| 77 | 30 | ||
| 31 | +# ─── 输出工具 ──────────────────────────────────────────────────────────────── | ||
| 32 | + | ||
| 33 | + | ||
| 78 | def _output(data: dict, exit_code: int = 0) -> None: | 34 | def _output(data: dict, exit_code: int = 0) -> None: |
| 79 | - """输出 JSON 并退出。""" | ||
| 80 | print(json.dumps(data, ensure_ascii=False, indent=2)) | 35 | print(json.dumps(data, ensure_ascii=False, indent=2)) |
| 81 | sys.exit(exit_code) | 36 | sys.exit(exit_code) |
| 82 | 37 | ||
| 83 | 38 | ||
| 84 | def _open_file_if_display(path: str) -> None: | 39 | def _open_file_if_display(path: str) -> None: |
| 85 | - """有桌面环境时用系统默认程序打开文件,无界面环境静默跳过。""" | ||
| 86 | - from chrome_launcher import has_display | ||
| 87 | - | ||
| 88 | - if not has_display(): | ||
| 89 | - return | ||
| 90 | - | 40 | + """有桌面时用系统默认程序打开文件。""" |
| 91 | import platform | 41 | import platform |
| 92 | import subprocess | 42 | import subprocess |
| 93 | 43 | ||
| @@ -103,210 +53,136 @@ def _open_file_if_display(path: str) -> None: | @@ -103,210 +53,136 @@ def _open_file_if_display(path: str) -> None: | ||
| 103 | logger.debug("无法自动打开文件: %s", path) | 53 | logger.debug("无法自动打开文件: %s", path) |
| 104 | 54 | ||
| 105 | 55 | ||
| 106 | -def _update_account_nickname(args: argparse.Namespace, page) -> None: | ||
| 107 | - """登录成功后,将平台昵称写入账号描述(best-effort,失败不影响登录结果)。""" | ||
| 108 | - if not getattr(args, "account", ""): | ||
| 109 | - return | ||
| 110 | - import sys as _sys | 56 | +# ─── Bridge 连接 ────────────────────────────────────────────────────────────── |
| 111 | 57 | ||
| 112 | - _sys.path.insert(0, os.path.join(os.path.dirname(__file__))) | ||
| 113 | - import account_manager | ||
| 114 | - from xhs.login import get_current_user_nickname | ||
| 115 | 58 | ||
| 116 | - try: | ||
| 117 | - nickname = get_current_user_nickname(page) | ||
| 118 | - if nickname: | ||
| 119 | - account_manager.update_account_description(args.account, nickname) | ||
| 120 | - logger.info("账号 %s 昵称已更新: %s", args.account, nickname) | ||
| 121 | - except Exception as e: | ||
| 122 | - logger.warning("更新账号昵称失败: %s", e) | 59 | +class _DummyBrowser: |
| 60 | + """空 browser 对象,保持与旧代码的兼容性。""" | ||
| 123 | 61 | ||
| 62 | + def close(self) -> None: | ||
| 63 | + pass | ||
| 124 | 64 | ||
| 125 | -def _resolve_account(args: argparse.Namespace) -> str | None: | ||
| 126 | - """解析 --account 参数,更新 args.port,返回 user_data_dir(无账号时返回 None)。""" | ||
| 127 | - if not getattr(args, "account", ""): | ||
| 128 | - return None | ||
| 129 | - import sys as _sys | 65 | + def close_page(self, page) -> None: |
| 66 | + pass | ||
| 130 | 67 | ||
| 131 | - _sys.path.insert(0, os.path.join(os.path.dirname(__file__))) | ||
| 132 | - import account_manager | ||
| 133 | - | ||
| 134 | - name = args.account | ||
| 135 | - args.port = account_manager.get_account_port(name) | ||
| 136 | - return account_manager.get_profile_dir(name) | ||
| 137 | - | ||
| 138 | - | ||
| 139 | -def _connect(args: argparse.Namespace): | ||
| 140 | - """连接到 Chrome 并返回 (browser, page)。 | ||
| 141 | - | ||
| 142 | - 优先复用上次命令留下的 tab(通过端口隔离的 session tab 文件记录), | ||
| 143 | - 避免每次命令都新建 tab 导致 Chrome 中 tab 堆积。 | ||
| 144 | - """ | ||
| 145 | - from chrome_launcher import ensure_chrome, has_display | ||
| 146 | - from xhs.cdp import Browser | ||
| 147 | - | ||
| 148 | - user_data_dir = _resolve_account(args) | ||
| 149 | - | ||
| 150 | - if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir): | ||
| 151 | - _output( | ||
| 152 | - {"success": False, "error": "无法启动 Chrome,请检查 Chrome 是否已安装"}, | ||
| 153 | - exit_code=2, | ||
| 154 | - ) | ||
| 155 | - | ||
| 156 | - browser = Browser(host=args.host, port=args.port) | ||
| 157 | - browser.connect() | ||
| 158 | - | ||
| 159 | - # 优先复用上次命令留下的 tab | ||
| 160 | - saved_id = _load_session_tab(args.port) | ||
| 161 | - if saved_id: | ||
| 162 | - page = browser.get_page_by_target_id(saved_id) | ||
| 163 | - if page: | ||
| 164 | - logger.debug("复用会话 tab: %s", saved_id) | ||
| 165 | - _save_session_tab(page.target_id, args.port) | ||
| 166 | - return browser, page | ||
| 167 | - logger.warning("会话 tab (target_id=%s) 已失效,重新获取", saved_id) | ||
| 168 | - | ||
| 169 | - page = browser.get_or_create_page() | ||
| 170 | - _save_session_tab(page.target_id, args.port) | ||
| 171 | - return browser, page | ||
| 172 | 68 | ||
| 69 | +def _ensure_bridge_ready(bridge_url: str) -> None: | ||
| 70 | + """确保 bridge server 在运行、浏览器扩展已连接。若未就绪则自动启动。""" | ||
| 71 | + import subprocess | ||
| 72 | + import time | ||
| 73 | + from pathlib import Path | ||
| 74 | + | ||
| 75 | + from xhs.bridge import BridgePage | ||
| 76 | + | ||
| 77 | + page = BridgePage(bridge_url) | ||
| 78 | + | ||
| 79 | + # ── 1. 检查 bridge server ──────────────────────────────────────── | ||
| 80 | + if not page.is_server_running(): | ||
| 81 | + logger.info("Bridge server 未运行,正在启动...") | ||
| 82 | + scripts_dir = Path(__file__).parent | ||
| 83 | + kwargs: dict = {} | ||
| 84 | + if sys.platform == "win32": | ||
| 85 | + kwargs["creationflags"] = subprocess.CREATE_NEW_CONSOLE | ||
| 86 | + subprocess.Popen( | ||
| 87 | + [sys.executable, str(scripts_dir / "bridge_server.py")], | ||
| 88 | + **kwargs, | ||
| 89 | + ) | ||
| 90 | + for _ in range(10): | ||
| 91 | + time.sleep(1) | ||
| 92 | + if page.is_server_running(): | ||
| 93 | + logger.info("Bridge server 已启动") | ||
| 94 | + break | ||
| 95 | + else: | ||
| 96 | + logger.warning("Bridge server 启动超时,请手动运行 bridge_server.py") | ||
| 97 | + return | ||
| 173 | 98 | ||
| 174 | -def _connect_saved_tab(args: argparse.Namespace): | ||
| 175 | - """连接到登录流程中记录的精确 tab,回退到第一个非空白 tab。""" | ||
| 176 | - from chrome_launcher import ensure_chrome, has_display | ||
| 177 | - from xhs.cdp import Browser | 99 | + # ── 2. 检查扩展是否连接 ────────────────────────────────────────── |
| 100 | + if page.is_extension_connected(): | ||
| 101 | + return | ||
| 178 | 102 | ||
| 179 | - user_data_dir = _resolve_account(args) | 103 | + logger.info("浏览器扩展未连接,正在打开 Chrome...") |
| 104 | + _open_chrome() | ||
| 180 | 105 | ||
| 181 | - if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir): | ||
| 182 | - _output({"success": False, "error": "无法连接到 Chrome"}, exit_code=2) | 106 | + for _ in range(20): |
| 107 | + time.sleep(1) | ||
| 108 | + if page.is_extension_connected(): | ||
| 109 | + logger.info("浏览器扩展已连接") | ||
| 110 | + return | ||
| 111 | + logger.warning("等待扩展连接超时,请确认 Chrome 已安装 XHS Bridge 扩展并已启用") | ||
| 183 | 112 | ||
| 184 | - browser = Browser(host=args.host, port=args.port) | ||
| 185 | - browser.connect() | ||
| 186 | 113 | ||
| 187 | - target_id = _load_login_tab(args.port) | ||
| 188 | - if target_id: | ||
| 189 | - page = browser.get_page_by_target_id(target_id) | ||
| 190 | - if page: | ||
| 191 | - return browser, page | ||
| 192 | - logger.warning("保存的 tab (target_id=%s) 已失效,回退到第一个可用 tab", target_id) | 114 | +def _open_chrome() -> None: |
| 115 | + """尝试启动 Chrome 浏览器。""" | ||
| 116 | + import subprocess | ||
| 193 | 117 | ||
| 194 | - page = browser.get_existing_page() | ||
| 195 | - if not page: | ||
| 196 | - _output( | ||
| 197 | - {"success": False, "error": "未找到已打开的登录页面,请重新执行登录前置步骤"}, | ||
| 198 | - exit_code=2, | ||
| 199 | - ) | ||
| 200 | - return browser, page | 118 | + candidates = [ |
| 119 | + r"C:\Program Files\Google\Chrome\Application\chrome.exe", | ||
| 120 | + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", | ||
| 121 | + os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"), | ||
| 122 | + ] | ||
| 123 | + for path in candidates: | ||
| 124 | + if os.path.exists(path): | ||
| 125 | + subprocess.Popen([path]) | ||
| 126 | + return | ||
| 127 | + # macOS / Linux fallback | ||
| 128 | + for cmd in [["open", "-a", "Google Chrome"], ["google-chrome"], ["chromium-browser"]]: | ||
| 129 | + try: | ||
| 130 | + subprocess.Popen(cmd) | ||
| 131 | + return | ||
| 132 | + except FileNotFoundError: | ||
| 133 | + continue | ||
| 134 | + logger.warning("找不到 Chrome,请手动打开浏览器") | ||
| 201 | 135 | ||
| 202 | 136 | ||
| 203 | -def _connect_existing(args: argparse.Namespace): | ||
| 204 | - """连接到 Chrome 并复用已有页面(用于分步发布的后续步骤)。""" | ||
| 205 | - from chrome_launcher import ensure_chrome, has_display | ||
| 206 | - from xhs.cdp import Browser | 137 | +def _connect(args: argparse.Namespace): |
| 138 | + """返回 (browser, page),browser 为空对象,page 通过 Extension Bridge 操作浏览器。""" | ||
| 139 | + from xhs.bridge import BridgePage | ||
| 207 | 140 | ||
| 208 | - user_data_dir = _resolve_account(args) | 141 | + bridge_url = getattr(args, "bridge_url", "ws://localhost:9333") |
| 142 | + _ensure_bridge_ready(bridge_url) | ||
| 143 | + return _DummyBrowser(), BridgePage(bridge_url) | ||
| 209 | 144 | ||
| 210 | - if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir): | ||
| 211 | - _output( | ||
| 212 | - {"success": False, "error": "无法连接到 Chrome"}, | ||
| 213 | - exit_code=2, | ||
| 214 | - ) | ||
| 215 | 145 | ||
| 216 | - browser = Browser(host=args.host, port=args.port) | ||
| 217 | - browser.connect() | ||
| 218 | - page = browser.get_existing_page() | ||
| 219 | - if not page: | ||
| 220 | - _output( | ||
| 221 | - {"success": False, "error": "未找到已打开的页面,请先执行前置步骤"}, | ||
| 222 | - exit_code=2, | ||
| 223 | - ) | ||
| 224 | - return browser, page | 146 | +# _connect_saved_tab / _connect_existing 在 bridge 模式下与 _connect 等价 |
| 147 | +_connect_saved_tab = _connect | ||
| 148 | +_connect_existing = _connect | ||
| 225 | 149 | ||
| 226 | 150 | ||
| 227 | -def _headless_fallback(port: int) -> None: | ||
| 228 | - """Headless 模式未登录时的处理:有桌面降级到有窗口模式,无桌面直接报错提示。""" | ||
| 229 | - from chrome_launcher import has_display, restart_chrome | 151 | +# ─── 子命令实现 ─────────────────────────────────────────────────────────────── |
| 230 | 152 | ||
| 231 | - if has_display(): | ||
| 232 | - logger.info("Headless 模式未登录,切换到有窗口模式...") | ||
| 233 | - restart_chrome(port=port, headless=False) | ||
| 234 | - _output( | ||
| 235 | - { | ||
| 236 | - "success": False, | ||
| 237 | - "error": "未登录", | ||
| 238 | - "action": "switched_to_headed", | ||
| 239 | - "message": "已切换到有窗口模式,请在浏览器中扫码登录", | ||
| 240 | - }, | ||
| 241 | - exit_code=1, | ||
| 242 | - ) | ||
| 243 | - else: | ||
| 244 | - _output( | ||
| 245 | - { | ||
| 246 | - "success": False, | ||
| 247 | - "error": "未登录", | ||
| 248 | - "action": "login_required", | ||
| 249 | - "message": "无界面环境下请先运行 send-code --phone <手机号> 完成登录", | ||
| 250 | - }, | ||
| 251 | - exit_code=1, | ||
| 252 | - ) | ||
| 253 | 153 | ||
| 254 | def _qrcode_fallback(browser, page, args: argparse.Namespace) -> None: | 154 | def _qrcode_fallback(browser, page, args: argparse.Namespace) -> None: |
| 255 | - """频率限制时刷新页面返回二维码,让 AI 直接展示给用户扫码。""" | ||
| 256 | - from xhs.login import ( | ||
| 257 | - fetch_qrcode, | ||
| 258 | - make_qrcode_url, | ||
| 259 | - save_qrcode_to_file, | ||
| 260 | - ) | 155 | + """频率限制时刷新页面返回二维码。""" |
| 156 | + from xhs.login import fetch_qrcode, make_qrcode_url, save_qrcode_to_file | ||
| 261 | from xhs.urls import EXPLORE_URL | 157 | from xhs.urls import EXPLORE_URL |
| 262 | 158 | ||
| 263 | - # 刷新页面使登录弹窗回到默认的二维码 tab | ||
| 264 | page.navigate(EXPLORE_URL) | 159 | page.navigate(EXPLORE_URL) |
| 265 | page.wait_for_load() | 160 | page.wait_for_load() |
| 266 | 161 | ||
| 267 | png_bytes, _b64_orig, already = fetch_qrcode(page) | 162 | png_bytes, _b64_orig, already = fetch_qrcode(page) |
| 268 | if already: | 163 | if already: |
| 269 | - browser.close() | ||
| 270 | _output({"logged_in": True, "message": "已登录"}) | 164 | _output({"logged_in": True, "message": "已登录"}) |
| 271 | return | 165 | return |
| 272 | 166 | ||
| 273 | qrcode_path = save_qrcode_to_file(png_bytes) | 167 | qrcode_path = save_qrcode_to_file(png_bytes) |
| 274 | image_url, login_url = make_qrcode_url(png_bytes) | 168 | image_url, login_url = make_qrcode_url(png_bytes) |
| 275 | - | ||
| 276 | _open_file_if_display(qrcode_path) | 169 | _open_file_if_display(qrcode_path) |
| 277 | 170 | ||
| 278 | - _save_login_tab(page.target_id, args.port) | ||
| 279 | - _clear_session_tab(args.port) | ||
| 280 | - browser.close() | ||
| 281 | result: dict = { | 171 | result: dict = { |
| 282 | "logged_in": False, | 172 | "logged_in": False, |
| 283 | "login_method": "qrcode", | 173 | "login_method": "qrcode", |
| 284 | "qrcode_path": qrcode_path, | 174 | "qrcode_path": qrcode_path, |
| 285 | "qrcode_image_url": image_url, | 175 | "qrcode_image_url": image_url, |
| 286 | - "message": ( | ||
| 287 | - "验证码发送受限,已切换为二维码登录,请扫码。" | ||
| 288 | - "扫码后运行 wait-login 等待登录结果。" | ||
| 289 | - ), | 176 | + "message": "验证码发送受限,已切换为二维码登录,请扫码。扫码后运行 wait-login 等待登录结果。", |
| 290 | } | 177 | } |
| 291 | if login_url: | 178 | if login_url: |
| 292 | result["qr_login_url"] = login_url | 179 | result["qr_login_url"] = login_url |
| 293 | _output(result, exit_code=1) | 180 | _output(result, exit_code=1) |
| 294 | 181 | ||
| 295 | 182 | ||
| 296 | -# ========== 子命令实现 ========== | ||
| 297 | - | ||
| 298 | - | ||
| 299 | def cmd_check_login(args: argparse.Namespace) -> None: | 183 | def cmd_check_login(args: argparse.Namespace) -> None: |
| 300 | - """检查登录状态。未登录时自动获取二维码,省去单独调 get-qrcode 的一轮通信。 | ||
| 301 | - | ||
| 302 | - 直接调 fetch_qrcode 一步完成:导航 + 登录检查 + 二维码获取, | ||
| 303 | - 不再经过 check_login_status 避免重复导航和等待。 | ||
| 304 | - """ | ||
| 305 | - from xhs.login import ( | ||
| 306 | - fetch_qrcode, | ||
| 307 | - make_qrcode_url, | ||
| 308 | - save_qrcode_to_file, | ||
| 309 | - ) | 184 | + """检查登录状态,未登录时自动获取二维码。""" |
| 185 | + from xhs.login import fetch_qrcode, make_qrcode_url, save_qrcode_to_file | ||
| 310 | 186 | ||
| 311 | browser, page = _connect(args) | 187 | browser, page = _connect(args) |
| 312 | try: | 188 | try: |
| @@ -317,156 +193,57 @@ def cmd_check_login(args: argparse.Namespace) -> None: | @@ -317,156 +193,57 @@ def cmd_check_login(args: argparse.Namespace) -> None: | ||
| 317 | 193 | ||
| 318 | qrcode_path = save_qrcode_to_file(png_bytes) | 194 | qrcode_path = save_qrcode_to_file(png_bytes) |
| 319 | image_url, login_url = make_qrcode_url(png_bytes) | 195 | image_url, login_url = make_qrcode_url(png_bytes) |
| 320 | - | ||
| 321 | - # 记录 login tab + 清除 session tab | ||
| 322 | - _save_login_tab(page.target_id, args.port) | ||
| 323 | - _clear_session_tab(args.port) | ||
| 324 | - | ||
| 325 | _open_file_if_display(qrcode_path) | 196 | _open_file_if_display(qrcode_path) |
| 326 | 197 | ||
| 327 | - from chrome_launcher import has_display | ||
| 328 | - | ||
| 329 | result: dict = { | 198 | result: dict = { |
| 330 | "logged_in": False, | 199 | "logged_in": False, |
| 200 | + "login_method": "qrcode", | ||
| 331 | "qrcode_path": qrcode_path, | 201 | "qrcode_path": qrcode_path, |
| 332 | "qrcode_image_url": image_url, | 202 | "qrcode_image_url": image_url, |
| 203 | + "hint": "未登录,二维码已自动生成。扫码后运行 wait-login 等待登录结果", | ||
| 333 | } | 204 | } |
| 334 | if login_url: | 205 | if login_url: |
| 335 | result["qr_login_url"] = login_url | 206 | result["qr_login_url"] = login_url |
| 336 | - if has_display(): | ||
| 337 | - result["login_method"] = "qrcode" | ||
| 338 | - result["hint"] = ( | ||
| 339 | - "未登录,二维码已自动生成。" | ||
| 340 | - "扫码后运行 wait-login 等待登录结果" | ||
| 341 | - ) | ||
| 342 | - else: | ||
| 343 | - result["login_method"] = "both" | ||
| 344 | - result["hint"] = ( | ||
| 345 | - "未登录,二维码已自动生成。" | ||
| 346 | - "方式A: 直接扫码 + wait-login;" | ||
| 347 | - "方式B: send-code --phone <手机号>" | ||
| 348 | - " + verify-code(手机验证码)" | ||
| 349 | - ) | ||
| 350 | _output(result, exit_code=1) | 207 | _output(result, exit_code=1) |
| 351 | finally: | 208 | finally: |
| 352 | - # 只断开 CDP 连接,不关闭 tab——保留登录页面 | ||
| 353 | browser.close() | 209 | browser.close() |
| 354 | 210 | ||
| 355 | 211 | ||
| 356 | def cmd_login(args: argparse.Namespace) -> None: | 212 | def cmd_login(args: argparse.Namespace) -> None: |
| 357 | - """获取登录二维码并阻塞等待扫码(最多 120 秒)。""" | ||
| 358 | - from xhs.login import fetch_qrcode, save_qrcode_to_file, wait_for_login | 213 | + """登录(扫码,阻塞等待完成)。""" |
| 214 | + from xhs.login import fetch_qrcode, make_qrcode_url, save_qrcode_to_file, wait_for_login | ||
| 359 | 215 | ||
| 360 | browser, page = _connect(args) | 216 | browser, page = _connect(args) |
| 361 | try: | 217 | try: |
| 362 | - png_bytes, _b64, already = fetch_qrcode(page) | 218 | + png_bytes, _b64_orig, already = fetch_qrcode(page) |
| 363 | if already: | 219 | if already: |
| 364 | _output({"logged_in": True, "message": "已登录"}) | 220 | _output({"logged_in": True, "message": "已登录"}) |
| 365 | return | 221 | return |
| 366 | 222 | ||
| 367 | qrcode_path = save_qrcode_to_file(png_bytes) | 223 | qrcode_path = save_qrcode_to_file(png_bytes) |
| 224 | + image_url, login_url = make_qrcode_url(png_bytes) | ||
| 368 | _open_file_if_display(qrcode_path) | 225 | _open_file_if_display(qrcode_path) |
| 369 | - print( | ||
| 370 | - json.dumps( | ||
| 371 | - {"qrcode_path": qrcode_path, "message": "请扫码登录"}, | ||
| 372 | - ensure_ascii=False, | ||
| 373 | - ) | ||
| 374 | - ) | ||
| 375 | - success = wait_for_login(page, timeout=120) | ||
| 376 | - if success: | ||
| 377 | - _update_account_nickname(args, page) | ||
| 378 | - _output( | ||
| 379 | - {"logged_in": success, "message": "登录成功" if success else "登录超时"}, | ||
| 380 | - exit_code=0 if success else 2, | ||
| 381 | - ) | ||
| 382 | - finally: | ||
| 383 | - browser.close_page(page) | ||
| 384 | - browser.close() | ||
| 385 | - | ||
| 386 | - | ||
| 387 | -def cmd_phone_login(args: argparse.Namespace) -> None: | ||
| 388 | - """手机号+验证码登录(适用于无界面服务器)。""" | ||
| 389 | - from xhs.errors import RateLimitError | ||
| 390 | - from xhs.login import send_phone_code, submit_phone_code | ||
| 391 | - | ||
| 392 | - browser, page = _connect(args) | ||
| 393 | - try: | ||
| 394 | - sent = send_phone_code(page, args.phone) | ||
| 395 | - except RateLimitError: | ||
| 396 | - # 频率限制——直接切换二维码登录 | ||
| 397 | - logger.info("验证码发送受限,切换为二维码登录") | ||
| 398 | - _qrcode_fallback(browser, page, args) | ||
| 399 | - return | ||
| 400 | - | ||
| 401 | - try: | ||
| 402 | - if not sent: | ||
| 403 | - _output({"logged_in": True, "message": "已登录,无需重新登录"}) | ||
| 404 | - return | ||
| 405 | - | ||
| 406 | - # 输出提示,等待用户在终端输入验证码 | ||
| 407 | - print( | ||
| 408 | - json.dumps( | ||
| 409 | - { | ||
| 410 | - "status": "code_sent", | ||
| 411 | - "message": ( | ||
| 412 | - f"验证码已发送至 " | ||
| 413 | - f"{args.phone[:3]}****{args.phone[-4:]}" | ||
| 414 | - ), | ||
| 415 | - }, | ||
| 416 | - ensure_ascii=False, | ||
| 417 | - ), | ||
| 418 | - flush=True, | ||
| 419 | - ) | ||
| 420 | 226 | ||
| 421 | - # 从 --code 参数或交互式 stdin 读取验证码 | ||
| 422 | - if args.code: | ||
| 423 | - code = args.code.strip() | ||
| 424 | - else: | ||
| 425 | - try: | ||
| 426 | - code = input("请输入验证码: ").strip() | ||
| 427 | - except EOFError: | ||
| 428 | - _output( | ||
| 429 | - {"success": False, "error": "未收到验证码输入"}, | ||
| 430 | - exit_code=2, | ||
| 431 | - ) | ||
| 432 | - return | ||
| 433 | - | ||
| 434 | - if not code: | ||
| 435 | - _output( | ||
| 436 | - {"success": False, "error": "验证码不能为空"}, | ||
| 437 | - exit_code=2, | ||
| 438 | - ) | ||
| 439 | - return | 227 | + result: dict = {"qrcode_path": qrcode_path, "qrcode_image_url": image_url} |
| 228 | + if login_url: | ||
| 229 | + result["qr_login_url"] = login_url | ||
| 230 | + logger.info("二维码已生成,等待扫码...") | ||
| 440 | 231 | ||
| 441 | - success = submit_phone_code(page, code) | 232 | + success = wait_for_login(page, timeout=120) |
| 442 | _output( | 233 | _output( |
| 443 | - { | ||
| 444 | - "logged_in": success, | ||
| 445 | - "message": "登录成功" if success else "验证码错误或超时", | ||
| 446 | - }, | 234 | + {"logged_in": success, "message": "登录成功" if success else "等待超时"}, |
| 447 | exit_code=0 if success else 2, | 235 | exit_code=0 if success else 2, |
| 448 | ) | 236 | ) |
| 449 | finally: | 237 | finally: |
| 450 | - # 不关闭 tab——与 verify-code 一致,保留页面供重试 | ||
| 451 | browser.close() | 238 | browser.close() |
| 452 | 239 | ||
| 453 | 240 | ||
| 454 | def cmd_get_qrcode(args: argparse.Namespace) -> None: | 241 | def cmd_get_qrcode(args: argparse.Namespace) -> None: |
| 455 | - """获取登录二维码并立即返回(非阻塞)。 | ||
| 456 | - | ||
| 457 | - 从登录弹窗的二维码 img 元素读取图片(data URL 或网络 URL), | ||
| 458 | - 保存为本地 PNG 文件后立即退出。Chrome tab 保持打开,QR 会话继续有效。 | ||
| 459 | - 调用方收到 qrcode_data_url 后直接内嵌到对话窗口显示;同时浏览器窗口(GUI 环境) | ||
| 460 | - 也会显示二维码,用户可选择扫任意一个。 | ||
| 461 | - """ | ||
| 462 | - from xhs.login import ( | ||
| 463 | - fetch_qrcode, | ||
| 464 | - make_qrcode_url, | ||
| 465 | - save_qrcode_to_file, | ||
| 466 | - ) | 242 | + """获取登录二维码截图并立即返回(非阻塞)。""" |
| 243 | + from xhs.login import fetch_qrcode, make_qrcode_url, save_qrcode_to_file | ||
| 467 | 244 | ||
| 468 | browser, page = _connect(args) | 245 | browser, page = _connect(args) |
| 469 | - | 246 | + try: |
| 470 | png_bytes, _b64_orig, already = fetch_qrcode(page) | 247 | png_bytes, _b64_orig, already = fetch_qrcode(page) |
| 471 | if already: | 248 | if already: |
| 472 | browser.close_page(page) | 249 | browser.close_page(page) |
| @@ -476,40 +253,28 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: | @@ -476,40 +253,28 @@ def cmd_get_qrcode(args: argparse.Namespace) -> None: | ||
| 476 | 253 | ||
| 477 | qrcode_path = save_qrcode_to_file(png_bytes) | 254 | qrcode_path = save_qrcode_to_file(png_bytes) |
| 478 | image_url, login_url = make_qrcode_url(png_bytes) | 255 | image_url, login_url = make_qrcode_url(png_bytes) |
| 479 | - | ||
| 480 | _open_file_if_display(qrcode_path) | 256 | _open_file_if_display(qrcode_path) |
| 481 | - | ||
| 482 | - # 记录 login tab,供 wait-login 精确 reconnect | ||
| 483 | - _save_login_tab(page.target_id, args.port) | ||
| 484 | - # 清除 session tab 引用——隔离登录表单,防止其他命令复用 | ||
| 485 | - _clear_session_tab(args.port) | ||
| 486 | - | ||
| 487 | - # 只断开 CDP 连接,不关闭 tab——QR 会话保持 | ||
| 488 | browser.close() | 257 | browser.close() |
| 258 | + | ||
| 489 | result: dict = { | 259 | result: dict = { |
| 490 | "qrcode_path": qrcode_path, | 260 | "qrcode_path": qrcode_path, |
| 491 | "qrcode_image_url": image_url, | 261 | "qrcode_image_url": image_url, |
| 492 | - "message": "二维码已生成,请扫码登录。" | ||
| 493 | - "扫码后运行 wait-login 等待登录结果。", | 262 | + "message": "二维码已生成,请扫码登录。扫码后运行 wait-login 等待登录结果。", |
| 494 | } | 263 | } |
| 495 | if login_url: | 264 | if login_url: |
| 496 | result["qr_login_url"] = login_url | 265 | result["qr_login_url"] = login_url |
| 497 | _output(result) | 266 | _output(result) |
| 267 | + finally: | ||
| 268 | + pass | ||
| 498 | 269 | ||
| 499 | 270 | ||
| 500 | def cmd_wait_login(args: argparse.Namespace) -> None: | 271 | def cmd_wait_login(args: argparse.Namespace) -> None: |
| 501 | - """等待扫码登录完成(配合 get-qrcode 使用)。 | ||
| 502 | - | ||
| 503 | - 连接已有 Chrome tab,内部轮询直到登录成功或超时,替代 Skill 层的多次 check-login 轮询。 | ||
| 504 | - """ | 272 | + """等待扫码登录完成(配合 get-qrcode 使用)。""" |
| 505 | from xhs.login import wait_for_login | 273 | from xhs.login import wait_for_login |
| 506 | 274 | ||
| 507 | browser, page = _connect_saved_tab(args) | 275 | browser, page = _connect_saved_tab(args) |
| 508 | try: | 276 | try: |
| 509 | success = wait_for_login(page, timeout=args.timeout) | 277 | success = wait_for_login(page, timeout=args.timeout) |
| 510 | - if success: | ||
| 511 | - _clear_login_tab(args.port) | ||
| 512 | - _update_account_nickname(args, page) | ||
| 513 | _output( | 278 | _output( |
| 514 | { | 279 | { |
| 515 | "logged_in": success, | 280 | "logged_in": success, |
| @@ -521,11 +286,35 @@ def cmd_wait_login(args: argparse.Namespace) -> None: | @@ -521,11 +286,35 @@ def cmd_wait_login(args: argparse.Namespace) -> None: | ||
| 521 | browser.close() | 286 | browser.close() |
| 522 | 287 | ||
| 523 | 288 | ||
| 524 | -def cmd_send_code(args: argparse.Namespace) -> None: | ||
| 525 | - """分步登录第一步:填写手机号并发送验证码,保持页面不关闭。 | 289 | +def cmd_phone_login(args: argparse.Namespace) -> None: |
| 290 | + """手机号+验证码登录(交互式)。""" | ||
| 291 | + from xhs.errors import RateLimitError | ||
| 292 | + from xhs.login import send_phone_code, submit_phone_code | ||
| 293 | + | ||
| 294 | + browser, page = _connect(args) | ||
| 295 | + try: | ||
| 296 | + sent = send_phone_code(page, args.phone) | ||
| 297 | + if not sent: | ||
| 298 | + _output({"logged_in": True, "message": "已登录,无需重新登录"}) | ||
| 299 | + return | ||
| 300 | + | ||
| 301 | + code = args.code | ||
| 302 | + if not code: | ||
| 303 | + code = input("请输入收到的短信验证码: ").strip() | ||
| 526 | 304 | ||
| 527 | - 频率限制时返回错误信息和建议,由 AI 告知用户选择。 | ||
| 528 | - """ | 305 | + success = submit_phone_code(page, code) |
| 306 | + _output( | ||
| 307 | + {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"}, | ||
| 308 | + exit_code=0 if success else 2, | ||
| 309 | + ) | ||
| 310 | + except RateLimitError: | ||
| 311 | + _qrcode_fallback(browser, page, args) | ||
| 312 | + finally: | ||
| 313 | + browser.close() | ||
| 314 | + | ||
| 315 | + | ||
| 316 | +def cmd_send_code(args: argparse.Namespace) -> None: | ||
| 317 | + """分步登录第一步:发送手机验证码。""" | ||
| 529 | from xhs.errors import RateLimitError | 318 | from xhs.errors import RateLimitError |
| 530 | from xhs.login import send_phone_code | 319 | from xhs.login import send_phone_code |
| 531 | 320 | ||
| @@ -535,11 +324,6 @@ def cmd_send_code(args: argparse.Namespace) -> None: | @@ -535,11 +324,6 @@ def cmd_send_code(args: argparse.Namespace) -> None: | ||
| 535 | if not sent: | 324 | if not sent: |
| 536 | _output({"logged_in": True, "message": "已登录,无需重新登录"}) | 325 | _output({"logged_in": True, "message": "已登录,无需重新登录"}) |
| 537 | return | 326 | return |
| 538 | - | ||
| 539 | - # 记录 login tab,供 verify-code 精确 reconnect | ||
| 540 | - _save_login_tab(page.target_id, args.port) | ||
| 541 | - # 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab | ||
| 542 | - _clear_session_tab(args.port) | ||
| 543 | _output({ | 327 | _output({ |
| 544 | "status": "code_sent", | 328 | "status": "code_sent", |
| 545 | "message": ( | 329 | "message": ( |
| @@ -548,54 +332,38 @@ def cmd_send_code(args: argparse.Namespace) -> None: | @@ -548,54 +332,38 @@ def cmd_send_code(args: argparse.Namespace) -> None: | ||
| 548 | ), | 332 | ), |
| 549 | }) | 333 | }) |
| 550 | except RateLimitError: | 334 | except RateLimitError: |
| 551 | - # 频率限制——直接切换二维码登录 | ||
| 552 | - logger.info("验证码发送受限,切换为二维码登录") | ||
| 553 | _qrcode_fallback(browser, page, args) | 335 | _qrcode_fallback(browser, page, args) |
| 554 | - else: | ||
| 555 | - # 只断开控制连接,不关闭页面——tab 保持打开,verify-code 继续复用 | 336 | + finally: |
| 556 | browser.close() | 337 | browser.close() |
| 557 | 338 | ||
| 558 | 339 | ||
| 559 | def cmd_verify_code(args: argparse.Namespace) -> None: | 340 | def cmd_verify_code(args: argparse.Namespace) -> None: |
| 560 | - """分步登录第二步:在已有页面上填写验证码并提交。""" | 341 | + """分步登录第二步:填写验证码并提交。""" |
| 561 | from xhs.login import submit_phone_code | 342 | from xhs.login import submit_phone_code |
| 562 | 343 | ||
| 563 | browser, page = _connect_saved_tab(args) | 344 | browser, page = _connect_saved_tab(args) |
| 564 | try: | 345 | try: |
| 565 | success = submit_phone_code(page, args.code) | 346 | success = submit_phone_code(page, args.code) |
| 566 | - if success: | ||
| 567 | - _clear_login_tab(args.port) | ||
| 568 | - _update_account_nickname(args, page) | ||
| 569 | _output( | 347 | _output( |
| 570 | {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"}, | 348 | {"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"}, |
| 571 | exit_code=0 if success else 2, | 349 | exit_code=0 if success else 2, |
| 572 | ) | 350 | ) |
| 573 | finally: | 351 | finally: |
| 574 | - # 不关闭 tab——成功后供后续命令复用,失败后用户可再次运行 verify-code 重试 | ||
| 575 | browser.close() | 352 | browser.close() |
| 576 | 353 | ||
| 577 | 354 | ||
| 578 | def cmd_delete_cookies(args: argparse.Namespace) -> None: | 355 | def cmd_delete_cookies(args: argparse.Namespace) -> None: |
| 579 | - """退出登录(页面 UI 点击退出)并删除 cookies 文件。""" | ||
| 580 | - from xhs.cookies import delete_cookies, get_cookies_file_path | 356 | + """退出登录(页面 UI 点击退出)。""" |
| 581 | from xhs.login import logout | 357 | from xhs.login import logout |
| 582 | 358 | ||
| 583 | - # 先通过浏览器 UI 退出登录 | ||
| 584 | browser, page = _connect(args) | 359 | browser, page = _connect(args) |
| 585 | try: | 360 | try: |
| 586 | logged_out = logout(page) | 361 | logged_out = logout(page) |
| 362 | + msg = "已退出登录" if logged_out else "未登录" | ||
| 363 | + _output({"success": True, "message": msg}) | ||
| 587 | finally: | 364 | finally: |
| 588 | - browser.close_page(page) | ||
| 589 | browser.close() | 365 | browser.close() |
| 590 | 366 | ||
| 591 | - # 再删除本地 cookies 文件 | ||
| 592 | - path = get_cookies_file_path(args.account) | ||
| 593 | - delete_cookies(path) | ||
| 594 | - | ||
| 595 | - _clear_session_tab(args.port) # 退出登录后清除会话 tab 记录 | ||
| 596 | - msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件" | ||
| 597 | - _output({"success": True, "message": msg, "cookies_path": path}) | ||
| 598 | - | ||
| 599 | 367 | ||
| 600 | def cmd_list_feeds(args: argparse.Namespace) -> None: | 368 | def cmd_list_feeds(args: argparse.Namespace) -> None: |
| 601 | """获取首页 Feed 列表。""" | 369 | """获取首页 Feed 列表。""" |
| @@ -606,7 +374,6 @@ def cmd_list_feeds(args: argparse.Namespace) -> None: | @@ -606,7 +374,6 @@ def cmd_list_feeds(args: argparse.Namespace) -> None: | ||
| 606 | feeds = list_feeds(page) | 374 | feeds = list_feeds(page) |
| 607 | _output({"feeds": [f.to_dict() for f in feeds], "count": len(feeds)}) | 375 | _output({"feeds": [f.to_dict() for f in feeds], "count": len(feeds)}) |
| 608 | finally: | 376 | finally: |
| 609 | - browser.close_page(page) | ||
| 610 | browser.close() | 377 | browser.close() |
| 611 | 378 | ||
| 612 | 379 | ||
| @@ -628,7 +395,6 @@ def cmd_search_feeds(args: argparse.Namespace) -> None: | @@ -628,7 +395,6 @@ def cmd_search_feeds(args: argparse.Namespace) -> None: | ||
| 628 | feeds = search_feeds(page, args.keyword, filter_opt) | 395 | feeds = search_feeds(page, args.keyword, filter_opt) |
| 629 | _output({"feeds": [f.to_dict() for f in feeds], "count": len(feeds)}) | 396 | _output({"feeds": [f.to_dict() for f in feeds], "count": len(feeds)}) |
| 630 | finally: | 397 | finally: |
| 631 | - browser.close_page(page) | ||
| 632 | browser.close() | 398 | browser.close() |
| 633 | 399 | ||
| 634 | 400 | ||
| @@ -655,7 +421,6 @@ def cmd_get_feed_detail(args: argparse.Namespace) -> None: | @@ -655,7 +421,6 @@ def cmd_get_feed_detail(args: argparse.Namespace) -> None: | ||
| 655 | ) | 421 | ) |
| 656 | _output(detail.to_dict()) | 422 | _output(detail.to_dict()) |
| 657 | finally: | 423 | finally: |
| 658 | - browser.close_page(page) | ||
| 659 | browser.close() | 424 | browser.close() |
| 660 | 425 | ||
| 661 | 426 | ||
| @@ -668,7 +433,6 @@ def cmd_user_profile(args: argparse.Namespace) -> None: | @@ -668,7 +433,6 @@ def cmd_user_profile(args: argparse.Namespace) -> None: | ||
| 668 | profile = get_user_profile(page, args.user_id, args.xsec_token) | 433 | profile = get_user_profile(page, args.user_id, args.xsec_token) |
| 669 | _output(profile.to_dict()) | 434 | _output(profile.to_dict()) |
| 670 | finally: | 435 | finally: |
| 671 | - browser.close_page(page) | ||
| 672 | browser.close() | 436 | browser.close() |
| 673 | 437 | ||
| 674 | 438 | ||
| @@ -681,7 +445,6 @@ def cmd_post_comment(args: argparse.Namespace) -> None: | @@ -681,7 +445,6 @@ def cmd_post_comment(args: argparse.Namespace) -> None: | ||
| 681 | post_comment(page, args.feed_id, args.xsec_token, args.content) | 445 | post_comment(page, args.feed_id, args.xsec_token, args.content) |
| 682 | _output({"success": True, "message": "评论发送成功"}) | 446 | _output({"success": True, "message": "评论发送成功"}) |
| 683 | finally: | 447 | finally: |
| 684 | - browser.close_page(page) | ||
| 685 | browser.close() | 448 | browser.close() |
| 686 | 449 | ||
| 687 | 450 | ||
| @@ -701,7 +464,6 @@ def cmd_reply_comment(args: argparse.Namespace) -> None: | @@ -701,7 +464,6 @@ def cmd_reply_comment(args: argparse.Namespace) -> None: | ||
| 701 | ) | 464 | ) |
| 702 | _output({"success": True, "message": "回复成功"}) | 465 | _output({"success": True, "message": "回复成功"}) |
| 703 | finally: | 466 | finally: |
| 704 | - browser.close_page(page) | ||
| 705 | browser.close() | 467 | browser.close() |
| 706 | 468 | ||
| 707 | 469 | ||
| @@ -717,7 +479,6 @@ def cmd_like_feed(args: argparse.Namespace) -> None: | @@ -717,7 +479,6 @@ def cmd_like_feed(args: argparse.Namespace) -> None: | ||
| 717 | result = like_feed(page, args.feed_id, args.xsec_token) | 479 | result = like_feed(page, args.feed_id, args.xsec_token) |
| 718 | _output(result.to_dict()) | 480 | _output(result.to_dict()) |
| 719 | finally: | 481 | finally: |
| 720 | - browser.close_page(page) | ||
| 721 | browser.close() | 482 | browser.close() |
| 722 | 483 | ||
| 723 | 484 | ||
| @@ -733,38 +494,26 @@ def cmd_favorite_feed(args: argparse.Namespace) -> None: | @@ -733,38 +494,26 @@ def cmd_favorite_feed(args: argparse.Namespace) -> None: | ||
| 733 | result = favorite_feed(page, args.feed_id, args.xsec_token) | 494 | result = favorite_feed(page, args.feed_id, args.xsec_token) |
| 734 | _output(result.to_dict()) | 495 | _output(result.to_dict()) |
| 735 | finally: | 496 | finally: |
| 736 | - browser.close_page(page) | ||
| 737 | browser.close() | 497 | browser.close() |
| 738 | 498 | ||
| 739 | 499 | ||
| 740 | def cmd_publish(args: argparse.Namespace) -> None: | 500 | def cmd_publish(args: argparse.Namespace) -> None: |
| 741 | """发布图文内容。""" | 501 | """发布图文内容。""" |
| 742 | from image_downloader import process_images | 502 | from image_downloader import process_images |
| 743 | - from xhs.login import check_login_status | ||
| 744 | from xhs.publish import publish_image_content | 503 | from xhs.publish import publish_image_content |
| 745 | from xhs.types import PublishImageContent | 504 | from xhs.types import PublishImageContent |
| 746 | 505 | ||
| 747 | - # 读取标题和正文 | ||
| 748 | with open(args.title_file, encoding="utf-8") as f: | 506 | with open(args.title_file, encoding="utf-8") as f: |
| 749 | title = f.read().strip() | 507 | title = f.read().strip() |
| 750 | with open(args.content_file, encoding="utf-8") as f: | 508 | with open(args.content_file, encoding="utf-8") as f: |
| 751 | content = f.read().strip() | 509 | content = f.read().strip() |
| 752 | 510 | ||
| 753 | - # 处理图片 | ||
| 754 | image_paths = process_images(args.images) if args.images else [] | 511 | image_paths = process_images(args.images) if args.images else [] |
| 755 | if not image_paths: | 512 | if not image_paths: |
| 756 | _output({"success": False, "error": "没有有效的图片"}, exit_code=2) | 513 | _output({"success": False, "error": "没有有效的图片"}, exit_code=2) |
| 757 | 514 | ||
| 758 | browser, page = _connect(args) | 515 | browser, page = _connect(args) |
| 759 | try: | 516 | try: |
| 760 | - # headless 模式登录检查 + 自动降级 | ||
| 761 | - headless = getattr(args, "headless", False) | ||
| 762 | - if headless and not check_login_status(page): | ||
| 763 | - browser.close_page(page) | ||
| 764 | - browser.close() | ||
| 765 | - _headless_fallback(args.port) | ||
| 766 | - return | ||
| 767 | - | ||
| 768 | publish_image_content( | 517 | publish_image_content( |
| 769 | page, | 518 | page, |
| 770 | PublishImageContent( | 519 | PublishImageContent( |
| @@ -779,7 +528,6 @@ def cmd_publish(args: argparse.Namespace) -> None: | @@ -779,7 +528,6 @@ def cmd_publish(args: argparse.Namespace) -> None: | ||
| 779 | ) | 528 | ) |
| 780 | _output({"success": True, "title": title, "images": len(image_paths), "status": "发布完成"}) | 529 | _output({"success": True, "title": title, "images": len(image_paths), "status": "发布完成"}) |
| 781 | finally: | 530 | finally: |
| 782 | - browser.close_page(page) | ||
| 783 | browser.close() | 531 | browser.close() |
| 784 | 532 | ||
| 785 | 533 | ||
| @@ -812,16 +560,8 @@ def cmd_fill_publish(args: argparse.Namespace) -> None: | @@ -812,16 +560,8 @@ def cmd_fill_publish(args: argparse.Namespace) -> None: | ||
| 812 | visibility=args.visibility or "", | 560 | visibility=args.visibility or "", |
| 813 | ), | 561 | ), |
| 814 | ) | 562 | ) |
| 815 | - _output( | ||
| 816 | - { | ||
| 817 | - "success": True, | ||
| 818 | - "title": title, | ||
| 819 | - "images": len(image_paths), | ||
| 820 | - "status": "表单已填写,等待确认发布", | ||
| 821 | - } | ||
| 822 | - ) | 563 | + _output({"success": True, "title": title, "images": len(image_paths), "status": "表单已填写,等待确认发布"}) |
| 823 | finally: | 564 | finally: |
| 824 | - # 不关闭页面,让用户在浏览器中预览 | ||
| 825 | browser.close() | 565 | browser.close() |
| 826 | 566 | ||
| 827 | 567 | ||
| @@ -848,21 +588,13 @@ def cmd_fill_publish_video(args: argparse.Namespace) -> None: | @@ -848,21 +588,13 @@ def cmd_fill_publish_video(args: argparse.Namespace) -> None: | ||
| 848 | visibility=args.visibility or "", | 588 | visibility=args.visibility or "", |
| 849 | ), | 589 | ), |
| 850 | ) | 590 | ) |
| 851 | - _output( | ||
| 852 | - { | ||
| 853 | - "success": True, | ||
| 854 | - "title": title, | ||
| 855 | - "video": args.video, | ||
| 856 | - "status": "视频表单已填写,等待确认发布", | ||
| 857 | - } | ||
| 858 | - ) | 591 | + _output({"success": True, "title": title, "video": args.video, "status": "视频表单已填写,等待确认发布"}) |
| 859 | finally: | 592 | finally: |
| 860 | - # 不关闭页面,让用户在浏览器中预览 | ||
| 861 | browser.close() | 593 | browser.close() |
| 862 | 594 | ||
| 863 | 595 | ||
| 864 | def cmd_click_publish(args: argparse.Namespace) -> None: | 596 | def cmd_click_publish(args: argparse.Namespace) -> None: |
| 865 | - """点击发布按钮(在用户确认后调用)。复用已有的发布页 tab。""" | 597 | + """点击发布按钮(在用户确认后调用)。""" |
| 866 | from xhs.publish import click_publish_button | 598 | from xhs.publish import click_publish_button |
| 867 | 599 | ||
| 868 | browser, page = _connect_existing(args) | 600 | browser, page = _connect_existing(args) |
| @@ -870,12 +602,11 @@ def cmd_click_publish(args: argparse.Namespace) -> None: | @@ -870,12 +602,11 @@ def cmd_click_publish(args: argparse.Namespace) -> None: | ||
| 870 | click_publish_button(page) | 602 | click_publish_button(page) |
| 871 | _output({"success": True, "status": "发布完成"}) | 603 | _output({"success": True, "status": "发布完成"}) |
| 872 | finally: | 604 | finally: |
| 873 | - browser.close_page(page) | ||
| 874 | browser.close() | 605 | browser.close() |
| 875 | 606 | ||
| 876 | 607 | ||
| 877 | def cmd_save_draft(args: argparse.Namespace) -> None: | 608 | def cmd_save_draft(args: argparse.Namespace) -> None: |
| 878 | - """保存为草稿(取消发布时调用)。""" | 609 | + """保存为草稿。""" |
| 879 | from xhs.publish import save_as_draft | 610 | from xhs.publish import save_as_draft |
| 880 | 611 | ||
| 881 | browser, page = _connect_existing(args) | 612 | browser, page = _connect_existing(args) |
| @@ -883,7 +614,6 @@ def cmd_save_draft(args: argparse.Namespace) -> None: | @@ -883,7 +614,6 @@ def cmd_save_draft(args: argparse.Namespace) -> None: | ||
| 883 | save_as_draft(page) | 614 | save_as_draft(page) |
| 884 | _output({"success": True, "status": "内容已保存到草稿箱"}) | 615 | _output({"success": True, "status": "内容已保存到草稿箱"}) |
| 885 | finally: | 616 | finally: |
| 886 | - browser.close_page(page) | ||
| 887 | browser.close() | 617 | browser.close() |
| 888 | 618 | ||
| 889 | 619 | ||
| @@ -904,20 +634,13 @@ def cmd_long_article(args: argparse.Namespace) -> None: | @@ -904,20 +634,13 @@ def cmd_long_article(args: argparse.Namespace) -> None: | ||
| 904 | content=content, | 634 | content=content, |
| 905 | image_paths=args.images, | 635 | image_paths=args.images, |
| 906 | ) | 636 | ) |
| 907 | - _output( | ||
| 908 | - { | ||
| 909 | - "success": True, | ||
| 910 | - "templates": template_names, | ||
| 911 | - "status": "长文已填写,请选择模板", | ||
| 912 | - } | ||
| 913 | - ) | 637 | + _output({"success": True, "templates": template_names, "status": "长文已填写,请选择模板"}) |
| 914 | finally: | 638 | finally: |
| 915 | - # 不关闭页面,后续 select-template / next-step 需要复用 | ||
| 916 | browser.close() | 639 | browser.close() |
| 917 | 640 | ||
| 918 | 641 | ||
| 919 | def cmd_select_template(args: argparse.Namespace) -> None: | 642 | def cmd_select_template(args: argparse.Namespace) -> None: |
| 920 | - """选择排版模板。复用已有的长文编辑页 tab。""" | 643 | + """选择排版模板。""" |
| 921 | from xhs.publish_long_article import select_template | 644 | from xhs.publish_long_article import select_template |
| 922 | 645 | ||
| 923 | browser, page = _connect_existing(args) | 646 | browser, page = _connect_existing(args) |
| @@ -926,17 +649,13 @@ def cmd_select_template(args: argparse.Namespace) -> None: | @@ -926,17 +649,13 @@ def cmd_select_template(args: argparse.Namespace) -> None: | ||
| 926 | if selected: | 649 | if selected: |
| 927 | _output({"success": True, "template": args.name, "status": "模板已选择"}) | 650 | _output({"success": True, "template": args.name, "status": "模板已选择"}) |
| 928 | else: | 651 | else: |
| 929 | - _output( | ||
| 930 | - {"success": False, "error": f"未找到模板: {args.name}"}, | ||
| 931 | - exit_code=2, | ||
| 932 | - ) | 652 | + _output({"success": False, "error": f"未找到模板: {args.name}"}, exit_code=2) |
| 933 | finally: | 653 | finally: |
| 934 | - # 不关闭页面,后续 next-step 需要复用 | ||
| 935 | browser.close() | 654 | browser.close() |
| 936 | 655 | ||
| 937 | 656 | ||
| 938 | def cmd_next_step(args: argparse.Namespace) -> None: | 657 | def cmd_next_step(args: argparse.Namespace) -> None: |
| 939 | - """点击下一步 + 填写发布页描述。复用已有的长文编辑页 tab。""" | 658 | + """点击下一步 + 填写发布页描述。""" |
| 940 | from xhs.publish_long_article import click_next_and_fill_description | 659 | from xhs.publish_long_article import click_next_and_fill_description |
| 941 | 660 | ||
| 942 | with open(args.content_file, encoding="utf-8") as f: | 661 | with open(args.content_file, encoding="utf-8") as f: |
| @@ -947,13 +666,11 @@ def cmd_next_step(args: argparse.Namespace) -> None: | @@ -947,13 +666,11 @@ def cmd_next_step(args: argparse.Namespace) -> None: | ||
| 947 | click_next_and_fill_description(page, description) | 666 | click_next_and_fill_description(page, description) |
| 948 | _output({"success": True, "status": "已进入发布页,等待确认发布"}) | 667 | _output({"success": True, "status": "已进入发布页,等待确认发布"}) |
| 949 | finally: | 668 | finally: |
| 950 | - # 不关闭页面,等待 click-publish | ||
| 951 | browser.close() | 669 | browser.close() |
| 952 | 670 | ||
| 953 | 671 | ||
| 954 | def cmd_publish_video(args: argparse.Namespace) -> None: | 672 | def cmd_publish_video(args: argparse.Namespace) -> None: |
| 955 | """发布视频内容。""" | 673 | """发布视频内容。""" |
| 956 | - from xhs.login import check_login_status | ||
| 957 | from xhs.publish_video import publish_video_content | 674 | from xhs.publish_video import publish_video_content |
| 958 | from xhs.types import PublishVideoContent | 675 | from xhs.types import PublishVideoContent |
| 959 | 676 | ||
| @@ -964,14 +681,6 @@ def cmd_publish_video(args: argparse.Namespace) -> None: | @@ -964,14 +681,6 @@ def cmd_publish_video(args: argparse.Namespace) -> None: | ||
| 964 | 681 | ||
| 965 | browser, page = _connect(args) | 682 | browser, page = _connect(args) |
| 966 | try: | 683 | try: |
| 967 | - # headless 模式登录检查 + 自动降级 | ||
| 968 | - headless = getattr(args, "headless", False) | ||
| 969 | - if headless and not check_login_status(page): | ||
| 970 | - browser.close_page(page) | ||
| 971 | - browser.close() | ||
| 972 | - _headless_fallback(args.port) | ||
| 973 | - return | ||
| 974 | - | ||
| 975 | publish_video_content( | 684 | publish_video_content( |
| 976 | page, | 685 | page, |
| 977 | PublishVideoContent( | 686 | PublishVideoContent( |
| @@ -985,73 +694,22 @@ def cmd_publish_video(args: argparse.Namespace) -> None: | @@ -985,73 +694,22 @@ def cmd_publish_video(args: argparse.Namespace) -> None: | ||
| 985 | ) | 694 | ) |
| 986 | _output({"success": True, "title": title, "video": args.video, "status": "发布完成"}) | 695 | _output({"success": True, "title": title, "video": args.video, "status": "发布完成"}) |
| 987 | finally: | 696 | finally: |
| 988 | - browser.close_page(page) | ||
| 989 | browser.close() | 697 | browser.close() |
| 990 | 698 | ||
| 991 | 699 | ||
| 992 | -# ========== 账号管理子命令 ========== | ||
| 993 | - | ||
| 994 | - | ||
| 995 | -def cmd_add_account(args: argparse.Namespace) -> None: | ||
| 996 | - """添加命名账号,自动分配独立端口和 Chrome Profile。""" | ||
| 997 | - import sys as _sys | ||
| 998 | - | ||
| 999 | - _sys.path.insert(0, os.path.join(os.path.dirname(__file__))) | ||
| 1000 | - import account_manager | ||
| 1001 | - | ||
| 1002 | - account_manager.add_account(args.name, description=args.description or "") | ||
| 1003 | - port = account_manager.get_account_port(args.name) | ||
| 1004 | - profile = account_manager.get_profile_dir(args.name) | ||
| 1005 | - _output({"success": True, "name": args.name, "port": port, "profile_dir": profile}) | ||
| 1006 | - | ||
| 1007 | - | ||
| 1008 | -def cmd_list_accounts(args: argparse.Namespace) -> None: | ||
| 1009 | - """列出所有命名账号。""" | ||
| 1010 | - import sys as _sys | ||
| 1011 | - | ||
| 1012 | - _sys.path.insert(0, os.path.join(os.path.dirname(__file__))) | ||
| 1013 | - import account_manager | ||
| 1014 | - | ||
| 1015 | - accounts = account_manager.list_accounts() | ||
| 1016 | - _output({"accounts": accounts, "count": len(accounts)}) | ||
| 1017 | - | ||
| 1018 | - | ||
| 1019 | -def cmd_remove_account(args: argparse.Namespace) -> None: | ||
| 1020 | - """删除命名账号。""" | ||
| 1021 | - import sys as _sys | ||
| 1022 | - | ||
| 1023 | - _sys.path.insert(0, os.path.join(os.path.dirname(__file__))) | ||
| 1024 | - import account_manager | ||
| 1025 | - | ||
| 1026 | - account_manager.remove_account(args.name) | ||
| 1027 | - _output({"success": True, "name": args.name}) | ||
| 1028 | - | ||
| 1029 | - | ||
| 1030 | -def cmd_set_default_account(args: argparse.Namespace) -> None: | ||
| 1031 | - """设置默认账号。""" | ||
| 1032 | - import sys as _sys | ||
| 1033 | - | ||
| 1034 | - _sys.path.insert(0, os.path.join(os.path.dirname(__file__))) | ||
| 1035 | - import account_manager | ||
| 1036 | - | ||
| 1037 | - account_manager.set_default_account(args.name) | ||
| 1038 | - _output({"success": True, "default": args.name}) | ||
| 1039 | - | ||
| 1040 | - | ||
| 1041 | -# ========== 参数解析 ========== | 700 | +# ─── 参数解析 ────────────────────────────────────────────────────────────────── |
| 1042 | 701 | ||
| 1043 | 702 | ||
| 1044 | def build_parser() -> argparse.ArgumentParser: | 703 | def build_parser() -> argparse.ArgumentParser: |
| 1045 | - """构建 CLI 参数解析器。""" | ||
| 1046 | parser = argparse.ArgumentParser( | 704 | parser = argparse.ArgumentParser( |
| 1047 | prog="xhs-cli", | 705 | prog="xhs-cli", |
| 1048 | - description="小红书自动化 CLI", | 706 | + description="小红书自动化 CLI(Extension Bridge 版)", |
| 707 | + ) | ||
| 708 | + parser.add_argument( | ||
| 709 | + "--bridge-url", | ||
| 710 | + default="ws://localhost:9333", | ||
| 711 | + help="Bridge server WebSocket 地址 (default: ws://localhost:9333)", | ||
| 1049 | ) | 712 | ) |
| 1050 | - | ||
| 1051 | - # 全局选项 | ||
| 1052 | - parser.add_argument("--host", default="127.0.0.1", help="Chrome 调试主机 (default: 127.0.0.1)") | ||
| 1053 | - parser.add_argument("--port", type=int, default=9222, help="Chrome 调试端口 (default: 9222)") | ||
| 1054 | - parser.add_argument("--account", default="", help="账号名称") | ||
| 1055 | 713 | ||
| 1056 | subparsers = parser.add_subparsers(dest="command", required=True) | 714 | subparsers = parser.add_subparsers(dest="command", required=True) |
| 1057 | 715 | ||
| @@ -1063,33 +721,33 @@ def build_parser() -> argparse.ArgumentParser: | @@ -1063,33 +721,33 @@ def build_parser() -> argparse.ArgumentParser: | ||
| 1063 | sub = subparsers.add_parser("login", help="登录(扫码,阻塞等待)") | 721 | sub = subparsers.add_parser("login", help="登录(扫码,阻塞等待)") |
| 1064 | sub.set_defaults(func=cmd_login) | 722 | sub.set_defaults(func=cmd_login) |
| 1065 | 723 | ||
| 1066 | - # get-qrcode(非阻塞,截图后立即返回) | ||
| 1067 | - sub = subparsers.add_parser("get-qrcode", help="获取登录二维码截图并立即返回(非阻塞)") | 724 | + # get-qrcode |
| 725 | + sub = subparsers.add_parser("get-qrcode", help="获取登录二维码截图(非阻塞)") | ||
| 1068 | sub.set_defaults(func=cmd_get_qrcode) | 726 | sub.set_defaults(func=cmd_get_qrcode) |
| 1069 | 727 | ||
| 1070 | - # wait-login(配合 get-qrcode,阻塞等待登录完成) | ||
| 1071 | - sub = subparsers.add_parser("wait-login", help="等待扫码登录完成(配合 get-qrcode 使用)") | 728 | + # wait-login |
| 729 | + sub = subparsers.add_parser("wait-login", help="等待扫码登录完成(配合 get-qrcode)") | ||
| 1072 | sub.add_argument("--timeout", type=float, default=120.0, help="等待超时秒数 (default: 120)") | 730 | sub.add_argument("--timeout", type=float, default=120.0, help="等待超时秒数 (default: 120)") |
| 1073 | sub.set_defaults(func=cmd_wait_login) | 731 | sub.set_defaults(func=cmd_wait_login) |
| 1074 | 732 | ||
| 1075 | - # phone-login(单命令交互式) | ||
| 1076 | - sub = subparsers.add_parser("phone-login", help="手机号+验证码登录(交互式,适合本地终端)") | ||
| 1077 | - sub.add_argument("--phone", required=True, help="手机号(不含国家码,如 13800138000)") | 733 | + # phone-login |
| 734 | + sub = subparsers.add_parser("phone-login", help="手机号+验证码登录(交互式)") | ||
| 735 | + sub.add_argument("--phone", required=True, help="手机号") | ||
| 1078 | sub.add_argument("--code", default="", help="短信验证码(省略则交互式输入)") | 736 | sub.add_argument("--code", default="", help="短信验证码(省略则交互式输入)") |
| 1079 | sub.set_defaults(func=cmd_phone_login) | 737 | sub.set_defaults(func=cmd_phone_login) |
| 1080 | 738 | ||
| 1081 | - # send-code(分步登录第一步) | ||
| 1082 | - sub = subparsers.add_parser("send-code", help="分步登录第一步:发送手机验证码,保持页面不关闭") | ||
| 1083 | - sub.add_argument("--phone", required=True, help="手机号(不含国家码)") | 739 | + # send-code |
| 740 | + sub = subparsers.add_parser("send-code", help="分步登录第一步:发送手机验证码") | ||
| 741 | + sub.add_argument("--phone", required=True, help="手机号") | ||
| 1084 | sub.set_defaults(func=cmd_send_code) | 742 | sub.set_defaults(func=cmd_send_code) |
| 1085 | 743 | ||
| 1086 | - # verify-code(分步登录第二步) | ||
| 1087 | - sub = subparsers.add_parser("verify-code", help="分步登录第二步:填写验证码并完成登录") | ||
| 1088 | - sub.add_argument("--code", required=True, help="收到的短信验证码") | 744 | + # verify-code |
| 745 | + sub = subparsers.add_parser("verify-code", help="分步登录第二步:填写验证码") | ||
| 746 | + sub.add_argument("--code", required=True, help="短信验证码") | ||
| 1089 | sub.set_defaults(func=cmd_verify_code) | 747 | sub.set_defaults(func=cmd_verify_code) |
| 1090 | 748 | ||
| 1091 | # delete-cookies | 749 | # delete-cookies |
| 1092 | - sub = subparsers.add_parser("delete-cookies", help="删除 cookies") | 750 | + sub = subparsers.add_parser("delete-cookies", help="退出登录") |
| 1093 | sub.set_defaults(func=cmd_delete_cookies) | 751 | sub.set_defaults(func=cmd_delete_cookies) |
| 1094 | 752 | ||
| 1095 | # list-feeds | 753 | # list-feeds |
| @@ -1111,142 +769,119 @@ def build_parser() -> argparse.ArgumentParser: | @@ -1111,142 +769,119 @@ def build_parser() -> argparse.ArgumentParser: | ||
| 1111 | sub.add_argument("--feed-id", required=True, help="Feed ID") | 769 | sub.add_argument("--feed-id", required=True, help="Feed ID") |
| 1112 | sub.add_argument("--xsec-token", required=True, help="xsec_token") | 770 | sub.add_argument("--xsec-token", required=True, help="xsec_token") |
| 1113 | sub.add_argument("--load-all-comments", action="store_true", help="加载全部评论") | 771 | sub.add_argument("--load-all-comments", action="store_true", help="加载全部评论") |
| 1114 | - sub.add_argument("--click-more-replies", action="store_true", help="点击展开更多回复") | ||
| 1115 | - sub.add_argument("--max-replies-threshold", type=int, default=10, help="展开回复数阈值") | ||
| 1116 | - sub.add_argument("--max-comment-items", type=int, default=0, help="最大评论数 (0=不限)") | ||
| 1117 | - sub.add_argument("--scroll-speed", default="normal", help="滚动速度: slow|normal|fast") | 772 | + sub.add_argument("--click-more-replies", action="store_true", help="展开更多回复") |
| 773 | + sub.add_argument("--max-replies-threshold", type=int, default=10) | ||
| 774 | + sub.add_argument("--max-comment-items", type=int, default=0) | ||
| 775 | + sub.add_argument("--scroll-speed", default="normal", help="slow|normal|fast") | ||
| 1118 | sub.set_defaults(func=cmd_get_feed_detail) | 776 | sub.set_defaults(func=cmd_get_feed_detail) |
| 1119 | 777 | ||
| 1120 | # user-profile | 778 | # user-profile |
| 1121 | sub = subparsers.add_parser("user-profile", help="获取用户主页") | 779 | sub = subparsers.add_parser("user-profile", help="获取用户主页") |
| 1122 | - sub.add_argument("--user-id", required=True, help="用户 ID") | ||
| 1123 | - sub.add_argument("--xsec-token", required=True, help="xsec_token") | 780 | + sub.add_argument("--user-id", required=True) |
| 781 | + sub.add_argument("--xsec-token", required=True) | ||
| 1124 | sub.set_defaults(func=cmd_user_profile) | 782 | sub.set_defaults(func=cmd_user_profile) |
| 1125 | 783 | ||
| 1126 | # post-comment | 784 | # post-comment |
| 1127 | sub = subparsers.add_parser("post-comment", help="发表评论") | 785 | sub = subparsers.add_parser("post-comment", help="发表评论") |
| 1128 | - sub.add_argument("--feed-id", required=True, help="Feed ID") | ||
| 1129 | - sub.add_argument("--xsec-token", required=True, help="xsec_token") | ||
| 1130 | - sub.add_argument("--content", required=True, help="评论内容") | 786 | + sub.add_argument("--feed-id", required=True) |
| 787 | + sub.add_argument("--xsec-token", required=True) | ||
| 788 | + sub.add_argument("--content", required=True) | ||
| 1131 | sub.set_defaults(func=cmd_post_comment) | 789 | sub.set_defaults(func=cmd_post_comment) |
| 1132 | 790 | ||
| 1133 | # reply-comment | 791 | # reply-comment |
| 1134 | sub = subparsers.add_parser("reply-comment", help="回复评论") | 792 | sub = subparsers.add_parser("reply-comment", help="回复评论") |
| 1135 | - sub.add_argument("--feed-id", required=True, help="Feed ID") | ||
| 1136 | - sub.add_argument("--xsec-token", required=True, help="xsec_token") | ||
| 1137 | - sub.add_argument("--content", required=True, help="回复内容") | ||
| 1138 | - sub.add_argument("--comment-id", help="目标评论 ID") | ||
| 1139 | - sub.add_argument("--user-id", help="目标用户 ID") | 793 | + sub.add_argument("--feed-id", required=True) |
| 794 | + sub.add_argument("--xsec-token", required=True) | ||
| 795 | + sub.add_argument("--content", required=True) | ||
| 796 | + sub.add_argument("--comment-id") | ||
| 797 | + sub.add_argument("--user-id") | ||
| 1140 | sub.set_defaults(func=cmd_reply_comment) | 798 | sub.set_defaults(func=cmd_reply_comment) |
| 1141 | 799 | ||
| 1142 | # like-feed | 800 | # like-feed |
| 1143 | sub = subparsers.add_parser("like-feed", help="点赞") | 801 | sub = subparsers.add_parser("like-feed", help="点赞") |
| 1144 | - sub.add_argument("--feed-id", required=True, help="Feed ID") | ||
| 1145 | - sub.add_argument("--xsec-token", required=True, help="xsec_token") | ||
| 1146 | - sub.add_argument("--unlike", action="store_true", help="取消点赞") | 802 | + sub.add_argument("--feed-id", required=True) |
| 803 | + sub.add_argument("--xsec-token", required=True) | ||
| 804 | + sub.add_argument("--unlike", action="store_true") | ||
| 1147 | sub.set_defaults(func=cmd_like_feed) | 805 | sub.set_defaults(func=cmd_like_feed) |
| 1148 | 806 | ||
| 1149 | # favorite-feed | 807 | # favorite-feed |
| 1150 | sub = subparsers.add_parser("favorite-feed", help="收藏") | 808 | sub = subparsers.add_parser("favorite-feed", help="收藏") |
| 1151 | - sub.add_argument("--feed-id", required=True, help="Feed ID") | ||
| 1152 | - sub.add_argument("--xsec-token", required=True, help="xsec_token") | ||
| 1153 | - sub.add_argument("--unfavorite", action="store_true", help="取消收藏") | 809 | + sub.add_argument("--feed-id", required=True) |
| 810 | + sub.add_argument("--xsec-token", required=True) | ||
| 811 | + sub.add_argument("--unfavorite", action="store_true") | ||
| 1154 | sub.set_defaults(func=cmd_favorite_feed) | 812 | sub.set_defaults(func=cmd_favorite_feed) |
| 1155 | 813 | ||
| 1156 | # publish | 814 | # publish |
| 1157 | sub = subparsers.add_parser("publish", help="发布图文") | 815 | sub = subparsers.add_parser("publish", help="发布图文") |
| 1158 | - sub.add_argument("--title-file", required=True, help="标题文件路径") | ||
| 1159 | - sub.add_argument("--content-file", required=True, help="正文文件路径") | ||
| 1160 | - sub.add_argument("--images", nargs="+", required=True, help="图片路径/URL") | ||
| 1161 | - sub.add_argument("--tags", nargs="*", help="标签") | ||
| 1162 | - sub.add_argument("--schedule-at", help="定时发布 (ISO8601)") | ||
| 1163 | - sub.add_argument("--original", action="store_true", help="声明原创") | ||
| 1164 | - sub.add_argument("--visibility", help="可见范围") | ||
| 1165 | - sub.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)") | 816 | + sub.add_argument("--title-file", required=True) |
| 817 | + sub.add_argument("--content-file", required=True) | ||
| 818 | + sub.add_argument("--images", nargs="+", required=True) | ||
| 819 | + sub.add_argument("--tags", nargs="*") | ||
| 820 | + sub.add_argument("--schedule-at") | ||
| 821 | + sub.add_argument("--original", action="store_true") | ||
| 822 | + sub.add_argument("--visibility") | ||
| 1166 | sub.set_defaults(func=cmd_publish) | 823 | sub.set_defaults(func=cmd_publish) |
| 1167 | 824 | ||
| 1168 | # publish-video | 825 | # publish-video |
| 1169 | sub = subparsers.add_parser("publish-video", help="发布视频") | 826 | sub = subparsers.add_parser("publish-video", help="发布视频") |
| 1170 | - sub.add_argument("--title-file", required=True, help="标题文件路径") | ||
| 1171 | - sub.add_argument("--content-file", required=True, help="正文文件路径") | ||
| 1172 | - sub.add_argument("--video", required=True, help="视频文件路径") | ||
| 1173 | - sub.add_argument("--tags", nargs="*", help="标签") | ||
| 1174 | - sub.add_argument("--schedule-at", help="定时发布 (ISO8601)") | ||
| 1175 | - sub.add_argument("--visibility", help="可见范围") | ||
| 1176 | - sub.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)") | 827 | + sub.add_argument("--title-file", required=True) |
| 828 | + sub.add_argument("--content-file", required=True) | ||
| 829 | + sub.add_argument("--video", required=True) | ||
| 830 | + sub.add_argument("--tags", nargs="*") | ||
| 831 | + sub.add_argument("--schedule-at") | ||
| 832 | + sub.add_argument("--visibility") | ||
| 1177 | sub.set_defaults(func=cmd_publish_video) | 833 | sub.set_defaults(func=cmd_publish_video) |
| 1178 | 834 | ||
| 1179 | - # fill-publish(只填写图文表单,不发布) | 835 | + # fill-publish |
| 1180 | sub = subparsers.add_parser("fill-publish", help="填写图文表单(不发布)") | 836 | sub = subparsers.add_parser("fill-publish", help="填写图文表单(不发布)") |
| 1181 | - sub.add_argument("--title-file", required=True, help="标题文件路径") | ||
| 1182 | - sub.add_argument("--content-file", required=True, help="正文文件路径") | ||
| 1183 | - sub.add_argument("--images", nargs="+", required=True, help="图片路径/URL") | ||
| 1184 | - sub.add_argument("--tags", nargs="*", help="标签") | ||
| 1185 | - sub.add_argument("--schedule-at", help="定时发布 (ISO8601)") | ||
| 1186 | - sub.add_argument("--original", action="store_true", help="声明原创") | ||
| 1187 | - sub.add_argument("--visibility", help="可见范围") | 837 | + sub.add_argument("--title-file", required=True) |
| 838 | + sub.add_argument("--content-file", required=True) | ||
| 839 | + sub.add_argument("--images", nargs="+", required=True) | ||
| 840 | + sub.add_argument("--tags", nargs="*") | ||
| 841 | + sub.add_argument("--schedule-at") | ||
| 842 | + sub.add_argument("--original", action="store_true") | ||
| 843 | + sub.add_argument("--visibility") | ||
| 1188 | sub.set_defaults(func=cmd_fill_publish) | 844 | sub.set_defaults(func=cmd_fill_publish) |
| 1189 | 845 | ||
| 1190 | - # fill-publish-video(只填写视频表单,不发布) | 846 | + # fill-publish-video |
| 1191 | sub = subparsers.add_parser("fill-publish-video", help="填写视频表单(不发布)") | 847 | sub = subparsers.add_parser("fill-publish-video", help="填写视频表单(不发布)") |
| 1192 | - sub.add_argument("--title-file", required=True, help="标题文件路径") | ||
| 1193 | - sub.add_argument("--content-file", required=True, help="正文文件路径") | ||
| 1194 | - sub.add_argument("--video", required=True, help="视频文件路径") | ||
| 1195 | - sub.add_argument("--tags", nargs="*", help="标签") | ||
| 1196 | - sub.add_argument("--schedule-at", help="定时发布 (ISO8601)") | ||
| 1197 | - sub.add_argument("--visibility", help="可见范围") | 848 | + sub.add_argument("--title-file", required=True) |
| 849 | + sub.add_argument("--content-file", required=True) | ||
| 850 | + sub.add_argument("--video", required=True) | ||
| 851 | + sub.add_argument("--tags", nargs="*") | ||
| 852 | + sub.add_argument("--schedule-at") | ||
| 853 | + sub.add_argument("--visibility") | ||
| 1198 | sub.set_defaults(func=cmd_fill_publish_video) | 854 | sub.set_defaults(func=cmd_fill_publish_video) |
| 1199 | 855 | ||
| 1200 | - # click-publish(点击发布按钮) | 856 | + # click-publish |
| 1201 | sub = subparsers.add_parser("click-publish", help="点击发布按钮") | 857 | sub = subparsers.add_parser("click-publish", help="点击发布按钮") |
| 1202 | sub.set_defaults(func=cmd_click_publish) | 858 | sub.set_defaults(func=cmd_click_publish) |
| 1203 | 859 | ||
| 1204 | - # long-article(长文模式) | 860 | + # save-draft |
| 861 | + sub = subparsers.add_parser("save-draft", help="保存为草稿") | ||
| 862 | + sub.set_defaults(func=cmd_save_draft) | ||
| 863 | + | ||
| 864 | + # long-article | ||
| 1205 | sub = subparsers.add_parser("long-article", help="长文模式:填写 + 一键排版") | 865 | sub = subparsers.add_parser("long-article", help="长文模式:填写 + 一键排版") |
| 1206 | - sub.add_argument("--title-file", required=True, help="标题文件路径") | ||
| 1207 | - sub.add_argument("--content-file", required=True, help="正文文件路径") | ||
| 1208 | - sub.add_argument("--images", nargs="*", help="可选图片路径") | 866 | + sub.add_argument("--title-file", required=True) |
| 867 | + sub.add_argument("--content-file", required=True) | ||
| 868 | + sub.add_argument("--images", nargs="*") | ||
| 1209 | sub.set_defaults(func=cmd_long_article) | 869 | sub.set_defaults(func=cmd_long_article) |
| 1210 | 870 | ||
| 1211 | - # select-template(选择模板) | 871 | + # select-template |
| 1212 | sub = subparsers.add_parser("select-template", help="选择排版模板") | 872 | sub = subparsers.add_parser("select-template", help="选择排版模板") |
| 1213 | - sub.add_argument("--name", required=True, help="模板名称") | 873 | + sub.add_argument("--name", required=True) |
| 1214 | sub.set_defaults(func=cmd_select_template) | 874 | sub.set_defaults(func=cmd_select_template) |
| 1215 | 875 | ||
| 1216 | - # next-step(下一步 + 填写描述) | 876 | + # next-step |
| 1217 | sub = subparsers.add_parser("next-step", help="点击下一步 + 填写描述") | 877 | sub = subparsers.add_parser("next-step", help="点击下一步 + 填写描述") |
| 1218 | - sub.add_argument("--content-file", required=True, help="描述内容文件路径") | 878 | + sub.add_argument("--content-file", required=True) |
| 1219 | sub.set_defaults(func=cmd_next_step) | 879 | sub.set_defaults(func=cmd_next_step) |
| 1220 | 880 | ||
| 1221 | - # save-draft(保存草稿) | ||
| 1222 | - sub = subparsers.add_parser("save-draft", help="保存为草稿(取消发布时使用)") | ||
| 1223 | - sub.set_defaults(func=cmd_save_draft) | ||
| 1224 | - | ||
| 1225 | - # add-account(添加命名账号) | ||
| 1226 | - sub = subparsers.add_parser("add-account", help="添加命名账号,自动分配独立端口") | ||
| 1227 | - sub.add_argument("--name", required=True, help="账号名称") | ||
| 1228 | - sub.add_argument("--description", default="", help="账号描述(可选)") | ||
| 1229 | - sub.set_defaults(func=cmd_add_account) | ||
| 1230 | - | ||
| 1231 | - # list-accounts(列出所有账号) | ||
| 1232 | - sub = subparsers.add_parser("list-accounts", help="列出所有命名账号") | ||
| 1233 | - sub.set_defaults(func=cmd_list_accounts) | ||
| 1234 | - | ||
| 1235 | - # remove-account(删除账号) | ||
| 1236 | - sub = subparsers.add_parser("remove-account", help="删除命名账号") | ||
| 1237 | - sub.add_argument("--name", required=True, help="账号名称") | ||
| 1238 | - sub.set_defaults(func=cmd_remove_account) | ||
| 1239 | - | ||
| 1240 | - # set-default-account(设置默认账号) | ||
| 1241 | - sub = subparsers.add_parser("set-default-account", help="设置默认账号") | ||
| 1242 | - sub.add_argument("--name", required=True, help="账号名称") | ||
| 1243 | - sub.set_defaults(func=cmd_set_default_account) | ||
| 1244 | - | ||
| 1245 | return parser | 881 | return parser |
| 1246 | 882 | ||
| 1247 | 883 | ||
| 1248 | def main() -> None: | 884 | def main() -> None: |
| 1249 | - """CLI 入口。""" | ||
| 1250 | parser = build_parser() | 885 | parser = build_parser() |
| 1251 | args = parser.parse_args() | 886 | args = parser.parse_args() |
| 1252 | 887 |
scripts/publish_pipeline.py
deleted
100644 → 0
| 1 | -"""发布编排器:下载 → 登录检查 → 发布 → 报告。""" | ||
| 2 | - | ||
| 3 | -from __future__ import annotations | ||
| 4 | - | ||
| 5 | -import json | ||
| 6 | -import logging | ||
| 7 | -import sys | ||
| 8 | - | ||
| 9 | -from image_downloader import process_images | ||
| 10 | -from title_utils import calc_title_length | ||
| 11 | -from xhs.cdp import Browser | ||
| 12 | -from xhs.login import check_login_status | ||
| 13 | -from xhs.publish import publish_image_content | ||
| 14 | -from xhs.publish_video import publish_video_content | ||
| 15 | -from xhs.types import PublishImageContent, PublishVideoContent | ||
| 16 | - | ||
| 17 | -logger = logging.getLogger(__name__) | ||
| 18 | - | ||
| 19 | - | ||
| 20 | -def run_publish_pipeline( | ||
| 21 | - title: str, | ||
| 22 | - content: str, | ||
| 23 | - images: list[str] | None = None, | ||
| 24 | - video: str | None = None, | ||
| 25 | - tags: list[str] | None = None, | ||
| 26 | - schedule_time: str | None = None, | ||
| 27 | - is_original: bool = False, | ||
| 28 | - visibility: str = "", | ||
| 29 | - host: str = "127.0.0.1", | ||
| 30 | - port: int = 9222, | ||
| 31 | - account: str = "", | ||
| 32 | - headless: bool = False, | ||
| 33 | -) -> dict: | ||
| 34 | - """执行完整发布流水线。 | ||
| 35 | - | ||
| 36 | - 当 headless=True 且未登录时,自动降级到有窗口模式。 | ||
| 37 | - | ||
| 38 | - Returns: | ||
| 39 | - 发布结果字典。 | ||
| 40 | - """ | ||
| 41 | - # 标题长度校验 | ||
| 42 | - title_len = calc_title_length(title) | ||
| 43 | - if title_len > 20: | ||
| 44 | - return {"success": False, "error": f"标题长度超限: {title_len}/20"} | ||
| 45 | - | ||
| 46 | - # 处理图片(下载 URL / 验证本地路径) | ||
| 47 | - local_images: list[str] = [] | ||
| 48 | - if images: | ||
| 49 | - local_images = process_images(images) | ||
| 50 | - if not local_images: | ||
| 51 | - return {"success": False, "error": "没有有效的图片"} | ||
| 52 | - | ||
| 53 | - # 连接浏览器 | ||
| 54 | - browser = Browser(host=host, port=port) | ||
| 55 | - browser.connect() | ||
| 56 | - | ||
| 57 | - try: | ||
| 58 | - page = browser.new_page() | ||
| 59 | - try: | ||
| 60 | - # 登录检查 | ||
| 61 | - if not check_login_status(page): | ||
| 62 | - browser.close_page(page) | ||
| 63 | - browser.close() | ||
| 64 | - | ||
| 65 | - # Headless 自动降级:切换到有窗口模式 | ||
| 66 | - if headless: | ||
| 67 | - from chrome_launcher import restart_chrome | ||
| 68 | - | ||
| 69 | - logger.info("Headless 模式未登录,切换到有窗口模式...") | ||
| 70 | - restart_chrome(port=port, headless=False) | ||
| 71 | - return { | ||
| 72 | - "success": False, | ||
| 73 | - "error": "未登录", | ||
| 74 | - "action": "switched_to_headed", | ||
| 75 | - "message": "已切换到有窗口模式,请在浏览器中扫码登录", | ||
| 76 | - "exit_code": 1, | ||
| 77 | - } | ||
| 78 | - | ||
| 79 | - return { | ||
| 80 | - "success": False, | ||
| 81 | - "error": "未登录", | ||
| 82 | - "exit_code": 1, | ||
| 83 | - } | ||
| 84 | - | ||
| 85 | - # 发布 | ||
| 86 | - if video: | ||
| 87 | - publish_video_content( | ||
| 88 | - page, | ||
| 89 | - PublishVideoContent( | ||
| 90 | - title=title, | ||
| 91 | - content=content, | ||
| 92 | - tags=tags or [], | ||
| 93 | - video_path=video, | ||
| 94 | - schedule_time=schedule_time, | ||
| 95 | - visibility=visibility, | ||
| 96 | - ), | ||
| 97 | - ) | ||
| 98 | - else: | ||
| 99 | - publish_image_content( | ||
| 100 | - page, | ||
| 101 | - PublishImageContent( | ||
| 102 | - title=title, | ||
| 103 | - content=content, | ||
| 104 | - tags=tags or [], | ||
| 105 | - image_paths=local_images, | ||
| 106 | - schedule_time=schedule_time, | ||
| 107 | - is_original=is_original, | ||
| 108 | - visibility=visibility, | ||
| 109 | - ), | ||
| 110 | - ) | ||
| 111 | - | ||
| 112 | - return { | ||
| 113 | - "success": True, | ||
| 114 | - "title": title, | ||
| 115 | - "content_length": len(content), | ||
| 116 | - "images": len(local_images), | ||
| 117 | - "video": video or "", | ||
| 118 | - "status": "发布完成", | ||
| 119 | - } | ||
| 120 | - | ||
| 121 | - finally: | ||
| 122 | - browser.close_page(page) | ||
| 123 | - finally: | ||
| 124 | - browser.close() | ||
| 125 | - | ||
| 126 | - | ||
| 127 | -def main() -> None: | ||
| 128 | - """CLI 入口(被 cli.py 的 publish/publish-video 子命令调用时使用)。""" | ||
| 129 | - import argparse | ||
| 130 | - | ||
| 131 | - parser = argparse.ArgumentParser(description="小红书发布流水线") | ||
| 132 | - parser.add_argument("--title-file", required=True, help="标题文件路径") | ||
| 133 | - parser.add_argument("--content-file", required=True, help="正文文件路径") | ||
| 134 | - parser.add_argument("--images", nargs="*", help="图片路径或 URL 列表") | ||
| 135 | - parser.add_argument("--video", help="视频文件路径") | ||
| 136 | - parser.add_argument("--tags", nargs="*", help="标签列表") | ||
| 137 | - parser.add_argument("--schedule-at", help="定时发布时间 (ISO8601)") | ||
| 138 | - parser.add_argument("--original", action="store_true", help="声明原创") | ||
| 139 | - parser.add_argument("--visibility", default="", help="可见范围") | ||
| 140 | - parser.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)") | ||
| 141 | - parser.add_argument("--host", default="127.0.0.1") | ||
| 142 | - parser.add_argument("--port", type=int, default=9222) | ||
| 143 | - parser.add_argument("--account", default="") | ||
| 144 | - args = parser.parse_args() | ||
| 145 | - | ||
| 146 | - # 读取标题和正文 | ||
| 147 | - with open(args.title_file, encoding="utf-8") as f: | ||
| 148 | - title = f.read().strip() | ||
| 149 | - with open(args.content_file, encoding="utf-8") as f: | ||
| 150 | - content = f.read().strip() | ||
| 151 | - | ||
| 152 | - result = run_publish_pipeline( | ||
| 153 | - title=title, | ||
| 154 | - content=content, | ||
| 155 | - images=args.images, | ||
| 156 | - video=args.video, | ||
| 157 | - tags=args.tags, | ||
| 158 | - schedule_time=args.schedule_at, | ||
| 159 | - is_original=args.original, | ||
| 160 | - visibility=args.visibility, | ||
| 161 | - host=args.host, | ||
| 162 | - port=args.port, | ||
| 163 | - account=args.account, | ||
| 164 | - headless=args.headless, | ||
| 165 | - ) | ||
| 166 | - | ||
| 167 | - print(json.dumps(result, ensure_ascii=False, indent=2)) | ||
| 168 | - exit_code = result.get("exit_code", 0 if result["success"] else 2) | ||
| 169 | - sys.exit(exit_code) | ||
| 170 | - | ||
| 171 | - | ||
| 172 | -if __name__ == "__main__": | ||
| 173 | - main() |
scripts/test_headless_login.py
deleted
100644 → 0
| 1 | -"""测试无头环境下手机登录流程中 headless 参数传递是否正确。 | ||
| 2 | - | ||
| 3 | -模拟 Linux 无桌面环境(has_display() = False),验证修复后的代码路径。 | ||
| 4 | -""" | ||
| 5 | -from __future__ import annotations | ||
| 6 | - | ||
| 7 | -import argparse | ||
| 8 | -import sys | ||
| 9 | -from unittest.mock import MagicMock, call, patch | ||
| 10 | - | ||
| 11 | -import pytest | ||
| 12 | - | ||
| 13 | -sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent)) | ||
| 14 | - | ||
| 15 | - | ||
| 16 | -# ---------- 工具 ---------- | ||
| 17 | - | ||
| 18 | -def _make_args(**kwargs) -> argparse.Namespace: | ||
| 19 | - defaults = dict(host="127.0.0.1", port=9222, account="") | ||
| 20 | - return argparse.Namespace(**{**defaults, **kwargs}) | ||
| 21 | - | ||
| 22 | - | ||
| 23 | -# ---------- Bug 2:_connect / _connect_existing ---------- | ||
| 24 | - | ||
| 25 | -class TestConnectHeadless: | ||
| 26 | - """_connect 和 _connect_existing 在无头环境下应传 headless=True。""" | ||
| 27 | - | ||
| 28 | - def test_connect_headless_when_no_display(self): | ||
| 29 | - mock_page = MagicMock() | ||
| 30 | - mock_browser_inst = MagicMock() | ||
| 31 | - mock_browser_inst.new_page.return_value = mock_page | ||
| 32 | - | ||
| 33 | - with ( | ||
| 34 | - patch("chrome_launcher.has_display", return_value=False), | ||
| 35 | - patch("chrome_launcher.ensure_chrome", return_value=True) as mock_ensure, | ||
| 36 | - patch("xhs.cdp.Browser", return_value=mock_browser_inst), | ||
| 37 | - ): | ||
| 38 | - import cli | ||
| 39 | - cli._connect(_make_args()) | ||
| 40 | - | ||
| 41 | - mock_ensure.assert_called_once_with(port=9222, headless=True) | ||
| 42 | - | ||
| 43 | - def test_connect_headed_when_has_display(self): | ||
| 44 | - mock_page = MagicMock() | ||
| 45 | - mock_browser_inst = MagicMock() | ||
| 46 | - mock_browser_inst.new_page.return_value = mock_page | ||
| 47 | - | ||
| 48 | - with ( | ||
| 49 | - patch("chrome_launcher.has_display", return_value=True), | ||
| 50 | - patch("chrome_launcher.ensure_chrome", return_value=True) as mock_ensure, | ||
| 51 | - patch("xhs.cdp.Browser", return_value=mock_browser_inst), | ||
| 52 | - ): | ||
| 53 | - import cli | ||
| 54 | - cli._connect(_make_args()) | ||
| 55 | - | ||
| 56 | - mock_ensure.assert_called_once_with(port=9222, headless=False) | ||
| 57 | - | ||
| 58 | - def test_connect_existing_headless_when_no_display(self): | ||
| 59 | - mock_page = MagicMock() | ||
| 60 | - mock_browser_inst = MagicMock() | ||
| 61 | - mock_browser_inst.get_existing_page.return_value = mock_page | ||
| 62 | - | ||
| 63 | - with ( | ||
| 64 | - patch("chrome_launcher.has_display", return_value=False), | ||
| 65 | - patch("chrome_launcher.ensure_chrome", return_value=True) as mock_ensure, | ||
| 66 | - patch("xhs.cdp.Browser", return_value=mock_browser_inst), | ||
| 67 | - ): | ||
| 68 | - import cli | ||
| 69 | - cli._connect_existing(_make_args()) | ||
| 70 | - | ||
| 71 | - mock_ensure.assert_called_once_with(port=9222, headless=True) | ||
| 72 | - | ||
| 73 | - | ||
| 74 | -# ---------- Bug 1:send-code RateLimitError 重启 ---------- | ||
| 75 | - | ||
| 76 | -class TestSendCodeRateLimit: | ||
| 77 | - """触发频率限制时,重启 Chrome 应使用正确的 headless 参数。""" | ||
| 78 | - | ||
| 79 | - def _run_send_code(self, has_display_value: bool): | ||
| 80 | - """运行 cmd_send_code 并触发 RateLimitError,返回 restart_chrome 的调用记录。""" | ||
| 81 | - from xhs.errors import RateLimitError | ||
| 82 | - | ||
| 83 | - mock_page = MagicMock() | ||
| 84 | - mock_browser_inst = MagicMock() | ||
| 85 | - mock_browser_inst.new_page.return_value = mock_page | ||
| 86 | - | ||
| 87 | - with ( | ||
| 88 | - patch("chrome_launcher.has_display", return_value=has_display_value), | ||
| 89 | - patch("chrome_launcher.ensure_chrome", return_value=True), | ||
| 90 | - patch("chrome_launcher.restart_chrome") as mock_restart, | ||
| 91 | - patch("xhs.cdp.Browser", return_value=mock_browser_inst), | ||
| 92 | - patch("xhs.login.send_phone_code", side_effect=[RateLimitError(), True]), | ||
| 93 | - pytest.raises(SystemExit), # _output 会 sys.exit | ||
| 94 | - ): | ||
| 95 | - import cli | ||
| 96 | - cli.cmd_send_code(_make_args(phone="13800138000")) | ||
| 97 | - | ||
| 98 | - return mock_restart | ||
| 99 | - | ||
| 100 | - def test_rate_limit_restart_headless_when_no_display(self): | ||
| 101 | - mock_restart = self._run_send_code(has_display_value=False) | ||
| 102 | - mock_restart.assert_called_once_with(port=9222, headless=True) | ||
| 103 | - | ||
| 104 | - def test_rate_limit_restart_headed_when_has_display(self): | ||
| 105 | - mock_restart = self._run_send_code(has_display_value=True) | ||
| 106 | - mock_restart.assert_called_once_with(port=9222, headless=False) | ||
| 107 | - | ||
| 108 | - | ||
| 109 | -# ---------- Bug 3:_headless_fallback ---------- | ||
| 110 | - | ||
| 111 | -class TestHeadlessFallback: | ||
| 112 | - """_headless_fallback 在有/无桌面时行为应不同。""" | ||
| 113 | - | ||
| 114 | - def test_no_display_returns_error_without_restart(self): | ||
| 115 | - with ( | ||
| 116 | - patch("chrome_launcher.has_display", return_value=False), | ||
| 117 | - patch("chrome_launcher.restart_chrome") as mock_restart, | ||
| 118 | - pytest.raises(SystemExit) as exc_info, | ||
| 119 | - ): | ||
| 120 | - import io, json | ||
| 121 | - from contextlib import redirect_stdout | ||
| 122 | - buf = io.StringIO() | ||
| 123 | - with redirect_stdout(buf): | ||
| 124 | - import cli | ||
| 125 | - cli._headless_fallback(port=9222) | ||
| 126 | - | ||
| 127 | - mock_restart.assert_not_called() | ||
| 128 | - assert exc_info.value.code == 1 | ||
| 129 | - output = json.loads(buf.getvalue()) | ||
| 130 | - assert output["action"] == "login_required" | ||
| 131 | - assert "send-code" in output["message"] | ||
| 132 | - | ||
| 133 | - def test_has_display_restarts_headed(self): | ||
| 134 | - with ( | ||
| 135 | - patch("chrome_launcher.has_display", return_value=True), | ||
| 136 | - patch("chrome_launcher.restart_chrome") as mock_restart, | ||
| 137 | - pytest.raises(SystemExit), | ||
| 138 | - ): | ||
| 139 | - import cli | ||
| 140 | - cli._headless_fallback(port=9222) | ||
| 141 | - | ||
| 142 | - mock_restart.assert_called_once_with(port=9222, headless=False) |
scripts/xhs/bridge.py
0 → 100644
| 1 | +"""BridgePage - 通过浏览器扩展 Bridge 实现与 CDP Page 相同的接口。 | ||
| 2 | + | ||
| 3 | +CLI 命令通过 WebSocket 发送到 bridge_server.py, | ||
| 4 | +bridge_server 转发给浏览器扩展执行,结果原路返回。 | ||
| 5 | + | ||
| 6 | +每次调用都是一次短连接(发一条命令 → 收一条回复), | ||
| 7 | +不需要维护持久连接。 | ||
| 8 | +""" | ||
| 9 | + | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import base64 | ||
| 13 | +import json | ||
| 14 | +import os | ||
| 15 | +from typing import Any | ||
| 16 | + | ||
| 17 | +import websockets.sync.client as ws_client | ||
| 18 | + | ||
| 19 | +from .errors import CDPError, ElementNotFoundError | ||
| 20 | + | ||
| 21 | +BRIDGE_URL = "ws://localhost:9333" | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +class BridgePage: | ||
| 25 | + """与 CDP Page 接口兼容的 Extension Bridge 实现。""" | ||
| 26 | + | ||
| 27 | + def __init__(self, bridge_url: str = BRIDGE_URL) -> None: | ||
| 28 | + self._bridge_url = bridge_url | ||
| 29 | + | ||
| 30 | + # ─── 内部通信 ─────────────────────────────────────────────── | ||
| 31 | + | ||
| 32 | + def _call(self, method: str, params: dict | None = None) -> Any: | ||
| 33 | + """向 bridge server 发送一条命令并等待结果。""" | ||
| 34 | + msg: dict[str, Any] = {"role": "cli", "method": method} | ||
| 35 | + if params: | ||
| 36 | + msg["params"] = params | ||
| 37 | + try: | ||
| 38 | + with ws_client.connect(self._bridge_url, max_size=50 * 1024 * 1024) as ws: | ||
| 39 | + ws.send(json.dumps(msg, ensure_ascii=False)) | ||
| 40 | + raw = ws.recv(timeout=90) | ||
| 41 | + except OSError as e: | ||
| 42 | + raise CDPError(f"无法连接到 bridge server(ws://localhost:9333): {e}") from e | ||
| 43 | + | ||
| 44 | + resp = json.loads(raw) | ||
| 45 | + if "error" in resp and resp["error"]: | ||
| 46 | + raise CDPError(f"Bridge 错误: {resp['error']}") | ||
| 47 | + return resp.get("result") | ||
| 48 | + | ||
| 49 | + # ─── 导航 ─────────────────────────────────────────────────── | ||
| 50 | + | ||
| 51 | + def navigate(self, url: str) -> None: | ||
| 52 | + self._call("navigate", {"url": url}) | ||
| 53 | + | ||
| 54 | + def wait_for_load(self, timeout: float = 60.0) -> None: | ||
| 55 | + self._call("wait_for_load", {"timeout": int(timeout * 1000)}) | ||
| 56 | + | ||
| 57 | + def wait_dom_stable(self, timeout: float = 10.0, interval: float = 0.5) -> None: | ||
| 58 | + self._call("wait_dom_stable", { | ||
| 59 | + "timeout": int(timeout * 1000), | ||
| 60 | + "interval": int(interval * 1000), | ||
| 61 | + }) | ||
| 62 | + | ||
| 63 | + # ─── JavaScript 执行 ──────────────────────────────────────── | ||
| 64 | + | ||
| 65 | + def evaluate(self, expression: str, timeout: float = 30.0) -> Any: | ||
| 66 | + return self._call("evaluate", {"expression": expression}) | ||
| 67 | + | ||
| 68 | + def evaluate_function(self, function_body: str, *args: Any) -> Any: | ||
| 69 | + return self._call("evaluate", {"expression": f"({function_body})()"}) | ||
| 70 | + | ||
| 71 | + # ─── 元素查询 ──────────────────────────────────────────────── | ||
| 72 | + | ||
| 73 | + def query_selector(self, selector: str) -> str | None: | ||
| 74 | + """返回 "found" 表示元素存在,None 表示不存在(兼容 CDP 的 objectId 语义)。""" | ||
| 75 | + found = self._call("has_element", {"selector": selector}) | ||
| 76 | + return "found" if found else None | ||
| 77 | + | ||
| 78 | + def query_selector_all(self, selector: str) -> list[str]: | ||
| 79 | + count = self.get_elements_count(selector) | ||
| 80 | + return ["found"] * count | ||
| 81 | + | ||
| 82 | + def has_element(self, selector: str) -> bool: | ||
| 83 | + return bool(self._call("has_element", {"selector": selector})) | ||
| 84 | + | ||
| 85 | + def wait_for_element(self, selector: str, timeout: float = 30.0) -> str: | ||
| 86 | + found = self._call("wait_for_selector", { | ||
| 87 | + "selector": selector, | ||
| 88 | + "timeout": int(timeout * 1000), | ||
| 89 | + }) | ||
| 90 | + if not found: | ||
| 91 | + raise ElementNotFoundError(selector) | ||
| 92 | + return "found" | ||
| 93 | + | ||
| 94 | + # ─── 元素操作 ──────────────────────────────────────────────── | ||
| 95 | + | ||
| 96 | + def click_element(self, selector: str) -> None: | ||
| 97 | + self._call("click_element", {"selector": selector}) | ||
| 98 | + | ||
| 99 | + def input_text(self, selector: str, text: str) -> None: | ||
| 100 | + self._call("input_text", {"selector": selector, "text": text}) | ||
| 101 | + | ||
| 102 | + def input_content_editable(self, selector: str, text: str) -> None: | ||
| 103 | + self._call("input_content_editable", {"selector": selector, "text": text}) | ||
| 104 | + | ||
| 105 | + def get_element_text(self, selector: str) -> str | None: | ||
| 106 | + return self._call("get_element_text", {"selector": selector}) | ||
| 107 | + | ||
| 108 | + def get_element_attribute(self, selector: str, attr: str) -> str | None: | ||
| 109 | + return self._call("get_element_attribute", {"selector": selector, "attr": attr}) | ||
| 110 | + | ||
| 111 | + def get_elements_count(self, selector: str) -> int: | ||
| 112 | + result = self._call("get_elements_count", {"selector": selector}) | ||
| 113 | + return int(result) if result is not None else 0 | ||
| 114 | + | ||
| 115 | + def remove_element(self, selector: str) -> None: | ||
| 116 | + self._call("remove_element", {"selector": selector}) | ||
| 117 | + | ||
| 118 | + def hover_element(self, selector: str) -> None: | ||
| 119 | + self._call("hover_element", {"selector": selector}) | ||
| 120 | + | ||
| 121 | + def select_all_text(self, selector: str) -> None: | ||
| 122 | + self._call("select_all_text", {"selector": selector}) | ||
| 123 | + | ||
| 124 | + # ─── 滚动 ──────────────────────────────────────────────────── | ||
| 125 | + | ||
| 126 | + def scroll_by(self, x: int, y: int) -> None: | ||
| 127 | + self._call("scroll_by", {"x": x, "y": y}) | ||
| 128 | + | ||
| 129 | + def scroll_to(self, x: int, y: int) -> None: | ||
| 130 | + self._call("scroll_to", {"x": x, "y": y}) | ||
| 131 | + | ||
| 132 | + def scroll_to_bottom(self) -> None: | ||
| 133 | + self._call("scroll_to_bottom") | ||
| 134 | + | ||
| 135 | + def scroll_element_into_view(self, selector: str) -> None: | ||
| 136 | + self._call("scroll_element_into_view", {"selector": selector}) | ||
| 137 | + | ||
| 138 | + def scroll_nth_element_into_view(self, selector: str, index: int) -> None: | ||
| 139 | + self._call("scroll_nth_element_into_view", {"selector": selector, "index": index}) | ||
| 140 | + | ||
| 141 | + def get_scroll_top(self) -> int: | ||
| 142 | + result = self._call("get_scroll_top") | ||
| 143 | + return int(result) if result is not None else 0 | ||
| 144 | + | ||
| 145 | + def get_viewport_height(self) -> int: | ||
| 146 | + result = self._call("get_viewport_height") | ||
| 147 | + return int(result) if result is not None else 768 | ||
| 148 | + | ||
| 149 | + # ─── 输入事件 ──────────────────────────────────────────────── | ||
| 150 | + | ||
| 151 | + def press_key(self, key: str) -> None: | ||
| 152 | + self._call("press_key", {"key": key}) | ||
| 153 | + | ||
| 154 | + def type_text(self, text: str, delay_ms: int = 50) -> None: | ||
| 155 | + self._call("type_text", {"text": text, "delayMs": delay_ms}) | ||
| 156 | + | ||
| 157 | + def mouse_move(self, x: float, y: float) -> None: | ||
| 158 | + self._call("mouse_move", {"x": x, "y": y}) | ||
| 159 | + | ||
| 160 | + def mouse_click(self, x: float, y: float, button: str = "left") -> None: | ||
| 161 | + self._call("mouse_click", {"x": x, "y": y, "button": button}) | ||
| 162 | + | ||
| 163 | + def dispatch_wheel_event(self, delta_y: float) -> None: | ||
| 164 | + self._call("dispatch_wheel_event", {"deltaY": delta_y}) | ||
| 165 | + | ||
| 166 | + # ─── 文件上传 ──────────────────────────────────────────────── | ||
| 167 | + | ||
| 168 | + def set_file_input(self, selector: str, files: list[str]) -> None: | ||
| 169 | + """通过 chrome.debugger + DOM.setFileInputFiles 上传本地文件。 | ||
| 170 | + 传递绝对路径给扩展,由扩展调用 CDP 完成上传(与原 CDP 方式等价)。 | ||
| 171 | + """ | ||
| 172 | + # 统一转换为绝对路径(兼容 Windows 反斜杠) | ||
| 173 | + abs_paths = [os.path.abspath(path) for path in files] | ||
| 174 | + self._call("set_file_input", {"selector": selector, "files": abs_paths}) | ||
| 175 | + | ||
| 176 | + # ─── 截图 ──────────────────────────────────────────────────── | ||
| 177 | + | ||
| 178 | + def screenshot_element(self, selector: str, padding: int = 0) -> bytes: | ||
| 179 | + result = self._call("screenshot_element", {"selector": selector, "padding": padding}) | ||
| 180 | + if result and result.get("data"): | ||
| 181 | + return base64.b64decode(result["data"]) | ||
| 182 | + return b"" | ||
| 183 | + | ||
| 184 | + # ─── 无操作(原 CDP 专有功能,扩展模式不需要) ───────────────── | ||
| 185 | + | ||
| 186 | + def inject_stealth(self) -> None: | ||
| 187 | + """不需要注入 stealth 脚本——直接使用用户浏览器,无需伪装。""" | ||
| 188 | + | ||
| 189 | + # ─── 兼容性辅助方法 ────────────────────────────────────────── | ||
| 190 | + | ||
| 191 | + def is_server_running(self) -> bool: | ||
| 192 | + """检查 bridge server 是否在运行(不需要 extension 已连接)。""" | ||
| 193 | + try: | ||
| 194 | + with ws_client.connect(self._bridge_url, open_timeout=3) as ws: | ||
| 195 | + ws.send(json.dumps({"role": "cli", "method": "ping_server"})) | ||
| 196 | + raw = ws.recv(timeout=5) | ||
| 197 | + resp = json.loads(raw) | ||
| 198 | + return "result" in resp | ||
| 199 | + except Exception: | ||
| 200 | + return False | ||
| 201 | + | ||
| 202 | + def is_extension_connected(self) -> bool: | ||
| 203 | + """检查浏览器扩展是否已连接到 bridge server。""" | ||
| 204 | + try: | ||
| 205 | + with ws_client.connect(self._bridge_url, open_timeout=3) as ws: | ||
| 206 | + ws.send(json.dumps({"role": "cli", "method": "ping_server"})) | ||
| 207 | + raw = ws.recv(timeout=5) | ||
| 208 | + resp = json.loads(raw) | ||
| 209 | + return bool(resp.get("result", {}).get("extension_connected")) | ||
| 210 | + except Exception: | ||
| 211 | + return False | ||
| 212 | + | ||
| 213 | + @property | ||
| 214 | + def target_id(self) -> str: | ||
| 215 | + """兼容旧代码对 page.target_id 的引用。""" | ||
| 216 | + return "extension-bridge" |
| @@ -15,7 +15,6 @@ import requests | @@ -15,7 +15,6 @@ import requests | ||
| 15 | import websockets.sync.client as ws_client | 15 | import websockets.sync.client as ws_client |
| 16 | 16 | ||
| 17 | from .errors import CDPError, ElementNotFoundError | 17 | from .errors import CDPError, ElementNotFoundError |
| 18 | -from .stealth import STEALTH_JS, build_ua_override | ||
| 19 | 18 | ||
| 20 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 21 | 20 | ||
| @@ -468,13 +467,6 @@ class Page: | @@ -468,13 +467,6 @@ class Page: | ||
| 468 | {"type": "keyUp", **info}, | 467 | {"type": "keyUp", **info}, |
| 469 | ) | 468 | ) |
| 470 | 469 | ||
| 471 | - def inject_stealth(self) -> None: | ||
| 472 | - """注入反检测脚本。""" | ||
| 473 | - self._send_session( | ||
| 474 | - "Page.addScriptToEvaluateOnNewDocument", | ||
| 475 | - {"source": STEALTH_JS}, | ||
| 476 | - ) | ||
| 477 | - | ||
| 478 | def remove_element(self, selector: str) -> None: | 470 | def remove_element(self, selector: str) -> None: |
| 479 | """移除 DOM 元素。""" | 471 | """移除 DOM 元素。""" |
| 480 | self.evaluate( | 472 | self.evaluate( |
| @@ -589,30 +581,7 @@ class Browser: | @@ -589,30 +581,7 @@ class Browser: | ||
| 589 | self._cdp = CDPClient(ws_url) | 581 | self._cdp = CDPClient(ws_url) |
| 590 | 582 | ||
| 591 | def _setup_page(self, page: Page) -> Page: | 583 | def _setup_page(self, page: Page) -> Page: |
| 592 | - """为 Page 对象注入 stealth、UA、viewport,并启用必要的 CDP domain。""" | ||
| 593 | - import contextlib | ||
| 594 | - | ||
| 595 | - page.inject_stealth() | ||
| 596 | - page._send_session( | ||
| 597 | - "Emulation.setUserAgentOverride", | ||
| 598 | - build_ua_override(self._chrome_version), | ||
| 599 | - ) | ||
| 600 | - page._send_session( | ||
| 601 | - "Emulation.setDeviceMetricsOverride", | ||
| 602 | - { | ||
| 603 | - "width": random.randint(1366, 1920), | ||
| 604 | - "height": random.randint(768, 1080), | ||
| 605 | - "deviceScaleFactor": 1, | ||
| 606 | - "mobile": False, | ||
| 607 | - }, | ||
| 608 | - ) | ||
| 609 | - for perm in ("geolocation", "notifications", "midi", "camera", "microphone"): | ||
| 610 | - with contextlib.suppress(CDPError): | ||
| 611 | - assert self._cdp is not None | ||
| 612 | - self._cdp.send( | ||
| 613 | - "Browser.setPermission", | ||
| 614 | - {"permission": {"name": perm}, "setting": "denied"}, | ||
| 615 | - ) | 584 | + """为 Page 对象启用必要的 CDP domain。""" |
| 616 | page._send_session("Page.enable") | 585 | page._send_session("Page.enable") |
| 617 | page._send_session("DOM.enable") | 586 | page._send_session("DOM.enable") |
| 618 | page._send_session("Runtime.enable") | 587 | page._send_session("Runtime.enable") |
| @@ -686,7 +655,6 @@ class Browser: | @@ -686,7 +655,6 @@ class Browser: | ||
| 686 | page._send_session("Page.enable") | 655 | page._send_session("Page.enable") |
| 687 | page._send_session("DOM.enable") | 656 | page._send_session("DOM.enable") |
| 688 | page._send_session("Runtime.enable") | 657 | page._send_session("Runtime.enable") |
| 689 | - page.inject_stealth() | ||
| 690 | return page | 658 | return page |
| 691 | 659 | ||
| 692 | def get_existing_page(self) -> Page | None: | 660 | def get_existing_page(self) -> Page | None: |
| @@ -710,7 +678,6 @@ class Browser: | @@ -710,7 +678,6 @@ class Browser: | ||
| 710 | page._send_session("Page.enable") | 678 | page._send_session("Page.enable") |
| 711 | page._send_session("DOM.enable") | 679 | page._send_session("DOM.enable") |
| 712 | page._send_session("Runtime.enable") | 680 | page._send_session("Runtime.enable") |
| 713 | - page.inject_stealth() | ||
| 714 | return page | 681 | return page |
| 715 | return None | 682 | return None |
| 716 | 683 |
| @@ -110,13 +110,31 @@ def fill_publish_form(page: Page, content: PublishImageContent) -> None: | @@ -110,13 +110,31 @@ def fill_publish_form(page: Page, content: PublishImageContent) -> None: | ||
| 110 | def click_publish_button(page: Page) -> None: | 110 | def click_publish_button(page: Page) -> None: |
| 111 | """点击发布按钮。 | 111 | """点击发布按钮。 |
| 112 | 112 | ||
| 113 | - Args: | ||
| 114 | - page: CDP 页面对象。 | 113 | + 用文本内容精确匹配,避免点到旁边的"发布笔记"下拉按钮。 |
| 115 | 114 | ||
| 116 | Raises: | 115 | Raises: |
| 117 | PublishError: 点击失败。 | 116 | PublishError: 点击失败。 |
| 118 | """ | 117 | """ |
| 119 | - page.click_element(PUBLISH_BUTTON) | 118 | + clicked = page.evaluate( |
| 119 | + """ | ||
| 120 | + (() => { | ||
| 121 | + // 找文本内容精确为"发布"的 bg-red 按钮(排除"发布笔记"等) | ||
| 122 | + const btns = document.querySelectorAll('button.bg-red'); | ||
| 123 | + for (const btn of btns) { | ||
| 124 | + const span = btn.querySelector('span'); | ||
| 125 | + const text = (span ? span.textContent : btn.textContent).trim(); | ||
| 126 | + if (text === '发布') { | ||
| 127 | + btn.scrollIntoView({block: 'center'}); | ||
| 128 | + btn.click(); | ||
| 129 | + return true; | ||
| 130 | + } | ||
| 131 | + } | ||
| 132 | + return false; | ||
| 133 | + })() | ||
| 134 | + """ | ||
| 135 | + ) | ||
| 136 | + if not clicked: | ||
| 137 | + raise PublishError("未找到发布按钮") | ||
| 120 | time.sleep(3) | 138 | time.sleep(3) |
| 121 | logger.info("发布完成") | 139 | logger.info("发布完成") |
| 122 | 140 | ||
| @@ -428,24 +446,60 @@ def _input_tags(page: Page, content_selector: str, tags: list[str]) -> None: | @@ -428,24 +446,60 @@ def _input_tags(page: Page, content_selector: str, tags: list[str]) -> None: | ||
| 428 | """输入标签。""" | 446 | """输入标签。""" |
| 429 | time.sleep(1) | 447 | time.sleep(1) |
| 430 | 448 | ||
| 431 | - # 先点击正文编辑器,确保焦点在正文而非标题 | ||
| 432 | - page.click_element(content_selector) | ||
| 433 | - time.sleep(0.3) | ||
| 434 | - | ||
| 435 | - # 移动光标到正文末尾(20次 ArrowDown) | ||
| 436 | - for _ in range(20): | ||
| 437 | - page.press_key("ArrowDown") | ||
| 438 | - time.sleep(0.01) | 449 | + # 先记录当前段落数(insertParagraph 之前),之后用于精确定位正文最后一段 |
| 450 | + # 注意:必须在 insertParagraph 之前记录,否则 para_count_before 会包含新增的 tags 行 | ||
| 451 | + para_count_before = int(page.evaluate( | ||
| 452 | + f'document.querySelector("{content_selector}").querySelectorAll("p").length' | ||
| 453 | + ) or 1) | ||
| 439 | 454 | ||
| 440 | - # 按两次回车换行 | ||
| 441 | - page.press_key("Enter") | ||
| 442 | - page.press_key("Enter") | ||
| 443 | - time.sleep(1) | 455 | + # 用 evaluate 直接 focus 编辑器、光标移到末尾并换行一次 |
| 456 | + # 避免 click_element 因 isTrusted=false 无法真正 focus Quill 编辑器的问题 | ||
| 457 | + page.evaluate( | ||
| 458 | + f""" | ||
| 459 | + (() => {{ | ||
| 460 | + const el = document.querySelector("{content_selector}"); | ||
| 461 | + if (!el) return; | ||
| 462 | + el.focus(); | ||
| 463 | + const range = document.createRange(); | ||
| 464 | + range.selectNodeContents(el); | ||
| 465 | + range.collapse(false); | ||
| 466 | + const sel = window.getSelection(); | ||
| 467 | + sel.removeAllRanges(); | ||
| 468 | + sel.addRange(range); | ||
| 469 | + document.execCommand("insertParagraph", false, null); | ||
| 470 | + }})() | ||
| 471 | + """ | ||
| 472 | + ) | ||
| 473 | + time.sleep(0.5) | ||
| 444 | 474 | ||
| 445 | for tag in tags: | 475 | for tag in tags: |
| 446 | tag = tag.lstrip("#") | 476 | tag = tag.lstrip("#") |
| 447 | _input_single_tag(page, content_selector, tag) | 477 | _input_single_tag(page, content_selector, tag) |
| 448 | 478 | ||
| 479 | + # 输入完所有 tags 后,回到正文最后一段(tags 输入前的最后一段)末尾,按下回车 | ||
| 480 | + # 用 para_count_before 精确定位,避免 tags 输入后 Quill 自动新增空段导致偏移 | ||
| 481 | + page.evaluate( | ||
| 482 | + f""" | ||
| 483 | + (() => {{ | ||
| 484 | + const el = document.querySelector("{content_selector}"); | ||
| 485 | + if (!el) return; | ||
| 486 | + const paras = el.querySelectorAll("p"); | ||
| 487 | + // tags 输入前最后一段的索引 = para_count_before - 1 | ||
| 488 | + const lastContent = paras[{para_count_before} - 1]; | ||
| 489 | + if (!lastContent) return; | ||
| 490 | + el.focus(); | ||
| 491 | + const range = document.createRange(); | ||
| 492 | + range.selectNodeContents(lastContent); | ||
| 493 | + range.collapse(false); | ||
| 494 | + const sel = window.getSelection(); | ||
| 495 | + sel.removeAllRanges(); | ||
| 496 | + sel.addRange(range); | ||
| 497 | + document.execCommand("insertParagraph", false, null); | ||
| 498 | + }})() | ||
| 499 | + """ | ||
| 500 | + ) | ||
| 501 | + time.sleep(0.3) | ||
| 502 | + | ||
| 449 | 503 | ||
| 450 | def _input_single_tag(page: Page, content_selector: str, tag: str) -> None: | 504 | def _input_single_tag(page: Page, content_selector: str, tag: str) -> None: |
| 451 | """输入单个标签。""" | 505 | """输入单个标签。""" |
scripts/xhs/stealth.py
deleted
100644 → 0
| 1 | -"""反检测配置:UA / Client Hints / JS 注入 / Chrome 启动参数。 | ||
| 2 | - | ||
| 3 | -关键原则:UA、navigator.platform、Client Hints、WebGL 等所有信号必须与实际平台一致。 | ||
| 4 | -""" | ||
| 5 | - | ||
| 6 | -from __future__ import annotations | ||
| 7 | - | ||
| 8 | -import platform as _platform | ||
| 9 | - | ||
| 10 | -# Chrome 版本号 — 定期更新以匹配主流版本(当前对应 2025 年中期稳定版) | ||
| 11 | -_CHROME_VER = "136" | ||
| 12 | -_CHROME_FULL_VER = "136.0.0.0" | ||
| 13 | - | ||
| 14 | - | ||
| 15 | -def _build_platform_config() -> dict: | ||
| 16 | - """根据实际操作系统生成一致的 UA / Client Hints / WebGL 配置。""" | ||
| 17 | - system = _platform.system() | ||
| 18 | - | ||
| 19 | - brands = [ | ||
| 20 | - {"brand": "Chromium", "version": _CHROME_VER}, | ||
| 21 | - {"brand": "Google Chrome", "version": _CHROME_VER}, | ||
| 22 | - {"brand": "Not-A.Brand", "version": "24"}, | ||
| 23 | - ] | ||
| 24 | - full_version_list = [ | ||
| 25 | - {"brand": "Chromium", "version": _CHROME_FULL_VER}, | ||
| 26 | - {"brand": "Google Chrome", "version": _CHROME_FULL_VER}, | ||
| 27 | - {"brand": "Not-A.Brand", "version": "24.0.0.0"}, | ||
| 28 | - ] | ||
| 29 | - | ||
| 30 | - if system == "Darwin": | ||
| 31 | - arch = "arm" if _platform.machine() == "arm64" else "x86" | ||
| 32 | - return { | ||
| 33 | - "ua": ( | ||
| 34 | - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " | ||
| 35 | - "AppleWebKit/537.36 (KHTML, like Gecko) " | ||
| 36 | - f"Chrome/{_CHROME_FULL_VER} Safari/537.36" | ||
| 37 | - ), | ||
| 38 | - "nav_platform": "MacIntel", | ||
| 39 | - "ua_metadata": { | ||
| 40 | - "brands": brands, | ||
| 41 | - "fullVersionList": full_version_list, | ||
| 42 | - "platform": "macOS", | ||
| 43 | - "platformVersion": "14.5.0", | ||
| 44 | - "architecture": arch, | ||
| 45 | - "model": "", | ||
| 46 | - "mobile": False, | ||
| 47 | - "bitness": "64", | ||
| 48 | - "wow64": False, | ||
| 49 | - }, | ||
| 50 | - "webgl_vendor": "Apple Inc.", | ||
| 51 | - "webgl_renderer": ( | ||
| 52 | - "ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Unspecified Version)" | ||
| 53 | - ), | ||
| 54 | - } | ||
| 55 | - | ||
| 56 | - if system == "Windows": | ||
| 57 | - return { | ||
| 58 | - "ua": ( | ||
| 59 | - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " | ||
| 60 | - "AppleWebKit/537.36 (KHTML, like Gecko) " | ||
| 61 | - f"Chrome/{_CHROME_FULL_VER} Safari/537.36" | ||
| 62 | - ), | ||
| 63 | - "nav_platform": "Win32", | ||
| 64 | - "ua_metadata": { | ||
| 65 | - "brands": brands, | ||
| 66 | - "fullVersionList": full_version_list, | ||
| 67 | - "platform": "Windows", | ||
| 68 | - "platformVersion": "15.0.0", | ||
| 69 | - "architecture": "x86", | ||
| 70 | - "model": "", | ||
| 71 | - "mobile": False, | ||
| 72 | - "bitness": "64", | ||
| 73 | - "wow64": False, | ||
| 74 | - }, | ||
| 75 | - "webgl_vendor": "Google Inc. (Intel)", | ||
| 76 | - "webgl_renderer": ( | ||
| 77 | - "ANGLE (Intel, Intel(R) UHD Graphics 630 (CML GT2), Direct3D11)" | ||
| 78 | - ), | ||
| 79 | - } | ||
| 80 | - | ||
| 81 | - # Linux | ||
| 82 | - return { | ||
| 83 | - "ua": ( | ||
| 84 | - "Mozilla/5.0 (X11; Linux x86_64) " | ||
| 85 | - "AppleWebKit/537.36 (KHTML, like Gecko) " | ||
| 86 | - f"Chrome/{_CHROME_FULL_VER} Safari/537.36" | ||
| 87 | - ), | ||
| 88 | - "nav_platform": "Linux x86_64", | ||
| 89 | - "ua_metadata": { | ||
| 90 | - "brands": brands, | ||
| 91 | - "fullVersionList": full_version_list, | ||
| 92 | - "platform": "Linux", | ||
| 93 | - "platformVersion": "6.5.0", | ||
| 94 | - "architecture": "x86", | ||
| 95 | - "model": "", | ||
| 96 | - "mobile": False, | ||
| 97 | - "bitness": "64", | ||
| 98 | - "wow64": False, | ||
| 99 | - }, | ||
| 100 | - "webgl_vendor": "Google Inc. (Mesa)", | ||
| 101 | - "webgl_renderer": ( | ||
| 102 | - "ANGLE (Mesa, Mesa Intel(R) UHD Graphics 630 (CML GT2), OpenGL 4.6)" | ||
| 103 | - ), | ||
| 104 | - } | ||
| 105 | - | ||
| 106 | - | ||
| 107 | -PLATFORM_CONFIG = _build_platform_config() | ||
| 108 | - | ||
| 109 | -# 向后兼容导出 | ||
| 110 | -REALISTIC_UA = PLATFORM_CONFIG["ua"] | ||
| 111 | - | ||
| 112 | - | ||
| 113 | -def build_ua_override(chrome_full_ver: str | None = None) -> dict: | ||
| 114 | - """构建 Emulation.setUserAgentOverride 参数。 | ||
| 115 | - | ||
| 116 | - Args: | ||
| 117 | - chrome_full_ver: Chrome 完整版本号(如 "134.0.6998.88"), | ||
| 118 | - 从 CDP /json/version 接口获取。为 None 时使用默认值。 | ||
| 119 | - | ||
| 120 | - Returns: | ||
| 121 | - 可直接传给 Emulation.setUserAgentOverride 的参数字典。 | ||
| 122 | - """ | ||
| 123 | - ver = chrome_full_ver or _CHROME_FULL_VER | ||
| 124 | - major = ver.split(".")[0] | ||
| 125 | - system = _platform.system() | ||
| 126 | - | ||
| 127 | - brands = [ | ||
| 128 | - {"brand": "Chromium", "version": major}, | ||
| 129 | - {"brand": "Google Chrome", "version": major}, | ||
| 130 | - {"brand": "Not-A.Brand", "version": "24"}, | ||
| 131 | - ] | ||
| 132 | - full_version_list = [ | ||
| 133 | - {"brand": "Chromium", "version": ver}, | ||
| 134 | - {"brand": "Google Chrome", "version": ver}, | ||
| 135 | - {"brand": "Not-A.Brand", "version": "24.0.0.0"}, | ||
| 136 | - ] | ||
| 137 | - | ||
| 138 | - if system == "Darwin": | ||
| 139 | - arch = "arm" if _platform.machine() == "arm64" else "x86" | ||
| 140 | - ua = ( | ||
| 141 | - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " | ||
| 142 | - "AppleWebKit/537.36 (KHTML, like Gecko) " | ||
| 143 | - f"Chrome/{ver} Safari/537.36" | ||
| 144 | - ) | ||
| 145 | - nav_platform = "MacIntel" | ||
| 146 | - ua_platform = "macOS" | ||
| 147 | - platform_ver = "14.5.0" | ||
| 148 | - elif system == "Windows": | ||
| 149 | - arch = "x86" | ||
| 150 | - ua = ( | ||
| 151 | - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " | ||
| 152 | - "AppleWebKit/537.36 (KHTML, like Gecko) " | ||
| 153 | - f"Chrome/{ver} Safari/537.36" | ||
| 154 | - ) | ||
| 155 | - nav_platform = "Win32" | ||
| 156 | - ua_platform = "Windows" | ||
| 157 | - platform_ver = "15.0.0" | ||
| 158 | - else: | ||
| 159 | - arch = "x86" | ||
| 160 | - ua = ( | ||
| 161 | - "Mozilla/5.0 (X11; Linux x86_64) " | ||
| 162 | - "AppleWebKit/537.36 (KHTML, like Gecko) " | ||
| 163 | - f"Chrome/{ver} Safari/537.36" | ||
| 164 | - ) | ||
| 165 | - nav_platform = "Linux x86_64" | ||
| 166 | - ua_platform = "Linux" | ||
| 167 | - platform_ver = "6.5.0" | ||
| 168 | - | ||
| 169 | - return { | ||
| 170 | - "userAgent": ua, | ||
| 171 | - "platform": nav_platform, | ||
| 172 | - "userAgentMetadata": { | ||
| 173 | - "brands": brands, | ||
| 174 | - "fullVersionList": full_version_list, | ||
| 175 | - "platform": ua_platform, | ||
| 176 | - "platformVersion": platform_ver, | ||
| 177 | - "architecture": arch, | ||
| 178 | - "model": "", | ||
| 179 | - "mobile": False, | ||
| 180 | - "bitness": "64", | ||
| 181 | - "wow64": False, | ||
| 182 | - }, | ||
| 183 | - } | ||
| 184 | - | ||
| 185 | -# --------------------------------------------------------------------------- | ||
| 186 | -# 反检测 JS 脚本模板($$占位符$$ 由 Python 替换为平台值) | ||
| 187 | -# --------------------------------------------------------------------------- | ||
| 188 | -_STEALTH_JS_TEMPLATE = """ | ||
| 189 | -(() => { | ||
| 190 | - // 1. navigator.webdriver — Proxy 包装原始 native getter,toString() 仍返回 [native code] | ||
| 191 | - const wd = Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver'); | ||
| 192 | - if (wd && wd.get) { | ||
| 193 | - Object.defineProperty(Navigator.prototype, 'webdriver', { | ||
| 194 | - get: new Proxy(wd.get, { apply: () => false }), | ||
| 195 | - configurable: true, | ||
| 196 | - }); | ||
| 197 | - } | ||
| 198 | - | ||
| 199 | - // 2. chrome.runtime | ||
| 200 | - if (!window.chrome) window.chrome = {}; | ||
| 201 | - if (!window.chrome.runtime) { | ||
| 202 | - window.chrome.runtime = { connect: () => {}, sendMessage: () => {} }; | ||
| 203 | - } | ||
| 204 | - | ||
| 205 | - // 3. chrome.app — headless 缺失此对象,检测脚本会检查 | ||
| 206 | - if (!window.chrome.app) { | ||
| 207 | - window.chrome.app = { | ||
| 208 | - isInstalled: false, | ||
| 209 | - InstallState: { | ||
| 210 | - DISABLED: 'disabled', | ||
| 211 | - INSTALLED: 'installed', | ||
| 212 | - NOT_INSTALLED: 'not_installed', | ||
| 213 | - }, | ||
| 214 | - RunningState: { | ||
| 215 | - CANNOT_RUN: 'cannot_run', | ||
| 216 | - READY_TO_RUN: 'ready_to_run', | ||
| 217 | - RUNNING: 'running', | ||
| 218 | - }, | ||
| 219 | - getDetails: function() {}, | ||
| 220 | - getIsInstalled: function() {}, | ||
| 221 | - installState: function() { return 'not_installed'; }, | ||
| 222 | - runningState: function() { return 'cannot_run'; }, | ||
| 223 | - }; | ||
| 224 | - } | ||
| 225 | - | ||
| 226 | - // 4. navigator.vendor — Chrome 应返回 "Google Inc." | ||
| 227 | - Object.defineProperty(navigator, 'vendor', { | ||
| 228 | - get: () => 'Google Inc.', | ||
| 229 | - configurable: true, | ||
| 230 | - }); | ||
| 231 | - | ||
| 232 | - // 5. plugins — 不覆盖,真实 Chrome 已有正确的 PluginArray | ||
| 233 | - | ||
| 234 | - // 4. languages | ||
| 235 | - Object.defineProperty(navigator, 'languages', { | ||
| 236 | - get: () => ['zh-CN', 'zh', 'en-US', 'en'], | ||
| 237 | - configurable: true, | ||
| 238 | - }); | ||
| 239 | - | ||
| 240 | - // 5. permissions | ||
| 241 | - const originalQuery = window.navigator.permissions?.query; | ||
| 242 | - if (originalQuery) { | ||
| 243 | - window.navigator.permissions.query = (parameters) => | ||
| 244 | - parameters.name === 'notifications' | ||
| 245 | - ? Promise.resolve({ state: Notification.permission }) | ||
| 246 | - : originalQuery(parameters); | ||
| 247 | - } | ||
| 248 | - | ||
| 249 | - // 6. WebGL vendor/renderer — 与平台一致(同时覆盖 WebGL1 和 WebGL2) | ||
| 250 | - const overrideWebGL = (proto) => { | ||
| 251 | - const original = proto.getParameter; | ||
| 252 | - proto.getParameter = function(p) { | ||
| 253 | - if (p === 37445) return '$$WEBGL_VENDOR$$'; | ||
| 254 | - if (p === 37446) return '$$WEBGL_RENDERER$$'; | ||
| 255 | - return original.call(this, p); | ||
| 256 | - }; | ||
| 257 | - }; | ||
| 258 | - overrideWebGL(WebGLRenderingContext.prototype); | ||
| 259 | - if (typeof WebGL2RenderingContext !== 'undefined') { | ||
| 260 | - overrideWebGL(WebGL2RenderingContext.prototype); | ||
| 261 | - } | ||
| 262 | - | ||
| 263 | - // 7. hardwareConcurrency — 随机 4 或 8 | ||
| 264 | - Object.defineProperty(navigator, 'hardwareConcurrency', { | ||
| 265 | - get: () => [4, 8][Math.floor(Math.random() * 2)], | ||
| 266 | - configurable: true, | ||
| 267 | - }); | ||
| 268 | - | ||
| 269 | - // 8. deviceMemory — 随机 4 或 8 | ||
| 270 | - Object.defineProperty(navigator, 'deviceMemory', { | ||
| 271 | - get: () => [4, 8][Math.floor(Math.random() * 2)], | ||
| 272 | - configurable: true, | ||
| 273 | - }); | ||
| 274 | - | ||
| 275 | - // 9. navigator.connection — 伪造网络信息 | ||
| 276 | - Object.defineProperty(navigator, 'connection', { | ||
| 277 | - get: () => ({ | ||
| 278 | - effectiveType: '4g', | ||
| 279 | - downlink: 10, | ||
| 280 | - rtt: 50, | ||
| 281 | - saveData: false, | ||
| 282 | - }), | ||
| 283 | - configurable: true, | ||
| 284 | - }); | ||
| 285 | - | ||
| 286 | - // 10. chrome.csi / chrome.loadTimes — 空函数伪装 | ||
| 287 | - if (window.chrome) { | ||
| 288 | - window.chrome.csi = function() { return {}; }; | ||
| 289 | - window.chrome.loadTimes = function() { return {}; }; | ||
| 290 | - } | ||
| 291 | - | ||
| 292 | - // 11. outerWidth/outerHeight — 不覆盖 | ||
| 293 | - // 正常浏览器 outer > inner(有标题栏/工具栏),设为相等反而暴露自动化特征 | ||
| 294 | - | ||
| 295 | -})(); | ||
| 296 | -""" | ||
| 297 | - | ||
| 298 | -STEALTH_JS = ( | ||
| 299 | - _STEALTH_JS_TEMPLATE | ||
| 300 | - .replace("$$WEBGL_VENDOR$$", PLATFORM_CONFIG["webgl_vendor"]) | ||
| 301 | - .replace("$$WEBGL_RENDERER$$", PLATFORM_CONFIG["webgl_renderer"]) | ||
| 302 | -) | ||
| 303 | - | ||
| 304 | -# Chrome 启动参数(反检测相关) | ||
| 305 | -STEALTH_ARGS = [ | ||
| 306 | - "--disable-blink-features=AutomationControlled", | ||
| 307 | - "--disable-infobars", | ||
| 308 | - "--no-first-run", | ||
| 309 | - "--no-default-browser-check", | ||
| 310 | - "--disable-background-timer-throttling", | ||
| 311 | - "--disable-backgrounding-occluded-windows", | ||
| 312 | - "--disable-renderer-backgrounding", | ||
| 313 | - "--disable-component-update", | ||
| 314 | - "--disable-extensions", | ||
| 315 | - "--disable-sync", | ||
| 316 | -] |
| 1 | --- | 1 | --- |
| 2 | name: xhs-auth | 2 | name: xhs-auth |
| 3 | description: | | 3 | description: | |
| 4 | - 小红书认证管理技能。检查登录状态、登录(二维码或手机号)、多账号管理。 | ||
| 5 | - 当用户要求登录小红书、检查登录状态、切换账号时触发。 | ||
| 6 | -version: 1.0.0 | 4 | + 小红书认证管理技能。检查登录状态、登录(二维码或手机号)、退出登录。 |
| 5 | + 当用户要求登录小红书、检查登录状态、退出登录时触发。 | ||
| 6 | +version: 2.0.0 | ||
| 7 | metadata: | 7 | metadata: |
| 8 | openclaw: | 8 | openclaw: |
| 9 | requires: | 9 | requires: |
| @@ -14,11 +14,12 @@ metadata: | @@ -14,11 +14,12 @@ metadata: | ||
| 14 | os: | 14 | os: |
| 15 | - darwin | 15 | - darwin |
| 16 | - linux | 16 | - linux |
| 17 | + - windows | ||
| 17 | --- | 18 | --- |
| 18 | 19 | ||
| 19 | # 小红书认证管理 | 20 | # 小红书认证管理 |
| 20 | 21 | ||
| 21 | -你是"小红书认证助手"。负责管理小红书登录状态和多账号切换。 | 22 | +你是"小红书认证助手"。负责管理小红书登录状态。 |
| 22 | 23 | ||
| 23 | ## 🔒 技能边界(强制) | 24 | ## 🔒 技能边界(强制) |
| 24 | 25 | ||
| @@ -39,29 +40,6 @@ metadata: | @@ -39,29 +40,6 @@ metadata: | ||
| 39 | | `send-code --phone` | 发送手机验证码 | | 40 | | `send-code --phone` | 发送手机验证码 | |
| 40 | | `verify-code --code` | 提交验证码完成登录 | | 41 | | `verify-code --code` | 提交验证码完成登录 | |
| 41 | | `delete-cookies` | 退出登录并清除 cookies | | 42 | | `delete-cookies` | 退出登录并清除 cookies | |
| 42 | -| `add-account --name` | 添加命名账号(自动分配端口) | | ||
| 43 | -| `list-accounts` | 列出所有命名账号及端口 | | ||
| 44 | -| `remove-account --name` | 删除命名账号 | | ||
| 45 | -| `set-default-account --name` | 设置默认账号 | | ||
| 46 | - | ||
| 47 | ---- | ||
| 48 | - | ||
| 49 | -## 账号选择(前置步骤) | ||
| 50 | - | ||
| 51 | -> **例外**:用户要求"添加账号 / 列出账号 / 删除账号 / 设置默认账号"时,**跳过此步骤**,直接执行对应管理命令。 | ||
| 52 | - | ||
| 53 | -其余操作(检查登录、登录、退出登录)先运行: | ||
| 54 | - | ||
| 55 | -```bash | ||
| 56 | -python scripts/cli.py list-accounts | ||
| 57 | -``` | ||
| 58 | - | ||
| 59 | -根据返回的 `count`: | ||
| 60 | -- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。 | ||
| 61 | -- **1 个命名账号**:告知用户"将对账号 X 执行操作",直接加 `--account <名称>` 执行。 | ||
| 62 | -- **多个命名账号**:向用户展示列表,询问操作哪个账号,用 `--account <选择的名称>` 执行后续命令。 | ||
| 63 | - | ||
| 64 | -账号选定后,本次操作全程固定该账号,**不重复询问**。 | ||
| 65 | 43 | ||
| 66 | --- | 44 | --- |
| 67 | 45 | ||
| @@ -71,13 +49,13 @@ python scripts/cli.py list-accounts | @@ -71,13 +49,13 @@ python scripts/cli.py list-accounts | ||
| 71 | 49 | ||
| 72 | 1. 用户要求"检查登录 / 是否登录 / 登录状态":执行登录状态检查。 | 50 | 1. 用户要求"检查登录 / 是否登录 / 登录状态":执行登录状态检查。 |
| 73 | 2. 用户要求"登录 / 扫码登录 / 手机登录 / 打开登录页":执行登录流程。 | 51 | 2. 用户要求"登录 / 扫码登录 / 手机登录 / 打开登录页":执行登录流程。 |
| 74 | -3. 用户要求"切换账号 / 换一个账号 / 退出登录 / 清除登录":执行 `delete-cookies`(内部自动先 UI 退出登录,再清除本地 cookies)。 | 52 | +3. 用户要求"退出登录 / 清除登录":执行 `delete-cookies`。 |
| 75 | 53 | ||
| 76 | ## 必做约束 | 54 | ## 必做约束 |
| 77 | 55 | ||
| 78 | - 所有 CLI 命令位于 `scripts/cli.py`,输出 JSON。 | 56 | - 所有 CLI 命令位于 `scripts/cli.py`,输出 JSON。 |
| 79 | -- 需要先有运行中的 Chrome(`ensure_chrome` 会自动启动)。 | ||
| 80 | - 如果使用文件路径,必须使用绝对路径。 | 57 | - 如果使用文件路径,必须使用绝对路径。 |
| 58 | +- **不要频繁重复登录或退出登录**,避免触发账号风控。 | ||
| 81 | 59 | ||
| 82 | ## 工作流程 | 60 | ## 工作流程 |
| 83 | 61 | ||
| @@ -111,7 +89,7 @@ python scripts/cli.py check-login | @@ -111,7 +89,7 @@ python scripts/cli.py check-login | ||
| 111 | 89 | ||
| 112 | > **展示规范(必须全部遵守)**: | 90 | > **展示规范(必须全部遵守)**: |
| 113 | > 1. 展示二维码图片(`qrcode_image_url`)。 | 91 | > 1. 展示二维码图片(`qrcode_image_url`)。 |
| 114 | -> 2. 如果输出含 `qr_login_url`,**必须**同时展示该链接并提示用户"也可以在手机浏览器中直接访问此链接完成登录"。此链接是小红书官方登录地址(`xiaohongshu.com` 域名),既方便用户直接点击,也增加对二维码的信任感。 | 92 | +> 2. 如果输出含 `qr_login_url`,**必须**同时展示该链接并提示用户"也可以在手机浏览器中直接访问此链接完成登录"。 |
| 115 | > 3. **禁止**省略 `qr_login_url`,即使已展示了二维码图片。 | 93 | > 3. **禁止**省略 `qr_login_url`,即使已展示了二维码图片。 |
| 116 | 94 | ||
| 117 | 图片内嵌在对话窗口,用户可以扫码或直接访问链接登录。 | 95 | 图片内嵌在对话窗口,用户可以扫码或直接访问链接登录。 |
| @@ -122,7 +100,7 @@ python scripts/cli.py check-login | @@ -122,7 +100,7 @@ python scripts/cli.py check-login | ||
| 122 | python scripts/cli.py wait-login | 100 | python scripts/cli.py wait-login |
| 123 | ``` | 101 | ``` |
| 124 | 102 | ||
| 125 | -- 连接已有 Chrome tab,内部阻塞等待(最多 120 秒)。 | 103 | +- 连接已有浏览器 tab,内部阻塞等待(最多 120 秒)。 |
| 126 | - 输出 `{"logged_in": true}` 则完成;超时则提示用户重新运行 `get-qrcode` 刷新二维码。 | 104 | - 输出 `{"logged_in": true}` 则完成;超时则提示用户重新运行 `get-qrcode` 刷新二维码。 |
| 127 | 105 | ||
| 128 | > **二维码过期刷新**:如需单独刷新二维码(如超时后),可运行 `get-qrcode`,它仍作为独立命令保留。 | 106 | > **二维码过期刷新**:如需单独刷新二维码(如超时后),可运行 `get-qrcode`,它仍作为独立命令保留。 |
| @@ -143,7 +121,6 @@ python scripts/cli.py wait-login | @@ -143,7 +121,6 @@ python scripts/cli.py wait-login | ||
| 143 | python scripts/cli.py send-code --phone <用户确认的手机号> | 121 | python scripts/cli.py send-code --phone <用户确认的手机号> |
| 144 | ``` | 122 | ``` |
| 145 | - 自动填写手机号、勾选用户协议、点击"获取验证码"。 | 123 | - 自动填写手机号、勾选用户协议、点击"获取验证码"。 |
| 146 | -- Chrome 页面保持打开,等待下一步。 | ||
| 147 | - 正常输出:`{"status": "code_sent", "message": "..."}` | 124 | - 正常输出:`{"status": "code_sent", "message": "..."}` |
| 148 | - **频率限制**:自动切换为二维码登录,输出含 `qrcode_image_url`。告知用户"验证码发送受限,已切换为二维码登录",按方式 A 的展示规范展示二维码,然后运行 `wait-login`。 | 125 | - **频率限制**:自动切换为二维码登录,输出含 `qrcode_image_url`。告知用户"验证码发送受限,已切换为二维码登录",按方式 A 的展示规范展示二维码,然后运行 `wait-login`。 |
| 149 | 126 | ||
| @@ -157,54 +134,18 @@ python scripts/cli.py verify-code --code <用户提供的6位验证码> | @@ -157,54 +134,18 @@ python scripts/cli.py verify-code --code <用户提供的6位验证码> | ||
| 157 | - 自动填写验证码、点击登录。 | 134 | - 自动填写验证码、点击登录。 |
| 158 | - 输出:`{"logged_in": true, "message": "登录成功"}` | 135 | - 输出:`{"logged_in": true, "message": "登录成功"}` |
| 159 | 136 | ||
| 160 | -### 清除 Cookies(切换账号/退出登录) | 137 | +### 清除 Cookies(退出登录) |
| 161 | 138 | ||
| 162 | > `delete-cookies` 命令内部自动完成两步:先通过页面 UI 点击「更多」→「退出登录」,再删除本地 cookies 文件。只需执行一条命令即可。 | 139 | > `delete-cookies` 命令内部自动完成两步:先通过页面 UI 点击「更多」→「退出登录」,再删除本地 cookies 文件。只需执行一条命令即可。 |
| 163 | 140 | ||
| 164 | ```bash | 141 | ```bash |
| 165 | python scripts/cli.py delete-cookies | 142 | python scripts/cli.py delete-cookies |
| 166 | -python scripts/cli.py --account work delete-cookies # 指定账号 | ||
| 167 | -``` | ||
| 168 | - | ||
| 169 | -## 多账号工作流 | ||
| 170 | - | ||
| 171 | -每个命名账号拥有独立端口(从 9223 起递增)和独立 Chrome Profile,账号之间完全隔离。 | ||
| 172 | - | ||
| 173 | -### 添加账号 | ||
| 174 | - | ||
| 175 | -```bash | ||
| 176 | -python scripts/cli.py add-account --name work --description "工作号" | ||
| 177 | -# 输出: {"success": true, "name": "work", "port": 9223, "profile_dir": "..."} | ||
| 178 | - | ||
| 179 | -python scripts/cli.py add-account --name personal | ||
| 180 | -# 输出: {"success": true, "name": "personal", "port": 9224, "profile_dir": "..."} | ||
| 181 | -``` | ||
| 182 | - | ||
| 183 | -### 使用指定账号执行操作 | ||
| 184 | - | ||
| 185 | -通过全局 `--account` 参数指定账号,CLI 自动切换到对应端口和 Chrome Profile: | ||
| 186 | - | ||
| 187 | -```bash | ||
| 188 | -python scripts/cli.py --account work check-login | ||
| 189 | -python scripts/cli.py --account work get-qrcode | ||
| 190 | -python scripts/cli.py --account personal check-login | ||
| 191 | -python scripts/cli.py check-login # 不指定账号,使用默认端口 9222 | ||
| 192 | -``` | ||
| 193 | - | ||
| 194 | -### 管理账号 | ||
| 195 | - | ||
| 196 | -```bash | ||
| 197 | -python scripts/cli.py list-accounts # 列出所有账号及端口 | ||
| 198 | -python scripts/cli.py set-default-account --name work # 设置默认账号 | ||
| 199 | -python scripts/cli.py remove-account --name personal # 删除账号 | ||
| 200 | ``` | 143 | ``` |
| 201 | 144 | ||
| 202 | --- | 145 | --- |
| 203 | 146 | ||
| 204 | ## 失败处理 | 147 | ## 失败处理 |
| 205 | 148 | ||
| 206 | -- **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。 | ||
| 207 | -- **登录弹窗未出现**:等待 15 秒超时,重试 `send-code`。 | ||
| 208 | - **验证码错误**:输出包含 `"logged_in": false`,重新运行 `verify-code --code <新验证码>`。 | 149 | - **验证码错误**:输出包含 `"logged_in": false`,重新运行 `verify-code --code <新验证码>`。 |
| 209 | - **二维码超时**:重新执行 `get-qrcode` 获取新二维码,再运行 `wait-login`。 | 150 | - **二维码超时**:重新执行 `get-qrcode` 获取新二维码,再运行 `wait-login`。 |
| 210 | -- **远程 CDP 连接失败**:检查 Chrome 是否已开启 `--remote-debugging-port`。 | 151 | +- **扩展未连接**:CLI 会自动打开 Chrome 并等待扩展连接,若超时提示用户检查 XHS Bridge 扩展是否已安装并启用。 |
| @@ -46,22 +46,6 @@ metadata: | @@ -46,22 +46,6 @@ metadata: | ||
| 46 | 46 | ||
| 47 | --- | 47 | --- |
| 48 | 48 | ||
| 49 | -## 账号选择(前置步骤) | ||
| 50 | - | ||
| 51 | -每次 skill 触发后,先运行: | ||
| 52 | - | ||
| 53 | -```bash | ||
| 54 | -python scripts/cli.py list-accounts | ||
| 55 | -``` | ||
| 56 | - | ||
| 57 | -根据返回的 `count`: | ||
| 58 | -- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。 | ||
| 59 | -- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。 | ||
| 60 | -- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。 | ||
| 61 | - | ||
| 62 | -账号选定后,本次操作全程固定该账号,**不重复询问**。 | ||
| 63 | - | ||
| 64 | ---- | ||
| 65 | 49 | ||
| 66 | ## 输入判断 | 50 | ## 输入判断 |
| 67 | 51 | ||
| @@ -77,7 +61,7 @@ python scripts/cli.py list-accounts | @@ -77,7 +61,7 @@ python scripts/cli.py list-accounts | ||
| 77 | - 复合流程中每一步都应向用户报告进度。 | 61 | - 复合流程中每一步都应向用户报告进度。 |
| 78 | - 发布类操作必须经过用户确认(参考 xhs-publish 约束)。 | 62 | - 发布类操作必须经过用户确认(参考 xhs-publish 约束)。 |
| 79 | - 评论类操作必须经过用户确认(参考 xhs-interact 约束)。 | 63 | - 评论类操作必须经过用户确认(参考 xhs-interact 约束)。 |
| 80 | -- 搜索和浏览操作之间保持合理间隔,避免频率过高。 | 64 | +- **控制整体频率**:即使使用真实账号和浏览器,频繁的自动化操作仍可能触发风控,建议分批、间隔执行,不要一次性处理大量任务。 |
| 81 | - 所有数据分析结果使用 markdown 表格结构化呈现。 | 65 | - 所有数据分析结果使用 markdown 表格结构化呈现。 |
| 82 | 66 | ||
| 83 | ## 工作流程 | 67 | ## 工作流程 |
| @@ -40,22 +40,6 @@ metadata: | @@ -40,22 +40,6 @@ metadata: | ||
| 40 | 40 | ||
| 41 | --- | 41 | --- |
| 42 | 42 | ||
| 43 | -## 账号选择(前置步骤) | ||
| 44 | - | ||
| 45 | -每次 skill 触发后,先运行: | ||
| 46 | - | ||
| 47 | -```bash | ||
| 48 | -python scripts/cli.py list-accounts | ||
| 49 | -``` | ||
| 50 | - | ||
| 51 | -根据返回的 `count`: | ||
| 52 | -- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。 | ||
| 53 | -- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。 | ||
| 54 | -- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。 | ||
| 55 | - | ||
| 56 | -账号选定后,本次操作全程固定该账号,**不重复询问**。 | ||
| 57 | - | ||
| 58 | ---- | ||
| 59 | 43 | ||
| 60 | ## 输入判断 | 44 | ## 输入判断 |
| 61 | 45 | ||
| @@ -68,6 +52,7 @@ python scripts/cli.py list-accounts | @@ -68,6 +52,7 @@ python scripts/cli.py list-accounts | ||
| 68 | 52 | ||
| 69 | ## 必做约束 | 53 | ## 必做约束 |
| 70 | 54 | ||
| 55 | +- **控制查询频率**:避免频繁、连续地搜索或加载大量内容,操作之间保持适当间隔。 | ||
| 71 | - 所有操作需要已登录的 Chrome 浏览器。 | 56 | - 所有操作需要已登录的 Chrome 浏览器。 |
| 72 | - `feed_id` 和 `xsec_token` 必须配对使用,从搜索结果或首页 Feed 中获取。 | 57 | - `feed_id` 和 `xsec_token` 必须配对使用,从搜索结果或首页 Feed 中获取。 |
| 73 | - 结果应结构化呈现,突出关键字段。 | 58 | - 结果应结构化呈现,突出关键字段。 |
| @@ -40,22 +40,6 @@ metadata: | @@ -40,22 +40,6 @@ metadata: | ||
| 40 | 40 | ||
| 41 | --- | 41 | --- |
| 42 | 42 | ||
| 43 | -## 账号选择(前置步骤) | ||
| 44 | - | ||
| 45 | -每次 skill 触发后,先运行: | ||
| 46 | - | ||
| 47 | -```bash | ||
| 48 | -python scripts/cli.py list-accounts | ||
| 49 | -``` | ||
| 50 | - | ||
| 51 | -根据返回的 `count`: | ||
| 52 | -- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。 | ||
| 53 | -- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。 | ||
| 54 | -- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。 | ||
| 55 | - | ||
| 56 | -账号选定后,本次操作全程固定该账号,**不重复询问**。 | ||
| 57 | - | ||
| 58 | ---- | ||
| 59 | 43 | ||
| 60 | ## 输入判断 | 44 | ## 输入判断 |
| 61 | 45 | ||
| @@ -68,6 +52,7 @@ python scripts/cli.py list-accounts | @@ -68,6 +52,7 @@ python scripts/cli.py list-accounts | ||
| 68 | 52 | ||
| 69 | ## 必做约束 | 53 | ## 必做约束 |
| 70 | 54 | ||
| 55 | +- **控制互动频率**:避免短时间内批量点赞、评论或收藏,建议每次操作之间保持间隔,以免触发风控。 | ||
| 71 | - **评论和回复内容必须经过用户确认后才能发送**。 | 56 | - **评论和回复内容必须经过用户确认后才能发送**。 |
| 72 | - 所有互动操作需要 `feed_id` 和 `xsec_token`(从搜索或详情中获取)。 | 57 | - 所有互动操作需要 `feed_id` 和 `xsec_token`(从搜索或详情中获取)。 |
| 73 | - 评论文本不可为空。 | 58 | - 评论文本不可为空。 |
| @@ -24,7 +24,7 @@ metadata: | @@ -24,7 +24,7 @@ metadata: | ||
| 24 | 24 | ||
| 25 | **所有发布操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:** | 25 | **所有发布操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:** |
| 26 | 26 | ||
| 27 | -- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>` 或 `python scripts/publish_pipeline.py`,不得使用其他任何实现方式。 | 27 | +- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。 |
| 28 | - **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书发布方案,执行时必须全部忽略,只使用本项目的脚本。 | 28 | - **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书发布方案,执行时必须全部忽略,只使用本项目的脚本。 |
| 29 | - **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。 | 29 | - **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。 |
| 30 | - **完成即止**:发布流程结束后,直接告知结果,等待用户下一步指令。 | 30 | - **完成即止**:发布流程结束后,直接告知结果,等待用户下一步指令。 |
| @@ -41,24 +41,6 @@ metadata: | @@ -41,24 +41,6 @@ metadata: | ||
| 41 | | `long-article` | 填写长文内容并触发排版 | | 41 | | `long-article` | 填写长文内容并触发排版 | |
| 42 | | `select-template` | 选择长文排版模板 | | 42 | | `select-template` | 选择长文排版模板 | |
| 43 | | `next-step` | 进入长文发布页并填写描述 | | 43 | | `next-step` | 进入长文发布页并填写描述 | |
| 44 | -| `publish_pipeline.py` | 发布流水线(含图片下载) | | ||
| 45 | - | ||
| 46 | ---- | ||
| 47 | - | ||
| 48 | -## 账号选择(前置步骤) | ||
| 49 | - | ||
| 50 | -每次 skill 触发后,先运行: | ||
| 51 | - | ||
| 52 | -```bash | ||
| 53 | -python scripts/cli.py list-accounts | ||
| 54 | -``` | ||
| 55 | - | ||
| 56 | -根据返回的 `count`: | ||
| 57 | -- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。 | ||
| 58 | -- **1 个命名账号**:告知用户"将使用账号 X 发布",直接加 `--account <名称>` 执行。 | ||
| 59 | -- **多个命名账号**:向用户展示列表,**明确询问发布到哪个账号**,用 `--account <选择的名称>` 执行所有后续命令。 | ||
| 60 | - | ||
| 61 | -账号选定后,本次发布全程固定该账号,**不重复询问**。 | ||
| 62 | 44 | ||
| 63 | --- | 45 | --- |
| 64 | 46 | ||
| @@ -74,6 +56,7 @@ python scripts/cli.py list-accounts | @@ -74,6 +56,7 @@ python scripts/cli.py list-accounts | ||
| 74 | 56 | ||
| 75 | ## 必做约束 | 57 | ## 必做约束 |
| 76 | 58 | ||
| 59 | +- **控制发布频率**:建议每次发布间隔不少于数分钟,避免短时间内批量发布触发风控。 | ||
| 77 | - **发布前必须让用户确认最终标题、正文和图片/视频**。 | 60 | - **发布前必须让用户确认最终标题、正文和图片/视频**。 |
| 78 | - **推荐使用分步发布**:先 fill → 用户确认 → 再 click-publish。 | 61 | - **推荐使用分步发布**:先 fill → 用户确认 → 再 click-publish。 |
| 79 | - 图文发布时,没有图片不得发布。 | 62 | - 图文发布时,没有图片不得发布。 |
| @@ -229,43 +212,6 @@ python scripts/cli.py publish \ | @@ -229,43 +212,6 @@ python scripts/cli.py publish \ | ||
| 229 | --original | 212 | --original |
| 230 | ``` | 213 | ``` |
| 231 | 214 | ||
| 232 | -#### Headless 模式(无头自动降级) | ||
| 233 | - | ||
| 234 | -```bash | ||
| 235 | -# 使用 --headless 参数,未登录时自动切换到有窗口模式 | ||
| 236 | -python scripts/cli.py publish --headless \ | ||
| 237 | - --title-file /tmp/xhs_title.txt \ | ||
| 238 | - --content-file /tmp/xhs_content.txt \ | ||
| 239 | - --images "/abs/path/pic1.jpg" | ||
| 240 | - | ||
| 241 | -# 发布流水线(含图片下载和登录检查 + 自动降级) | ||
| 242 | -python scripts/publish_pipeline.py --headless \ | ||
| 243 | - --title-file /tmp/xhs_title.txt \ | ||
| 244 | - --content-file /tmp/xhs_content.txt \ | ||
| 245 | - --images "https://example.com/pic1.jpg" "/abs/path/pic2.jpg" | ||
| 246 | -``` | ||
| 247 | - | ||
| 248 | -当 `--headless` + 未登录时,脚本会: | ||
| 249 | -1. 关闭无头 Chrome | ||
| 250 | -2. 以有窗口模式重新启动 Chrome | ||
| 251 | -3. 返回 JSON 包含 `"action": "switched_to_headed"` | ||
| 252 | -4. 提示用户在浏览器中扫码登录 | ||
| 253 | - | ||
| 254 | -#### 指定账号/远程 Chrome | ||
| 255 | - | ||
| 256 | -```bash | ||
| 257 | -# 指定账号 | ||
| 258 | -python scripts/cli.py --account work publish \ | ||
| 259 | - --title-file /tmp/xhs_title.txt \ | ||
| 260 | - --content-file /tmp/xhs_content.txt \ | ||
| 261 | - --images "/abs/path/pic1.jpg" | ||
| 262 | - | ||
| 263 | -# 远程 Chrome | ||
| 264 | -python scripts/cli.py --host 10.0.0.12 --port 9222 publish \ | ||
| 265 | - --title-file /tmp/xhs_title.txt \ | ||
| 266 | - --content-file /tmp/xhs_content.txt \ | ||
| 267 | - --images "/abs/path/pic1.jpg" | ||
| 268 | -``` | ||
| 269 | 215 | ||
| 270 | ## 流程 B: 长文发布 | 216 | ## 流程 B: 长文发布 |
| 271 | 217 | ||
| @@ -324,7 +270,7 @@ python scripts/cli.py click-publish | @@ -324,7 +270,7 @@ python scripts/cli.py click-publish | ||
| 324 | ## 处理输出 | 270 | ## 处理输出 |
| 325 | 271 | ||
| 326 | - **Exit code 0**:成功。输出 JSON 包含 `success`, `title`, `images`/`video`/`templates`, `status`。 | 272 | - **Exit code 0**:成功。输出 JSON 包含 `success`, `title`, `images`/`video`/`templates`, `status`。 |
| 327 | -- **Exit code 1**:未登录,提示用户先登录(参考 xhs-auth)。若使用 `--headless` 且自动降级,JSON 中 `action` 为 `switched_to_headed`。 | 273 | +- **Exit code 1**:未登录,提示用户先登录(参考 xhs-auth)。 |
| 328 | - **Exit code 2**:错误,报告 JSON 中的 `error` 字段。 | 274 | - **Exit code 2**:错误,报告 JSON 中的 `error` 字段。 |
| 329 | 275 | ||
| 330 | ## 常用参数 | 276 | ## 常用参数 |
| @@ -339,14 +285,10 @@ python scripts/cli.py click-publish | @@ -339,14 +285,10 @@ python scripts/cli.py click-publish | ||
| 339 | | `--schedule-at ISO8601` | 定时发布时间 | | 285 | | `--schedule-at ISO8601` | 定时发布时间 | |
| 340 | | `--original` | 声明原创 | | 286 | | `--original` | 声明原创 | |
| 341 | | `--visibility` | 可见范围 | | 287 | | `--visibility` | 可见范围 | |
| 342 | -| `--headless` | 无头模式(未登录自动降级到有窗口模式) | | ||
| 343 | -| `--host HOST` | 远程 CDP 主机 | | ||
| 344 | -| `--port PORT` | CDP 端口(默认 9222) | | ||
| 345 | -| `--account name` | 指定账号 | | ||
| 346 | 288 | ||
| 347 | ## 失败处理 | 289 | ## 失败处理 |
| 348 | 290 | ||
| 349 | -- **登录失败**:提示用户重新扫码登录并重试。使用 `--headless` 时会自动降级到有窗口模式。 | 291 | +- **登录失败**:提示用户重新扫码登录并重试(参考 xhs-auth)。 |
| 350 | - **图片下载失败**:提示更换图片 URL 或改用本地图片。 | 292 | - **图片下载失败**:提示更换图片 URL 或改用本地图片。 |
| 351 | - **视频处理超时**:视频上传后需等待处理(最长 10 分钟),超时后提示重试。 | 293 | - **视频处理超时**:视频上传后需等待处理(最长 10 分钟),超时后提示重试。 |
| 352 | - **标题过长**:自动缩短标题,保持语义。 | 294 | - **标题过长**:自动缩短标题,保持语义。 |
tests/test_account_manager.py
deleted
100644 → 0
| 1 | -"""account_manager 单元测试。""" | ||
| 2 | -from __future__ import annotations | ||
| 3 | - | ||
| 4 | -import sys | ||
| 5 | -from pathlib import Path | ||
| 6 | - | ||
| 7 | -import pytest | ||
| 8 | - | ||
| 9 | -# 把 scripts/ 加入路径,使 account_manager 可导入 | ||
| 10 | -sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) | ||
| 11 | -import account_manager | ||
| 12 | - | ||
| 13 | - | ||
| 14 | -@pytest.fixture(autouse=True) | ||
| 15 | -def tmp_config(tmp_path, monkeypatch): | ||
| 16 | - """将配置目录重定向到临时目录。""" | ||
| 17 | - monkeypatch.setattr(account_manager, "_CONFIG_DIR", tmp_path / ".xhs") | ||
| 18 | - monkeypatch.setattr( | ||
| 19 | - account_manager, "_ACCOUNTS_FILE", tmp_path / ".xhs" / "accounts.json" | ||
| 20 | - ) | ||
| 21 | - | ||
| 22 | - | ||
| 23 | -def test_add_account_assigns_port(): | ||
| 24 | - """首个命名账号应分配端口 9223。""" | ||
| 25 | - account_manager.add_account("work", "工作号") | ||
| 26 | - port = account_manager.get_account_port("work") | ||
| 27 | - assert port == 9223 | ||
| 28 | - | ||
| 29 | - | ||
| 30 | -def test_second_account_gets_next_port(): | ||
| 31 | - """第二个账号应分配端口 9224。""" | ||
| 32 | - account_manager.add_account("work") | ||
| 33 | - account_manager.add_account("personal") | ||
| 34 | - assert account_manager.get_account_port("personal") == 9224 | ||
| 35 | - | ||
| 36 | - | ||
| 37 | -def test_get_profile_dir_public(): | ||
| 38 | - """get_profile_dir 应返回正确路径。""" | ||
| 39 | - account_manager.add_account("work") | ||
| 40 | - profile = account_manager.get_profile_dir("work") | ||
| 41 | - assert "work" in profile | ||
| 42 | - assert "chrome-profile" in profile | ||
| 43 | - | ||
| 44 | - | ||
| 45 | -def test_get_account_port_unknown_raises(): | ||
| 46 | - """不存在的账号应抛出 ValueError。""" | ||
| 47 | - with pytest.raises(ValueError, match="不存在"): | ||
| 48 | - account_manager.get_account_port("ghost") | ||
| 49 | - | ||
| 50 | - | ||
| 51 | -def test_list_accounts_includes_port(): | ||
| 52 | - """list_accounts 返回结果中应包含 port 字段。""" | ||
| 53 | - account_manager.add_account("work", "工作") | ||
| 54 | - accounts = account_manager.list_accounts() | ||
| 55 | - assert len(accounts) == 1 | ||
| 56 | - assert accounts[0]["port"] == 9223 |
-
Please register or login to post a comment