realsijin

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>
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 [![Star History Chart](https://api.star-history.com/image?repos=autoclaw-cc/xiaohongshu-skills&type=date&legend=top-left)](https://www.star-history.com/?repos=autoclaw-cc%2Fxiaohongshu-skills&type=date&legend=top-left) 231 [![Star History Chart](https://api.star-history.com/image?repos=autoclaw-cc/xiaohongshu-skills&type=date&legend=top-left)](https://www.star-history.com/?repos=autoclaw-cc%2Fxiaohongshu-skills&type=date&legend=top-left)
  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();
  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 +}
  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 +}
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)  
  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))
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
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()  
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)  
  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 """输入单个标签。"""
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 - **标题过长**:自动缩短标题,保持语义。
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