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>
# xiaohongshu-skills
小红书自动化 Claude Code Skills,基于 Python CDP 浏览器自动化引擎
小红书自动化 Claude Code Skills,使用用户的真实浏览器和账号信息操作小红书
## Git 工作流
... ... @@ -18,11 +18,12 @@ uv run pytest # 运行测试
## 架构
双层结构:`scripts/` 是 Python CDP 自动化引擎,`skills/` 是 Claude Code Skills 定义(SKILL.md 格式)。
双层结构:`scripts/` 是 Python 自动化引擎,`skills/` 是 Claude Code Skills 定义(SKILL.md 格式)。
- `scripts/xhs/` — 核心自动化库(模块化,每个功能一个文件)
- `scripts/cli.py` — 统一 CLI 入口,23 个子命令,JSON 结构化输出
- `scripts/publish_pipeline.py` — 发布编排器(含图片下载和登录检查)
- `scripts/cli.py` — 统一 CLI 入口,JSON 结构化输出,自动启动 bridge server 和浏览器
- `scripts/bridge_server.py` — 本地通信服务(连接 CLI 与浏览器扩展)
- `extension/` — Chrome 扩展,在用户的真实浏览器中执行操作
- `skills/*/SKILL.md` — 指导 Claude 如何调用 scripts/
### 调用方式
... ... @@ -31,9 +32,10 @@ uv run pytest # 运行测试
python scripts/cli.py check-login
python scripts/cli.py search-feeds --keyword "关键词"
python scripts/cli.py publish --title-file t.txt --content-file c.txt --images pic.jpg
python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --images URL1
```
> CLI 会自动检测环境,若浏览器未打开也会自动启动 Chrome。
## 代码规范
- 行长度上限 100 字符
... ... @@ -48,7 +50,6 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima
- 发布类操作必须有用户确认机制
- 文件路径必须使用绝对路径
- 敏感内容通过文件传递,不内联到命令行参数
- Chrome Profile 目录隔离账号 cookies
## CLI 子命令对照表
... ... @@ -74,7 +75,3 @@ python scripts/publish_pipeline.py --title-file t.txt --content-file c.txt --ima
| `long-article` | — | 长文发布(填写+排版) |
| `select-template` | — | 长文发布(选择模板) |
| `next-step` | — | 长文发布(下一步+描述) |
| `add-account` | — | 账号管理(添加,自动分配端口) |
| `list-accounts` | — | 账号管理(列出所有) |
| `remove-account` | — | 账号管理(删除) |
| `set-default-account` | — | 账号管理(设置默认) |
... ...
# xiaohongshu-skills
小红书自动化 Skills,基于 Python CDP 浏览器自动化引擎
小红书自动化 Skills,直接使用你已登录的浏览器和真实账号,以普通用户的方式操作小红书
支持 [OpenClaw](https://github.com/anthropics/openclaw) 及所有兼容 `SKILL.md` 格式的 AI Agent 平台(如 Claude Code)。
> **⚠️ 使用建议**:虽然本项目使用真实的用户浏览器和账号环境,但仍建议**控制使用频率**,避免短时间内大量操作。频繁的自动化行为可能触发小红书的风控机制,导致账号受限。
## 功能概览
| 技能 | 说明 | 核心能力 |
|------|------|----------|
| **xhs-auth** | 认证管理 | 登录检查、扫码登录、多账号切换 |
| **xhs-auth** | 认证管理 | 登录检查、扫码登录、手机验证码登录 |
| **xhs-publish** | 内容发布 | 图文 / 视频 / 长文发布、定时发布、分步预览 |
| **xhs-explore** | 内容发现 | 关键词搜索、笔记详情、用户主页、首页推荐 |
| **xhs-interact** | 社交互动 | 评论、回复、点赞、收藏 |
... ... @@ -28,12 +30,11 @@ Agent 会自动执行:搜索 → 筛选图文 → 按点赞排序 → 收藏
- [uv](https://docs.astral.sh/uv/) 包管理器
- Google Chrome 浏览器
### 方法一:下载 ZIP 安装(推荐)
### 第一步:安装项目
最简单稳妥的方式,适用于 OpenClaw 及所有支持 `SKILL.md` 的 Agent 平台。
**方法一:下载 ZIP(推荐)**
1. 在 GitHub 仓库页面点击 **Code → Download ZIP**,下载项目压缩包。
2. 解压到你的 Agent 的 skills 目录下:
1. 在 GitHub 仓库页面点击 **Code → Download ZIP**,下载并解压到你的 Agent skills 目录:
```
# OpenClaw 示例
... ... @@ -43,30 +44,30 @@ Agent 会自动执行:搜索 → 筛选图文 → 按点赞排序 → 收藏
<your-project>/.claude/skills/xiaohongshu-skills/
```
3. 安装 Python 依赖:
**方法二:Git Clone**
```bash
cd xiaohongshu-skills
uv sync
cd <your-agent-project>/skills/
git clone https://github.com/autoclaw-cc/xiaohongshu-skills.git
```
安装完成后,Agent 会自动识别 `SKILL.md` 并加载小红书技能。
### 方法二:Git Clone
2. 安装 Python 依赖:
```bash
# 进入 skills 目录
cd <your-agent-project>/skills/
# 克隆项目
git clone https://github.com/autoclaw-cc/xiaohongshu-skills.git
cd xiaohongshu-skills
# 安装依赖
uv sync
```
> 其他支持 SKILL.md 格式的 Agent 框架安装方式类似 — 将本项目放入其 skills 目录即可。
### 第二步:安装浏览器扩展
扩展让 AI 能够在你的浏览器中以你的身份操作小红书,使用的是你真实的登录状态和账号信息。
1. 打开 Chrome,地址栏输入 `chrome://extensions/`
2. 右上角开启**开发者模式**
3. 点击**加载已解压的扩展程序**,选择本项目的 `extension/` 目录
4. 确认扩展 **XHS Bridge** 已启用
安装完成后即可使用 — 所有操作都发生在你自己的浏览器里,使用你的真实账号和浏览器环境。
## 使用方式
... ... @@ -93,29 +94,14 @@ uv sync
所有功能也可以通过命令行直接调用,输出 JSON 格式,便于脚本集成。
#### 1. 启动 Chrome
```bash
# 有窗口模式(首次登录必须)
python scripts/chrome_launcher.py
# 无头模式
python scripts/chrome_launcher.py --headless
```
#### 2. 登录
```bash
# 检查登录状态(已登录时返回用户昵称和小红书号)
# 检查登录状态
python scripts/cli.py check-login
# 扫码登录
python scripts/cli.py login
```
#### 3. 搜索笔记
```bash
# 搜索笔记
python scripts/cli.py search-feeds --keyword "关键词"
# 带筛选条件
... ... @@ -123,29 +109,24 @@ python scripts/cli.py search-feeds \
--keyword "关键词" \
--sort-by "最多点赞" \
--note-type "图文"
```
#### 4. 查看笔记详情
```bash
# 查看笔记详情
python scripts/cli.py get-feed-detail \
--feed-id FEED_ID --xsec-token XSEC_TOKEN
```
#### 5. 发布内容
```bash
# 图文发布(分步:填写 → 预览 → 确认发布)
# 图文发布(分步:填写 → 预览 → 确认)
python scripts/cli.py fill-publish \
--title-file title.txt \
--content-file content.txt \
--images "/abs/path/pic1.jpg" "/abs/path/pic2.jpg"
# 用户在浏览器中预览确认后
python scripts/cli.py click-publish
# 或保存为草稿
python scripts/cli.py save-draft
# 一步发布图文
python scripts/cli.py publish \
--title-file title.txt \
--content-file content.txt \
--images "/abs/path/pic1.jpg" \
--tags "标签1" "标签2"
# 视频发布
python scripts/cli.py publish-video \
... ... @@ -153,41 +134,21 @@ python scripts/cli.py publish-video \
--content-file content.txt \
--video "/abs/path/video.mp4"
# 长文发布
python scripts/cli.py long-article \
--title-file title.txt \
--content-file content.txt
# 点赞 / 收藏 / 评论
python scripts/cli.py like-feed --feed-id FEED_ID --xsec-token XSEC_TOKEN
python scripts/cli.py favorite-feed --feed-id FEED_ID --xsec-token XSEC_TOKEN
python scripts/cli.py post-comment --feed-id FEED_ID --xsec-token XSEC_TOKEN --content "评论内容"
```
#### 6. 社交互动
```bash
# 评论
python scripts/cli.py post-comment \
--feed-id FEED_ID --xsec-token XSEC_TOKEN \
--content "评论内容"
# 点赞
python scripts/cli.py like-feed \
--feed-id FEED_ID --xsec-token XSEC_TOKEN
# 收藏
python scripts/cli.py favorite-feed \
--feed-id FEED_ID --xsec-token XSEC_TOKEN
```
> 第一次运行时,若 Chrome 未打开,CLI 会自动启动它。
## CLI 命令参考
全局选项:
- `--host HOST` — Chrome 调试主机(默认 127.0.0.1)
- `--port PORT` — Chrome 调试端口(默认 9222)
- `--account NAME` — 指定账号
| 子命令 | 说明 |
|--------|------|
| `check-login` | 检查登录状态,返回用户昵称和小红书号 |
| `login` | 获取登录二维码,等待扫码,登录后返回用户信息 |
| `delete-cookies` | 清除 cookies(退出/切换账号) |
| `delete-cookies` | 清除 cookies(退出登录) |
| `list-feeds` | 获取首页推荐 Feed |
| `search-feeds` | 关键词搜索笔记(支持排序/类型/时间/范围/位置筛选) |
| `get-feed-detail` | 获取笔记完整内容和评论 |
... ... @@ -212,11 +173,14 @@ python scripts/cli.py favorite-feed \
```
xiaohongshu-skills/
├── scripts/ # Python CDP 自动化引擎
├── extension/ # Chrome 扩展
│ ├── manifest.json
│ ├── background.js
│ └── content.js
├── scripts/ # Python 自动化引擎
│ ├── xhs/ # 核心自动化包
│ │ ├── cdp.py # CDP WebSocket 客户端
│ │ ├── stealth.py # 反检测保护
│ │ ├── selectors.py # CSS 选择器(集中管理,改版时只改此文件)
│ │ ├── bridge.py # 扩展通信客户端
│ │ ├── selectors.py # CSS 选择器(集中管理)
│ │ ├── login.py # 登录 + 用户信息获取
│ │ ├── feeds.py # 首页 Feed
│ │ ├── search.py # 搜索 + 筛选
... ... @@ -231,14 +195,12 @@ xiaohongshu-skills/
│ │ ├── errors.py # 异常体系
│ │ ├── urls.py # URL 常量
│ │ ├── cookies.py # Cookie 持久化
│ │ └── human.py # 人类行为模拟
│ ├── cli.py # 统一 CLI 入口(20 个子命令)
│ ├── chrome_launcher.py # Chrome 进程管理
│ ├── account_manager.py # 多账号管理
│ │ └── human.py # 行为模拟
│ ├── cli.py # 统一 CLI 入口
│ ├── bridge_server.py # 本地通信服务
│ ├── image_downloader.py # 媒体下载(SHA256 缓存)
│ ├── title_utils.py # UTF-16 标题长度计算
│ ├── run_lock.py # 单实例锁
│ └── publish_pipeline.py # 发布编排器
│ └── run_lock.py # 单实例锁
├── skills/ # Claude Code Skills 定义
│ ├── xhs-auth/SKILL.md
│ ├── xhs-publish/SKILL.md
... ... @@ -247,29 +209,10 @@ xiaohongshu-skills/
│ └── xhs-content-ops/SKILL.md
├── SKILL.md # 技能统一入口(路由到子技能)
├── CLAUDE.md # 项目开发指南
├── pyproject.toml # uv 项目配置
├── pyproject.toml
└── README.md
```
## 技术架构
### 双层设计
```
用户 ──→ AI Agent ──→ SKILL.md(意图路由)──→ CLI ──→ CDP 引擎 ──→ Chrome ──→ 小红书
```
1. **Skills 层**`skills/` + `SKILL.md`)— AI Agent 的能力定义,描述何时触发、如何调用、如何处理失败。Agent 读取 SKILL.md 后自动获得小红书操作能力。
2. **引擎层**`scripts/`)— Python CDP 自动化引擎,通过 Chrome DevTools Protocol 直接控制浏览器。内置反检测保护、人类行为模拟、JSON 结构化输出。
### 关键设计
- **数据提取**:通过 `window.__INITIAL_STATE__` 读取页面数据,与小红书前端框架对齐
- **反检测**:Stealth JS 注入 + CDP 真实输入事件(`isTrusted=true`)+ 随机延迟
- **选择器集中管理**:所有 CSS 选择器在 `xhs/selectors.py` 统一维护,小红书改版时只需改一个文件
- **分步发布**:fill → 预览 → confirm 三步流程,确保用户始终掌控发布内容
## 开发
```bash
... ... @@ -283,8 +226,6 @@ uv run pytest # 运行测试
MIT
## Trend
## Star History
[![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)
... ...
/**
* XHS Bridge - Background Service Worker
*
* 连接 Python bridge server(ws://localhost:9333),接收命令并执行:
* - navigate / wait_for_load: chrome.tabs.update + onUpdated
* - evaluate / has_element 等: chrome.scripting.executeScript (MAIN world)
* - click / input 等 DOM 操作: chrome.tabs.sendMessage → content.js
* - screenshot: chrome.tabs.captureVisibleTab
* - get_cookies: chrome.cookies.getAll
*/
const BRIDGE_URL = "ws://localhost:9333";
let ws = null;
// 保持 service worker 存活:有开放的 WebSocket 连接时 Chrome 不会终止 SW
// 额外加 alarm 作为保底
chrome.alarms.create("keepAlive", { periodInMinutes: 0.4 });
chrome.alarms.onAlarm.addListener(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) connect();
});
// ───────────────────────── WebSocket ─────────────────────────
function connect() {
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
ws = new WebSocket(BRIDGE_URL);
ws.onopen = () => {
console.log("[XHS Bridge] 已连接到 bridge server");
ws.send(JSON.stringify({ role: "extension" }));
};
ws.onmessage = async (event) => {
let msg;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
try {
const result = await handleCommand(msg);
ws.send(JSON.stringify({ id: msg.id, result: result ?? null }));
} catch (err) {
ws.send(JSON.stringify({ id: msg.id, error: String(err.message || err) }));
}
};
ws.onclose = () => {
console.log("[XHS Bridge] 连接断开,3s 后重连...");
setTimeout(connect, 3000);
};
ws.onerror = (e) => {
console.error("[XHS Bridge] WS 错误", e);
};
}
// ───────────────────────── 命令路由 ─────────────────────────
async function handleCommand(msg) {
const { method, params = {} } = msg;
switch (method) {
// ── 导航 ──
case "navigate":
return await cmdNavigate(params);
case "wait_for_load":
return await cmdWaitForLoad(params);
// ── 截图 ──
case "screenshot_element":
return await cmdScreenshot(params);
case "set_file_input":
return await cmdSetFileInputViaDebugger(params);
// ── Cookies ──
case "get_cookies":
return await cmdGetCookies(params);
// ── 在页面主 world 执行 JS(可访问 window.__INITIAL_STATE__ 等) ──
case "evaluate":
case "wait_dom_stable":
case "wait_for_selector":
case "has_element":
case "get_elements_count":
case "get_element_text":
case "get_element_attribute":
case "get_scroll_top":
case "get_viewport_height":
case "get_url":
return await cmdEvaluateInMainWorld(method, params);
// ── DOM 操作(在页面 MAIN world 执行,无需 content script 就绪) ──
default:
return await cmdDomInMainWorld(method, params);
}
}
// ───────────────────────── 导航 ─────────────────────────
async function cmdNavigate({ url }) {
const tab = await getOrOpenXhsTab();
await chrome.tabs.update(tab.id, { url });
await waitForTabComplete(tab.id, url, 60000);
return null;
}
async function cmdWaitForLoad({ timeout = 60000 }) {
const tab = await getOrOpenXhsTab();
await waitForTabComplete(tab.id, null, timeout);
return null;
}
async function waitForTabComplete(tabId, expectedUrlPrefix, timeout) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeout;
function listener(id, info, updatedTab) {
if (id !== tabId) return;
if (info.status !== "complete") return;
if (expectedUrlPrefix && !updatedTab.url?.startsWith(expectedUrlPrefix.slice(0, 20))) return;
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
chrome.tabs.onUpdated.addListener(listener);
// 轮询兜底:若事件在监听前已触发
const poll = async () => {
if (Date.now() > deadline) {
chrome.tabs.onUpdated.removeListener(listener);
reject(new Error("页面加载超时"));
return;
}
const tab = await chrome.tabs.get(tabId).catch(() => null);
if (tab && tab.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
return;
}
setTimeout(poll, 400);
};
setTimeout(poll, 600);
});
}
// ───────────────────────── 截图 ─────────────────────────
async function cmdScreenshot() {
const tab = await getOrOpenXhsTab();
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
return { data: dataUrl.split(",")[1] };
}
// ───────────────────────── Cookies ─────────────────────────
async function cmdGetCookies({ domain = "xiaohongshu.com" }) {
return await chrome.cookies.getAll({ domain });
}
// ───────────────────────── MAIN world JS 执行 ─────────────────────────
async function cmdEvaluateInMainWorld(method, params) {
const tab = await getOrOpenXhsTab();
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: mainWorldExecutor,
args: [method, params],
});
const r = results?.[0]?.result;
if (r && typeof r === "object" && "__xhs_error" in r) {
throw new Error(r.__xhs_error);
}
return r;
}
/**
* 在页面主 world 运行,可访问 window.__INITIAL_STATE__ 等页面全局变量。
* 注意:此函数被序列化后注入页面,不能引用外部变量。
*/
function mainWorldExecutor(method, params) {
function poll(check, interval, timeout) {
return new Promise((resolve, reject) => {
const start = Date.now();
(function tick() {
const result = check();
if (result !== false && result !== null && result !== undefined) {
resolve(result);
return;
}
if (Date.now() - start >= timeout) {
reject(new Error("超时"));
return;
}
setTimeout(tick, interval);
})();
});
}
switch (method) {
case "evaluate": {
try {
// eslint-disable-next-line no-new-func
return Function(`"use strict"; return (${params.expression})`)();
} catch (e) {
return { __xhs_error: `JS执行错误: ${e.message}` };
}
}
case "has_element":
return document.querySelector(params.selector) !== null;
case "get_elements_count":
return document.querySelectorAll(params.selector).length;
case "get_element_text": {
const el = document.querySelector(params.selector);
return el ? el.textContent : null;
}
case "get_element_attribute": {
const el = document.querySelector(params.selector);
return el ? el.getAttribute(params.attr) : null;
}
case "get_scroll_top":
return window.pageYOffset || document.documentElement.scrollTop || 0;
case "get_viewport_height":
return window.innerHeight;
case "get_url":
return window.location.href;
case "wait_dom_stable": {
const timeout = params.timeout || 10000;
const interval = params.interval || 500;
return new Promise((resolve) => {
let last = -1;
const start = Date.now();
(function tick() {
const size = document.body ? document.body.innerHTML.length : 0;
if (size === last && size > 0) { resolve(null); return; }
last = size;
if (Date.now() - start >= timeout) { resolve(null); return; }
setTimeout(tick, interval);
})();
});
}
case "wait_for_selector": {
const timeout = params.timeout || 30000;
return poll(
() => document.querySelector(params.selector) ? true : false,
200,
timeout,
).catch(() => { throw new Error(`等待元素超时: ${params.selector}`); });
}
default:
return { __xhs_error: `未知 MAIN world 方法: ${method}` };
}
}
// ───────────────────────── 文件上传(chrome.debugger + CDP) ─────────
async function cmdSetFileInputViaDebugger({ selector, files }) {
const tab = await getOrOpenXhsTab();
const target = { tabId: tab.id };
await chrome.debugger.attach(target, "1.3");
try {
const { root } = await chrome.debugger.sendCommand(target, "DOM.getDocument", { depth: 0 });
const { nodeId } = await chrome.debugger.sendCommand(target, "DOM.querySelector", {
nodeId: root.nodeId,
selector,
});
if (!nodeId) throw new Error(`文件输入框不存在: ${selector}`);
await chrome.debugger.sendCommand(target, "DOM.setFileInputFiles", {
nodeId,
files, // 本地文件路径数组,由 Python 侧提供
});
} finally {
await chrome.debugger.detach(target).catch(() => {});
}
return null;
}
// ───────────────────────── DOM 操作(MAIN world) ────────────────────
async function cmdDomInMainWorld(method, params) {
const tab = await getOrOpenXhsTab();
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: domExecutor,
args: [method, params],
});
const r = results?.[0]?.result;
if (r && typeof r === "object" && "__xhs_error" in r) {
throw new Error(r.__xhs_error);
}
return r ?? null;
}
/**
* DOM 操作执行器,在页面 MAIN world 运行。
* 不能引用外部变量,所有逻辑自包含。
*/
function domExecutor(method, params) {
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function requireEl(selector) {
const el = document.querySelector(selector);
if (!el) return { __xhs_error: `元素不存在: ${selector}` };
return el;
}
switch (method) {
case "click_element": {
const el = requireEl(params.selector);
if (el.__xhs_error) return el;
el.scrollIntoView({ block: "center" });
el.focus();
el.click();
return null;
}
case "input_text": {
const el = requireEl(params.selector);
if (el.__xhs_error) return el;
el.focus();
el.value = params.text;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return null;
}
case "input_content_editable": {
return new Promise(async (resolve) => {
const el = document.querySelector(params.selector);
if (!el) { resolve({ __xhs_error: `元素不存在: ${params.selector}` }); return; }
el.focus();
document.execCommand("selectAll", false, null);
document.execCommand("delete", false, null);
await sleep(80);
const lines = params.text.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i]) document.execCommand("insertText", false, lines[i]);
if (i < lines.length - 1) {
// insertParagraph 才能在 contenteditable 里真正插入换行
document.execCommand("insertParagraph", false, null);
await sleep(30);
}
}
resolve(null);
});
}
case "set_file_input": {
return new Promise((resolve) => {
const el = document.querySelector(params.selector);
if (!el) { resolve({ __xhs_error: `文件输入框不存在: ${params.selector}` }); return; }
function makeFiles() {
const dt = new DataTransfer();
for (const f of params.files) {
const bytes = Uint8Array.from(atob(f.data), c => c.charCodeAt(0));
dt.items.add(new File([bytes], f.name, { type: f.type }));
}
return dt;
}
// 方法1: 覆盖 files 属性 + change 事件(标准 file input)
try {
const dt = makeFiles();
Object.defineProperty(el, "files", { value: dt.files, configurable: true, writable: true });
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("input", { bubbles: true }));
} catch (e) {}
// 方法2: drag-drop 到上传区域(XHS 主要监听 drop 事件)
const dropTarget =
el.closest('[class*="upload"]') ||
el.closest('[class*="Upload"]') ||
el.parentElement;
if (dropTarget) {
try {
const dt2 = makeFiles();
dropTarget.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: dt2 }));
dropTarget.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer: dt2 }));
dropTarget.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt2 }));
} catch (e) {}
}
resolve(null);
});
}
case "scroll_by":
window.scrollBy(params.x || 0, params.y || 0); return null;
case "scroll_to":
window.scrollTo(params.x || 0, params.y || 0); return null;
case "scroll_to_bottom":
window.scrollTo(0, document.body.scrollHeight); return null;
case "scroll_element_into_view": {
const el = document.querySelector(params.selector);
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
return null;
}
case "scroll_nth_element_into_view": {
const els = document.querySelectorAll(params.selector);
if (els[params.index]) els[params.index].scrollIntoView({ behavior: "smooth", block: "center" });
return null;
}
case "dispatch_wheel_event": {
const target = document.querySelector(".note-scroller") ||
document.querySelector(".interaction-container") || document.documentElement;
target.dispatchEvent(new WheelEvent("wheel", { deltaY: params.deltaY || 0, deltaMode: 0, bubbles: true, cancelable: true }));
return null;
}
case "mouse_move":
document.dispatchEvent(new MouseEvent("mousemove", { clientX: params.x, clientY: params.y, bubbles: true }));
return null;
case "mouse_click": {
const el = document.elementFromPoint(params.x, params.y);
if (el) {
["mousedown", "mouseup", "click"].forEach(t =>
el.dispatchEvent(new MouseEvent(t, { clientX: params.x, clientY: params.y, bubbles: true }))
);
}
return null;
}
case "press_key": {
const active = document.activeElement || document.body;
const inCE = active.isContentEditable;
if (inCE && params.key === "Enter") {
document.execCommand("insertParagraph", false, null);
return null;
}
if (inCE && params.key === "ArrowDown") {
// 将光标移到内容末尾(等价于多次下移到底)
const sel = window.getSelection();
if (sel && active.childNodes.length) {
sel.selectAllChildren(active);
sel.collapseToEnd();
}
return null;
}
const keyMap = {
Enter: { key: "Enter", code: "Enter", keyCode: 13 },
ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
Tab: { key: "Tab", code: "Tab", keyCode: 9 },
Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
};
const info = keyMap[params.key] || { key: params.key, code: params.key, keyCode: 0 };
active.dispatchEvent(new KeyboardEvent("keydown", { ...info, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keyup", { ...info, bubbles: true }));
return null;
}
case "type_text": {
return new Promise(async (resolve) => {
const active = document.activeElement || document.body;
const inCE = active.isContentEditable;
for (const char of params.text) {
if (inCE) {
document.execCommand("insertText", false, char);
} else {
active.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keypress", { key: char, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true }));
}
await sleep(params.delayMs || 50);
}
resolve(null);
});
}
case "remove_element": {
const el = document.querySelector(params.selector);
if (el) el.remove();
return null;
}
case "hover_element": {
const el = document.querySelector(params.selector);
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2, y = rect.top + rect.height / 2;
el.dispatchEvent(new MouseEvent("mouseover", { clientX: x, clientY: y, bubbles: true }));
el.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true }));
}
return null;
}
case "select_all_text": {
const el = document.querySelector(params.selector);
if (el) { el.focus(); if (el.select) el.select(); else document.execCommand("selectAll"); }
return null;
}
default:
return { __xhs_error: `未知 DOM 命令: ${method}` };
}
}
// ───────────────────────── Tab 管理 ─────────────────────────
async function getOrOpenXhsTab() {
const tabs = await chrome.tabs.query({
url: [
"https://www.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"https://creator.xiaohongshu.com/*",
],
});
if (tabs.length > 0) return tabs[0];
// 没有已打开的 XHS 页面,新建一个
const tab = await chrome.tabs.create({ url: "https://www.xiaohongshu.com/" });
await waitForTabComplete(tab.id, null, 30000);
return tab;
}
// ───────────────────────── 启动 ─────────────────────────
connect();
... ...
/**
* XHS Bridge - Content Script(隔离 world)
*
* 接收来自 background.js 的 DOM 操作命令并执行。
* evaluate / has_element 等需要访问页面 JS 变量的命令由 background.js
* 通过 chrome.scripting.executeScript(world:"MAIN") 直接处理,不经过这里。
*/
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
handleDomCommand(msg.method, msg.params || {})
.then((result) => sendResponse({ result: result ?? null }))
.catch((err) => sendResponse({ error: String(err.message || err) }));
return true; // 异步响应
});
async function handleDomCommand(method, params) {
switch (method) {
case "click_element":
return cmdClickElement(params);
case "input_text":
return cmdInputText(params);
case "input_content_editable":
return cmdInputContentEditable(params);
case "scroll_by":
window.scrollBy(params.x || 0, params.y || 0);
return null;
case "scroll_to":
window.scrollTo(params.x || 0, params.y || 0);
return null;
case "scroll_to_bottom":
window.scrollTo(0, document.body.scrollHeight);
return null;
case "scroll_element_into_view": {
const el = document.querySelector(params.selector);
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
return null;
}
case "scroll_nth_element_into_view": {
const els = document.querySelectorAll(params.selector);
if (els[params.index]) els[params.index].scrollIntoView({ behavior: "smooth", block: "center" });
return null;
}
case "dispatch_wheel_event": {
const target =
document.querySelector(".note-scroller") ||
document.querySelector(".interaction-container") ||
document.documentElement;
target.dispatchEvent(
new WheelEvent("wheel", {
deltaY: params.deltaY || 0,
deltaMode: 0,
bubbles: true,
cancelable: true,
view: window,
}),
);
return null;
}
case "mouse_move": {
document.dispatchEvent(
new MouseEvent("mousemove", { clientX: params.x, clientY: params.y, bubbles: true }),
);
return null;
}
case "mouse_click": {
const el = document.elementFromPoint(params.x, params.y);
if (el) {
el.dispatchEvent(new MouseEvent("mousedown", { clientX: params.x, clientY: params.y, bubbles: true }));
el.dispatchEvent(new MouseEvent("mouseup", { clientX: params.x, clientY: params.y, bubbles: true }));
el.dispatchEvent(new MouseEvent("click", { clientX: params.x, clientY: params.y, bubbles: true }));
}
return null;
}
case "press_key": {
const keyMap = {
Enter: { key: "Enter", code: "Enter", keyCode: 13 },
ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
Tab: { key: "Tab", code: "Tab", keyCode: 9 },
Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
};
const info = keyMap[params.key] || { key: params.key, code: params.key, keyCode: 0 };
const active = document.activeElement || document.body;
active.dispatchEvent(new KeyboardEvent("keydown", { ...info, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keyup", { ...info, bubbles: true }));
return null;
}
case "type_text": {
const active = document.activeElement || document.body;
const delay = params.delayMs || 50;
for (const char of params.text) {
active.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keypress", { key: char, bubbles: true }));
active.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true }));
await sleep(delay);
}
return null;
}
case "remove_element": {
const el = document.querySelector(params.selector);
if (el) el.remove();
return null;
}
case "hover_element": {
const el = document.querySelector(params.selector);
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
el.dispatchEvent(new MouseEvent("mouseover", { clientX: x, clientY: y, bubbles: true }));
el.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true }));
}
return null;
}
case "select_all_text": {
const el = document.querySelector(params.selector);
if (el) {
el.focus();
if (el.select) el.select();
else document.execCommand("selectAll");
}
return null;
}
case "set_file_input":
return cmdSetFileInput(params);
default:
throw new Error(`content.js: 未知命令 ${method}`);
}
}
// ───────────────────────── 具体实现 ─────────────────────────
function cmdClickElement({ selector }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`元素不存在: ${selector}`);
el.scrollIntoView({ block: "center" });
el.click();
return null;
}
function cmdInputText({ selector, text }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`元素不存在: ${selector}`);
el.focus();
el.value = text;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return null;
}
async function cmdInputContentEditable({ selector, text }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`元素不存在: ${selector}`);
el.focus();
// 全选清空
document.execCommand("selectAll", false, null);
document.execCommand("delete", false, null);
await sleep(80);
// 逐行插入(换行转为 Enter 键事件)
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i]) document.execCommand("insertText", false, lines[i]);
if (i < lines.length - 1) {
el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }));
el.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }));
await sleep(40);
}
}
return null;
}
async function cmdSetFileInput({ selector, files }) {
const el = document.querySelector(selector);
if (!el) throw new Error(`文件输入框不存在: ${selector}`);
const dt = new DataTransfer();
for (const f of files) {
const bytes = Uint8Array.from(atob(f.data), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: f.type });
dt.items.add(new File([blob], f.name, { type: f.type }));
}
Object.defineProperty(el, "files", {
value: dt.files,
configurable: true,
writable: true,
});
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("input", { bubbles: true }));
return null;
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
... ...
{
"manifest_version": 3,
"name": "XHS Bridge",
"version": "1.0.0",
"description": "小红书自动化 Bridge - 连接本地 Python CLI",
"permissions": [
"tabs",
"cookies",
"scripting",
"alarms",
"debugger"
],
"host_permissions": [
"https://www.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"https://creator.xiaohongshu.com/*",
"ws://localhost/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": [
"https://www.xiaohongshu.com/*",
"https://xiaohongshu.com/*",
"https://creator.xiaohongshu.com/*"
],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
... ...
"""多账号管理,对应独立的账号配置管理。"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
logger = logging.getLogger(__name__)
# 账号配置文件路径
_CONFIG_DIR = Path.home() / ".xhs"
_ACCOUNTS_FILE = _CONFIG_DIR / "accounts.json"
# 命名账号端口起始值(默认账号使用 9222)
_NAMED_PORT_START = 9223
def _load_config() -> dict:
"""加载账号配置。"""
if not _ACCOUNTS_FILE.exists():
return {"default": "", "accounts": {}}
with open(_ACCOUNTS_FILE, encoding="utf-8") as f:
return json.load(f)
def _save_config(config: dict) -> None:
"""保存账号配置。"""
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(_ACCOUNTS_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
def list_accounts() -> list[dict]:
"""列出所有账号。"""
config = _load_config()
default = config.get("default", "")
accounts = config.get("accounts", {})
result = []
for name, info in accounts.items():
result.append(
{
"name": name,
"description": info.get("description", ""),
"is_default": name == default,
"profile_dir": get_profile_dir(name),
"port": info.get("port", _NAMED_PORT_START),
}
)
return result
def add_account(name: str, description: str = "") -> None:
"""添加账号,自动分配独立端口(从 _NAMED_PORT_START 递增)。"""
config = _load_config()
accounts = config.setdefault("accounts", {})
if name in accounts:
raise ValueError(f"账号 '{name}' 已存在")
# 自动分配端口:取已有端口的最大值(至少 _NAMED_PORT_START - 1)加 1
existing_ports = {info.get("port", _NAMED_PORT_START) for info in accounts.values()}
port = max(existing_ports | {_NAMED_PORT_START - 1}) + 1
accounts[name] = {"description": description, "port": port}
# 如果是第一个账号,设为默认
if not config.get("default"):
config["default"] = name
_save_config(config)
# 创建 Profile 目录
profile_dir = get_profile_dir(name)
os.makedirs(profile_dir, exist_ok=True)
logger.info("添加账号: %s (port=%d)", name, port)
def remove_account(name: str) -> None:
"""删除账号。"""
config = _load_config()
accounts = config.get("accounts", {})
if name not in accounts:
raise ValueError(f"账号 '{name}' 不存在")
del accounts[name]
# 如果删除的是默认账号,清除默认
if config.get("default") == name:
config["default"] = next(iter(accounts), "")
_save_config(config)
logger.info("删除账号: %s", name)
def set_default_account(name: str) -> None:
"""设置默认账号。"""
config = _load_config()
accounts = config.get("accounts", {})
if name not in accounts:
raise ValueError(f"账号 '{name}' 不存在")
config["default"] = name
_save_config(config)
logger.info("默认账号设置为: %s", name)
def update_account_description(name: str, description: str) -> None:
"""更新账号描述(通常用于存储平台昵称)。"""
config = _load_config()
accounts = config.get("accounts", {})
if name not in accounts:
raise ValueError(f"账号 '{name}' 不存在")
accounts[name]["description"] = description
_save_config(config)
logger.info("账号 %s 描述已更新: %s", name, description)
def get_default_account() -> str:
"""获取默认账号名称。"""
config = _load_config()
return config.get("default", "")
def get_profile_dir(account: str) -> str:
"""获取账号的 Chrome Profile 目录。"""
return str(_CONFIG_DIR / "accounts" / account / "chrome-profile")
def _get_profile_dir(account: str) -> str:
"""获取账号的 Chrome Profile 目录(别名,向后兼容)。"""
return get_profile_dir(account)
def get_account_port(name: str) -> int:
"""获取指定账号的 Chrome 调试端口。"""
config = _load_config()
accounts = config.get("accounts", {})
if name not in accounts:
raise ValueError(f"账号 '{name}' 不存在")
return accounts[name].get("port", _NAMED_PORT_START)
"""XHS Extension Bridge Server
Extension 连接到这里(WebSocket),CLI 命令通过同一端口发送(role=cli),
Bridge 将命令路由给 Extension 并把结果返回给 CLI。
启动方式:
python scripts/bridge_server.py
端口:9333(可通过 --port 覆盖)
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import sys
import uuid
from typing import Any
import websockets
from websockets.server import ServerConnection
logger = logging.getLogger("xhs-bridge")
class BridgeServer:
def __init__(self) -> None:
self._extension_ws: ServerConnection | None = None
self._pending: dict[str, asyncio.Future[Any]] = {}
async def handle(self, ws: ServerConnection) -> None:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=10)
except (asyncio.TimeoutError, Exception) as e:
logger.warning("握手超时或失败: %s", e)
return
try:
msg = json.loads(raw)
except json.JSONDecodeError:
return
role = msg.get("role")
if role == "extension":
await self._handle_extension(ws)
elif role == "cli":
await self._handle_cli(ws, msg)
else:
logger.warning("未知 role: %s", role)
# ─── Extension 端(长连接) ───────────────────────────────────────
async def _handle_extension(self, ws: ServerConnection) -> None:
logger.info("Extension 已连接")
self._extension_ws = ws
try:
async for raw in ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
msg_id = msg.get("id")
if msg_id and msg_id in self._pending:
future = self._pending.pop(msg_id)
if not future.done():
future.set_result(msg)
finally:
self._extension_ws = None
logger.info("Extension 已断开")
# 唤醒所有等待中的 CLI 请求并报错
for future in self._pending.values():
if not future.done():
future.set_exception(ConnectionError("Extension 断开连接"))
self._pending.clear()
# ─── CLI 端(短连接,发一条命令,收一条回复) ─────────────────────
async def _handle_cli(self, ws: ServerConnection, msg: dict) -> None:
# 特殊命令:查询 server/extension 状态,无需转发
if msg.get("method") == "ping_server":
await ws.send(json.dumps({
"result": {"extension_connected": self._extension_ws is not None}
}))
return
if not self._extension_ws:
await ws.send(json.dumps({"error": "Extension 未连接,请确认浏览器已安装并启用 XHS Bridge 扩展"}))
return
msg_id = str(uuid.uuid4())
msg["id"] = msg_id
loop = asyncio.get_event_loop()
future: asyncio.Future[Any] = loop.create_future()
self._pending[msg_id] = future
await self._extension_ws.send(json.dumps(msg))
try:
result = await asyncio.wait_for(future, timeout=90.0)
await ws.send(json.dumps(result))
except asyncio.TimeoutError:
self._pending.pop(msg_id, None)
await ws.send(json.dumps({"error": "命令执行超时(90s)"}))
except ConnectionError as e:
await ws.send(json.dumps({"error": str(e)}))
async def main(port: int) -> None:
server = BridgeServer()
async with websockets.serve(server.handle, "localhost", port):
logger.info("Bridge server 已启动: ws://localhost:%d", port)
logger.info("等待浏览器扩展连接...")
await asyncio.Future() # 永久运行
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="XHS Extension Bridge Server")
parser.add_argument("--port", type=int, default=9333, help="监听端口(默认 9333)")
args = parser.parse_args()
asyncio.run(main(args.port))
... ...
"""Chrome 进程管理(跨平台),对应 Go browser/browser.go 的进程管理部分。"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import platform
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
from xhs.stealth import STEALTH_ARGS
logger = logging.getLogger(__name__)
# 默认远程调试端口
DEFAULT_PORT = 9222
# 全局进程追踪
_chrome_process: subprocess.Popen | None = None
# 各平台 Chrome 默认路径
_CHROME_PATHS: dict[str, list[str]] = {
"Darwin": [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
],
"Linux": [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
],
"Windows": [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
],
}
def _get_default_data_dir() -> str:
"""返回默认 Chrome Profile 目录路径。"""
return str(Path.home() / ".xhs" / "chrome-profile")
def is_port_open(port: int, host: str = "127.0.0.1") -> bool:
"""TCP socket 级端口检测(秒级响应)。"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
try:
s.connect((host, port))
return True
except (ConnectionRefusedError, TimeoutError, OSError):
return False
def find_chrome() -> str | None:
"""查找 Chrome 可执行文件路径。"""
# 环境变量优先
env_path = os.getenv("CHROME_BIN")
if env_path and os.path.isfile(env_path):
return env_path
# which/where 查找(含 Windows chrome.exe)
chrome = (
shutil.which("google-chrome")
or shutil.which("chromium")
or shutil.which("chrome")
or shutil.which("chrome.exe")
)
if chrome:
return chrome
# 平台默认路径
system = platform.system()
# Windows: 额外检查环境变量路径
if system == "Windows":
for env_var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
base = os.environ.get(env_var, "")
if base:
candidate = os.path.join(base, "Google", "Chrome", "Application", "chrome.exe")
if os.path.isfile(candidate):
return candidate
for path in _CHROME_PATHS.get(system, []):
if os.path.isfile(path):
return path
return None
def is_chrome_running(port: int = DEFAULT_PORT) -> bool:
"""检查指定端口的 Chrome 是否在运行(TCP 级检测)。"""
return is_port_open(port)
def launch_chrome(
port: int = DEFAULT_PORT,
headless: bool = False,
user_data_dir: str | None = None,
chrome_bin: str | None = None,
) -> subprocess.Popen | None:
"""启动 Chrome 进程(带远程调试端口)。
Args:
port: 远程调试端口。
headless: 是否无头模式。
user_data_dir: 用户数据目录(Profile 隔离),默认 ~/.xhs/chrome-profile。
chrome_bin: Chrome 可执行文件路径。
Returns:
Chrome 子进程,若已在运行则返回 None。
Raises:
FileNotFoundError: 未找到 Chrome。
"""
global _chrome_process
# 已在运行则跳过
if is_port_open(port):
logger.info("Chrome 已在运行 (port=%d),跳过启动", port)
return None
if not chrome_bin:
chrome_bin = find_chrome()
if not chrome_bin:
raise FileNotFoundError("未找到 Chrome,请设置 CHROME_BIN 环境变量或安装 Chrome")
# 默认 user-data-dir
if not user_data_dir:
user_data_dir = _get_default_data_dir()
args = [
chrome_bin,
f"--remote-debugging-port={port}",
f"--user-data-dir={user_data_dir}",
*STEALTH_ARGS,
]
if headless:
args.append("--headless=new")
# 代理
proxy = os.getenv("XHS_PROXY")
if proxy:
args.append(f"--proxy-server={proxy}")
logger.info("使用代理: %s", _mask_proxy(proxy))
logger.info("启动 Chrome: port=%d, headless=%s, profile=%s", port, headless, user_data_dir)
process = subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_chrome_process = process
# 等待 Chrome 准备就绪
_wait_for_chrome(port)
return process
def close_chrome(process: subprocess.Popen) -> None:
"""关闭 Chrome 进程。"""
if process.poll() is not None:
return
try:
process.terminate()
process.wait(timeout=5)
except (subprocess.TimeoutExpired, OSError):
process.kill()
process.wait(timeout=3)
logger.info("Chrome 进程已关闭")
def kill_chrome(port: int = DEFAULT_PORT) -> None:
"""关闭指定端口的 Chrome 实例。
策略: CDP Browser.close → terminate 追踪进程 → 端口查找终止进程。
Args:
port: Chrome 调试端口。
"""
global _chrome_process
# 策略1: 通过 CDP 关闭
try:
import requests
resp = requests.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
if resp.status_code == 200:
ws_url = resp.json().get("webSocketDebuggerUrl")
if ws_url:
import websockets.sync.client
ws = websockets.sync.client.connect(ws_url)
ws.send(json.dumps({"id": 1, "method": "Browser.close"}))
ws.close()
logger.info("通过 CDP Browser.close 关闭 Chrome (port=%d)", port)
time.sleep(1)
except Exception:
pass
# 策略2: terminate 追踪的子进程
if _chrome_process and _chrome_process.poll() is None:
try:
_chrome_process.terminate()
_chrome_process.wait(timeout=5)
logger.info("通过 terminate 关闭追踪的 Chrome 进程")
except Exception:
with contextlib.suppress(Exception):
_chrome_process.kill()
_chrome_process = None
# 策略3: 通过端口查找并终止进程(跨平台)
if is_port_open(port):
pids = _find_pids_by_port(port)
if pids:
for pid in pids:
_kill_pid(pid)
logger.info("通过进程终止关闭 Chrome (port=%d)", port)
# 等待端口释放(最多 5s)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if not is_port_open(port):
return
time.sleep(0.5)
if is_port_open(port):
logger.warning("端口 %d 仍被占用,kill 可能未完全生效", port)
def ensure_chrome(
port: int = DEFAULT_PORT,
headless: bool = False,
user_data_dir: str | None = None,
chrome_bin: str | None = None,
) -> bool:
"""确保 Chrome 在指定端口可用(一站式入口)。
如果 Chrome 已在运行,直接返回 True。
否则尝试启动 Chrome 并等待端口就绪。
Args:
port: 远程调试端口。
headless: 是否无头模式(仅新启动时生效)。
user_data_dir: 用户数据目录。
chrome_bin: Chrome 可执行文件路径。
Returns:
True 表示 Chrome 可用,False 表示启动失败。
"""
if is_port_open(port):
return True
try:
launch_chrome(
port=port, headless=headless, user_data_dir=user_data_dir, chrome_bin=chrome_bin,
)
return is_port_open(port)
except FileNotFoundError as e:
logger.error("启动 Chrome 失败: %s", e)
return False
def restart_chrome(
port: int = DEFAULT_PORT,
headless: bool = False,
user_data_dir: str | None = None,
chrome_bin: str | None = None,
) -> subprocess.Popen | None:
"""重启 Chrome:关闭当前实例后以新模式重新启动。
Args:
port: 远程调试端口。
headless: 是否无头模式。
user_data_dir: 用户数据目录。
chrome_bin: Chrome 可执行文件路径。
Returns:
新的 Chrome 子进程,或 None。
"""
logger.info("重启 Chrome: port=%d, headless=%s", port, headless)
kill_chrome(port)
time.sleep(1)
return launch_chrome(
port=port,
headless=headless,
user_data_dir=user_data_dir,
chrome_bin=chrome_bin,
)
def _wait_for_chrome(port: int, timeout: float = 15.0) -> None:
"""等待 Chrome 调试端口就绪(TCP 级检测)。"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if is_port_open(port):
logger.info("Chrome 已就绪 (port=%d)", port)
return
time.sleep(0.5)
logger.warning("等待 Chrome 就绪超时 (port=%d)", port)
def _find_pids_by_port(port: int) -> list[int]:
"""查找占用指定端口的进程 PID(跨平台)。"""
try:
if sys.platform == "win32":
result = subprocess.run(
["netstat", "-ano", "-p", "TCP"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return []
pids: list[int] = []
for line in result.stdout.splitlines():
if f":{port}" in line and "LISTENING" in line:
parts = line.split()
with contextlib.suppress(ValueError, IndexError):
pids.append(int(parts[-1]))
return list(set(pids))
else:
result = subprocess.run(
["lsof", "-ti", f":{port}"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return []
pids = []
for p in result.stdout.strip().split("\n"):
with contextlib.suppress(ValueError):
pids.append(int(p))
return pids
except Exception:
return []
def _kill_pid(pid: int) -> None:
"""终止指定 PID 的进程(跨平台)。"""
try:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/PID", str(pid), "/F"],
capture_output=True,
timeout=5,
)
else:
import signal
os.kill(pid, signal.SIGTERM)
except Exception:
logger.debug("终止进程 %d 失败", pid)
def _mask_proxy(proxy_url: str) -> str:
"""隐藏代理 URL 中的敏感信息。"""
from urllib.parse import urlparse
try:
parsed = urlparse(proxy_url)
if parsed.username:
return proxy_url.replace(parsed.username, "***").replace(parsed.password or "", "***")
except Exception:
pass
return proxy_url
def has_display() -> bool:
"""检测当前环境是否有图形界面(用于自动选择登录方式)。"""
system = platform.system()
if system in ("Windows", "Darwin"):
return True # Windows / macOS 默认有 GUI
# Linux: 检查 DISPLAY 或 WAYLAND_DISPLAY 环境变量
return bool(os.getenv("DISPLAY") or os.getenv("WAYLAND_DISPLAY"))
"""统一 CLI 入口,对应 Go MCP 工具的 13 个子命令。
"""统一 CLI 入口(Extension Bridge 版本)
通过浏览器扩展 Bridge 连接用户已打开的浏览器,无需 Chrome 调试端口。
先启动 bridge_server.py,并在浏览器中安装 XHS Bridge 扩展,再运行此 CLI。
全局选项: --host, --port, --account
输出: JSON(ensure_ascii=False)
退出码: 0=成功, 1=未登录, 2=错误
"""
... ... @@ -8,59 +10,10 @@
from __future__ import annotations
import argparse
import contextlib
import json
import logging
import os
import sys
import tempfile
def _session_tab_file(port: int) -> str:
"""返回指定端口的 session tab 文件路径(每账号独立隔离)。"""
return os.path.join(tempfile.gettempdir(), "xhs", f"session_tab_{port}.txt")
def _login_tab_file(port: int) -> str:
"""返回指定端口的 login tab 文件路径(每账号独立隔离)。"""
return os.path.join(tempfile.gettempdir(), "xhs", f"login_tab_{port}.txt")
def _save_login_tab(target_id: str, port: int) -> None:
path = _login_tab_file(port)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(target_id)
def _load_login_tab(port: int) -> str | None:
with contextlib.suppress(FileNotFoundError):
data = open(_login_tab_file(port)).read().strip()
return data or None
return None
def _clear_login_tab(port: int) -> None:
with contextlib.suppress(FileNotFoundError):
os.remove(_login_tab_file(port))
def _save_session_tab(target_id: str, port: int) -> None:
path = _session_tab_file(port)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(target_id)
def _load_session_tab(port: int) -> str | None:
with contextlib.suppress(FileNotFoundError):
data = open(_session_tab_file(port)).read().strip()
return data or None
return None
def _clear_session_tab(port: int) -> None:
with contextlib.suppress(FileNotFoundError):
os.remove(_session_tab_file(port))
# Windows 控制台默认编码(如 cp1252)不支持中文,强制 UTF-8
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
... ... @@ -75,19 +28,16 @@ logging.basicConfig(
logger = logging.getLogger("xhs-cli")
# ─── 输出工具 ────────────────────────────────────────────────────────────────
def _output(data: dict, exit_code: int = 0) -> None:
"""输出 JSON 并退出。"""
print(json.dumps(data, ensure_ascii=False, indent=2))
sys.exit(exit_code)
def _open_file_if_display(path: str) -> None:
"""有桌面环境时用系统默认程序打开文件,无界面环境静默跳过。"""
from chrome_launcher import has_display
if not has_display():
return
"""有桌面时用系统默认程序打开文件。"""
import platform
import subprocess
... ... @@ -103,210 +53,136 @@ def _open_file_if_display(path: str) -> None:
logger.debug("无法自动打开文件: %s", path)
def _update_account_nickname(args: argparse.Namespace, page) -> None:
"""登录成功后,将平台昵称写入账号描述(best-effort,失败不影响登录结果)。"""
if not getattr(args, "account", ""):
return
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
import account_manager
from xhs.login import get_current_user_nickname
# ─── Bridge 连接 ──────────────────────────────────────────────────────────────
try:
nickname = get_current_user_nickname(page)
if nickname:
account_manager.update_account_description(args.account, nickname)
logger.info("账号 %s 昵称已更新: %s", args.account, nickname)
except Exception as e:
logger.warning("更新账号昵称失败: %s", e)
class _DummyBrowser:
"""空 browser 对象,保持与旧代码的兼容性。"""
def _resolve_account(args: argparse.Namespace) -> str | None:
"""解析 --account 参数,更新 args.port,返回 user_data_dir(无账号时返回 None)。"""
if not getattr(args, "account", ""):
return None
import sys as _sys
def close(self) -> None:
pass
_sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
import account_manager
def close_page(self, page) -> None:
pass
name = args.account
args.port = account_manager.get_account_port(name)
return account_manager.get_profile_dir(name)
def _connect(args: argparse.Namespace):
"""连接到 Chrome 并返回 (browser, page)。
优先复用上次命令留下的 tab(通过端口隔离的 session tab 文件记录),
避免每次命令都新建 tab 导致 Chrome 中 tab 堆积。
"""
from chrome_launcher import ensure_chrome, has_display
from xhs.cdp import Browser
user_data_dir = _resolve_account(args)
if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir):
_output(
{"success": False, "error": "无法启动 Chrome,请检查 Chrome 是否已安装"},
exit_code=2,
def _ensure_bridge_ready(bridge_url: str) -> None:
"""确保 bridge server 在运行、浏览器扩展已连接。若未就绪则自动启动。"""
import subprocess
import time
from pathlib import Path
from xhs.bridge import BridgePage
page = BridgePage(bridge_url)
# ── 1. 检查 bridge server ────────────────────────────────────────
if not page.is_server_running():
logger.info("Bridge server 未运行,正在启动...")
scripts_dir = Path(__file__).parent
kwargs: dict = {}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NEW_CONSOLE
subprocess.Popen(
[sys.executable, str(scripts_dir / "bridge_server.py")],
**kwargs,
)
for _ in range(10):
time.sleep(1)
if page.is_server_running():
logger.info("Bridge server 已启动")
break
else:
logger.warning("Bridge server 启动超时,请手动运行 bridge_server.py")
return
browser = Browser(host=args.host, port=args.port)
browser.connect()
# 优先复用上次命令留下的 tab
saved_id = _load_session_tab(args.port)
if saved_id:
page = browser.get_page_by_target_id(saved_id)
if page:
logger.debug("复用会话 tab: %s", saved_id)
_save_session_tab(page.target_id, args.port)
return browser, page
logger.warning("会话 tab (target_id=%s) 已失效,重新获取", saved_id)
page = browser.get_or_create_page()
_save_session_tab(page.target_id, args.port)
return browser, page
def _connect_saved_tab(args: argparse.Namespace):
"""连接到登录流程中记录的精确 tab,回退到第一个非空白 tab。"""
from chrome_launcher import ensure_chrome, has_display
from xhs.cdp import Browser
# ── 2. 检查扩展是否连接 ──────────────────────────────────────────
if page.is_extension_connected():
return
user_data_dir = _resolve_account(args)
logger.info("浏览器扩展未连接,正在打开 Chrome...")
_open_chrome()
if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir):
_output({"success": False, "error": "无法连接到 Chrome"}, exit_code=2)
for _ in range(20):
time.sleep(1)
if page.is_extension_connected():
logger.info("浏览器扩展已连接")
return
logger.warning("等待扩展连接超时,请确认 Chrome 已安装 XHS Bridge 扩展并已启用")
browser = Browser(host=args.host, port=args.port)
browser.connect()
target_id = _load_login_tab(args.port)
if target_id:
page = browser.get_page_by_target_id(target_id)
if page:
return browser, page
logger.warning("保存的 tab (target_id=%s) 已失效,回退到第一个可用 tab", target_id)
def _open_chrome() -> None:
"""尝试启动 Chrome 浏览器。"""
import subprocess
page = browser.get_existing_page()
if not page:
_output(
{"success": False, "error": "未找到已打开的登录页面,请重新执行登录前置步骤"},
exit_code=2,
)
return browser, page
candidates = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
]
for path in candidates:
if os.path.exists(path):
subprocess.Popen([path])
return
# macOS / Linux fallback
for cmd in [["open", "-a", "Google Chrome"], ["google-chrome"], ["chromium-browser"]]:
try:
subprocess.Popen(cmd)
return
except FileNotFoundError:
continue
logger.warning("找不到 Chrome,请手动打开浏览器")
def _connect_existing(args: argparse.Namespace):
"""连接到 Chrome 并复用已有页面(用于分步发布的后续步骤)。"""
from chrome_launcher import ensure_chrome, has_display
from xhs.cdp import Browser
def _connect(args: argparse.Namespace):
"""返回 (browser, page),browser 为空对象,page 通过 Extension Bridge 操作浏览器。"""
from xhs.bridge import BridgePage
user_data_dir = _resolve_account(args)
bridge_url = getattr(args, "bridge_url", "ws://localhost:9333")
_ensure_bridge_ready(bridge_url)
return _DummyBrowser(), BridgePage(bridge_url)
if not ensure_chrome(port=args.port, headless=not has_display(), user_data_dir=user_data_dir):
_output(
{"success": False, "error": "无法连接到 Chrome"},
exit_code=2,
)
browser = Browser(host=args.host, port=args.port)
browser.connect()
page = browser.get_existing_page()
if not page:
_output(
{"success": False, "error": "未找到已打开的页面,请先执行前置步骤"},
exit_code=2,
)
return browser, page
# _connect_saved_tab / _connect_existing 在 bridge 模式下与 _connect 等价
_connect_saved_tab = _connect
_connect_existing = _connect
def _headless_fallback(port: int) -> None:
"""Headless 模式未登录时的处理:有桌面降级到有窗口模式,无桌面直接报错提示。"""
from chrome_launcher import has_display, restart_chrome
# ─── 子命令实现 ───────────────────────────────────────────────────────────────
if has_display():
logger.info("Headless 模式未登录,切换到有窗口模式...")
restart_chrome(port=port, headless=False)
_output(
{
"success": False,
"error": "未登录",
"action": "switched_to_headed",
"message": "已切换到有窗口模式,请在浏览器中扫码登录",
},
exit_code=1,
)
else:
_output(
{
"success": False,
"error": "未登录",
"action": "login_required",
"message": "无界面环境下请先运行 send-code --phone <手机号> 完成登录",
},
exit_code=1,
)
def _qrcode_fallback(browser, page, args: argparse.Namespace) -> None:
"""频率限制时刷新页面返回二维码,让 AI 直接展示给用户扫码。"""
from xhs.login import (
fetch_qrcode,
make_qrcode_url,
save_qrcode_to_file,
)
"""频率限制时刷新页面返回二维码。"""
from xhs.login import fetch_qrcode, make_qrcode_url, save_qrcode_to_file
from xhs.urls import EXPLORE_URL
# 刷新页面使登录弹窗回到默认的二维码 tab
page.navigate(EXPLORE_URL)
page.wait_for_load()
png_bytes, _b64_orig, already = fetch_qrcode(page)
if already:
browser.close()
_output({"logged_in": True, "message": "已登录"})
return
qrcode_path = save_qrcode_to_file(png_bytes)
image_url, login_url = make_qrcode_url(png_bytes)
_open_file_if_display(qrcode_path)
_save_login_tab(page.target_id, args.port)
_clear_session_tab(args.port)
browser.close()
result: dict = {
"logged_in": False,
"login_method": "qrcode",
"qrcode_path": qrcode_path,
"qrcode_image_url": image_url,
"message": (
"验证码发送受限,已切换为二维码登录,请扫码。"
"扫码后运行 wait-login 等待登录结果。"
),
"message": "验证码发送受限,已切换为二维码登录,请扫码。扫码后运行 wait-login 等待登录结果。",
}
if login_url:
result["qr_login_url"] = login_url
_output(result, exit_code=1)
# ========== 子命令实现 ==========
def cmd_check_login(args: argparse.Namespace) -> None:
"""检查登录状态。未登录时自动获取二维码,省去单独调 get-qrcode 的一轮通信。
直接调 fetch_qrcode 一步完成:导航 + 登录检查 + 二维码获取,
不再经过 check_login_status 避免重复导航和等待。
"""
from xhs.login import (
fetch_qrcode,
make_qrcode_url,
save_qrcode_to_file,
)
"""检查登录状态,未登录时自动获取二维码。"""
from xhs.login import fetch_qrcode, make_qrcode_url, save_qrcode_to_file
browser, page = _connect(args)
try:
... ... @@ -317,215 +193,128 @@ def cmd_check_login(args: argparse.Namespace) -> None:
qrcode_path = save_qrcode_to_file(png_bytes)
image_url, login_url = make_qrcode_url(png_bytes)
# 记录 login tab + 清除 session tab
_save_login_tab(page.target_id, args.port)
_clear_session_tab(args.port)
_open_file_if_display(qrcode_path)
from chrome_launcher import has_display
result: dict = {
"logged_in": False,
"login_method": "qrcode",
"qrcode_path": qrcode_path,
"qrcode_image_url": image_url,
"hint": "未登录,二维码已自动生成。扫码后运行 wait-login 等待登录结果",
}
if login_url:
result["qr_login_url"] = login_url
if has_display():
result["login_method"] = "qrcode"
result["hint"] = (
"未登录,二维码已自动生成。"
"扫码后运行 wait-login 等待登录结果"
)
else:
result["login_method"] = "both"
result["hint"] = (
"未登录,二维码已自动生成。"
"方式A: 直接扫码 + wait-login;"
"方式B: send-code --phone <手机号>"
" + verify-code(手机验证码)"
)
_output(result, exit_code=1)
finally:
# 只断开 CDP 连接,不关闭 tab——保留登录页面
browser.close()
def cmd_login(args: argparse.Namespace) -> None:
"""获取登录二维码并阻塞等待扫码(最多 120 秒)。"""
from xhs.login import fetch_qrcode, save_qrcode_to_file, wait_for_login
"""登录(扫码,阻塞等待完成)。"""
from xhs.login import fetch_qrcode, make_qrcode_url, save_qrcode_to_file, wait_for_login
browser, page = _connect(args)
try:
png_bytes, _b64, already = fetch_qrcode(page)
png_bytes, _b64_orig, already = fetch_qrcode(page)
if already:
_output({"logged_in": True, "message": "已登录"})
return
qrcode_path = save_qrcode_to_file(png_bytes)
image_url, login_url = make_qrcode_url(png_bytes)
_open_file_if_display(qrcode_path)
print(
json.dumps(
{"qrcode_path": qrcode_path, "message": "请扫码登录"},
ensure_ascii=False,
)
)
result: dict = {"qrcode_path": qrcode_path, "qrcode_image_url": image_url}
if login_url:
result["qr_login_url"] = login_url
logger.info("二维码已生成,等待扫码...")
success = wait_for_login(page, timeout=120)
if success:
_update_account_nickname(args, page)
_output(
{"logged_in": success, "message": "登录成功" if success else "登录超时"},
{"logged_in": success, "message": "登录成功" if success else "等待超时"},
exit_code=0 if success else 2,
)
finally:
browser.close_page(page)
browser.close()
def cmd_phone_login(args: argparse.Namespace) -> None:
"""手机号+验证码登录(适用于无界面服务器)。"""
from xhs.errors import RateLimitError
from xhs.login import send_phone_code, submit_phone_code
def cmd_get_qrcode(args: argparse.Namespace) -> None:
"""获取登录二维码截图并立即返回(非阻塞)。"""
from xhs.login import fetch_qrcode, make_qrcode_url, save_qrcode_to_file
browser, page = _connect(args)
try:
sent = send_phone_code(page, args.phone)
except RateLimitError:
# 频率限制——直接切换二维码登录
logger.info("验证码发送受限,切换为二维码登录")
_qrcode_fallback(browser, page, args)
return
try:
if not sent:
_output({"logged_in": True, "message": "已登录,无需重新登录"})
png_bytes, _b64_orig, already = fetch_qrcode(page)
if already:
browser.close_page(page)
browser.close()
_output({"logged_in": True, "message": "已登录"})
return
# 输出提示,等待用户在终端输入验证码
print(
json.dumps(
{
"status": "code_sent",
"message": (
f"验证码已发送至 "
f"{args.phone[:3]}****{args.phone[-4:]}"
),
},
ensure_ascii=False,
),
flush=True,
)
qrcode_path = save_qrcode_to_file(png_bytes)
image_url, login_url = make_qrcode_url(png_bytes)
_open_file_if_display(qrcode_path)
browser.close()
# 从 --code 参数或交互式 stdin 读取验证码
if args.code:
code = args.code.strip()
else:
try:
code = input("请输入验证码: ").strip()
except EOFError:
_output(
{"success": False, "error": "未收到验证码输入"},
exit_code=2,
)
return
result: dict = {
"qrcode_path": qrcode_path,
"qrcode_image_url": image_url,
"message": "二维码已生成,请扫码登录。扫码后运行 wait-login 等待登录结果。",
}
if login_url:
result["qr_login_url"] = login_url
_output(result)
finally:
pass
if not code:
_output(
{"success": False, "error": "验证码不能为空"},
exit_code=2,
)
return
success = submit_phone_code(page, code)
def cmd_wait_login(args: argparse.Namespace) -> None:
"""等待扫码登录完成(配合 get-qrcode 使用)。"""
from xhs.login import wait_for_login
browser, page = _connect_saved_tab(args)
try:
success = wait_for_login(page, timeout=args.timeout)
_output(
{
"logged_in": success,
"message": "登录成功" if success else "验证码错误或超时",
"message": "登录成功" if success else "等待超时,请重新运行 get-qrcode 获取新二维码",
},
exit_code=0 if success else 2,
)
finally:
# 不关闭 tab——与 verify-code 一致,保留页面供重试
browser.close()
def cmd_get_qrcode(args: argparse.Namespace) -> None:
"""获取登录二维码并立即返回(非阻塞)。
从登录弹窗的二维码 img 元素读取图片(data URL 或网络 URL),
保存为本地 PNG 文件后立即退出。Chrome tab 保持打开,QR 会话继续有效。
调用方收到 qrcode_data_url 后直接内嵌到对话窗口显示;同时浏览器窗口(GUI 环境)
也会显示二维码,用户可选择扫任意一个。
"""
from xhs.login import (
fetch_qrcode,
make_qrcode_url,
save_qrcode_to_file,
)
def cmd_phone_login(args: argparse.Namespace) -> None:
"""手机号+验证码登录(交互式)。"""
from xhs.errors import RateLimitError
from xhs.login import send_phone_code, submit_phone_code
browser, page = _connect(args)
try:
sent = send_phone_code(page, args.phone)
if not sent:
_output({"logged_in": True, "message": "已登录,无需重新登录"})
return
png_bytes, _b64_orig, already = fetch_qrcode(page)
if already:
browser.close_page(page)
browser.close()
_output({"logged_in": True, "message": "已登录"})
return
qrcode_path = save_qrcode_to_file(png_bytes)
image_url, login_url = make_qrcode_url(png_bytes)
_open_file_if_display(qrcode_path)
# 记录 login tab,供 wait-login 精确 reconnect
_save_login_tab(page.target_id, args.port)
# 清除 session tab 引用——隔离登录表单,防止其他命令复用
_clear_session_tab(args.port)
# 只断开 CDP 连接,不关闭 tab——QR 会话保持
browser.close()
result: dict = {
"qrcode_path": qrcode_path,
"qrcode_image_url": image_url,
"message": "二维码已生成,请扫码登录。"
"扫码后运行 wait-login 等待登录结果。",
}
if login_url:
result["qr_login_url"] = login_url
_output(result)
def cmd_wait_login(args: argparse.Namespace) -> None:
"""等待扫码登录完成(配合 get-qrcode 使用)。
连接已有 Chrome tab,内部轮询直到登录成功或超时,替代 Skill 层的多次 check-login 轮询。
"""
from xhs.login import wait_for_login
code = args.code
if not code:
code = input("请输入收到的短信验证码: ").strip()
browser, page = _connect_saved_tab(args)
try:
success = wait_for_login(page, timeout=args.timeout)
if success:
_clear_login_tab(args.port)
_update_account_nickname(args, page)
success = submit_phone_code(page, code)
_output(
{
"logged_in": success,
"message": "登录成功" if success else "等待超时,请重新运行 get-qrcode 获取新二维码",
},
{"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"},
exit_code=0 if success else 2,
)
except RateLimitError:
_qrcode_fallback(browser, page, args)
finally:
browser.close()
def cmd_send_code(args: argparse.Namespace) -> None:
"""分步登录第一步:填写手机号并发送验证码,保持页面不关闭。
频率限制时返回错误信息和建议,由 AI 告知用户选择。
"""
"""分步登录第一步:发送手机验证码。"""
from xhs.errors import RateLimitError
from xhs.login import send_phone_code
... ... @@ -535,11 +324,6 @@ def cmd_send_code(args: argparse.Namespace) -> None:
if not sent:
_output({"logged_in": True, "message": "已登录,无需重新登录"})
return
# 记录 login tab,供 verify-code 精确 reconnect
_save_login_tab(page.target_id, args.port)
# 清除 session tab 引用——隔离登录表单,防止其他命令复用并关闭/导航该 tab
_clear_session_tab(args.port)
_output({
"status": "code_sent",
"message": (
... ... @@ -548,54 +332,38 @@ def cmd_send_code(args: argparse.Namespace) -> None:
),
})
except RateLimitError:
# 频率限制——直接切换二维码登录
logger.info("验证码发送受限,切换为二维码登录")
_qrcode_fallback(browser, page, args)
else:
# 只断开控制连接,不关闭页面——tab 保持打开,verify-code 继续复用
finally:
browser.close()
def cmd_verify_code(args: argparse.Namespace) -> None:
"""分步登录第二步:在已有页面上填写验证码并提交。"""
"""分步登录第二步:填写验证码并提交。"""
from xhs.login import submit_phone_code
browser, page = _connect_saved_tab(args)
try:
success = submit_phone_code(page, args.code)
if success:
_clear_login_tab(args.port)
_update_account_nickname(args, page)
_output(
{"logged_in": success, "message": "登录成功" if success else "验证码错误或超时"},
exit_code=0 if success else 2,
)
finally:
# 不关闭 tab——成功后供后续命令复用,失败后用户可再次运行 verify-code 重试
browser.close()
def cmd_delete_cookies(args: argparse.Namespace) -> None:
"""退出登录(页面 UI 点击退出)并删除 cookies 文件。"""
from xhs.cookies import delete_cookies, get_cookies_file_path
"""退出登录(页面 UI 点击退出)。"""
from xhs.login import logout
# 先通过浏览器 UI 退出登录
browser, page = _connect(args)
try:
logged_out = logout(page)
msg = "已退出登录" if logged_out else "未登录"
_output({"success": True, "message": msg})
finally:
browser.close_page(page)
browser.close()
# 再删除本地 cookies 文件
path = get_cookies_file_path(args.account)
delete_cookies(path)
_clear_session_tab(args.port) # 退出登录后清除会话 tab 记录
msg = "已退出登录并删除 cookies" if logged_out else "未登录,已删除 cookies 文件"
_output({"success": True, "message": msg, "cookies_path": path})
def cmd_list_feeds(args: argparse.Namespace) -> None:
"""获取首页 Feed 列表。"""
... ... @@ -606,7 +374,6 @@ def cmd_list_feeds(args: argparse.Namespace) -> None:
feeds = list_feeds(page)
_output({"feeds": [f.to_dict() for f in feeds], "count": len(feeds)})
finally:
browser.close_page(page)
browser.close()
... ... @@ -628,7 +395,6 @@ def cmd_search_feeds(args: argparse.Namespace) -> None:
feeds = search_feeds(page, args.keyword, filter_opt)
_output({"feeds": [f.to_dict() for f in feeds], "count": len(feeds)})
finally:
browser.close_page(page)
browser.close()
... ... @@ -655,7 +421,6 @@ def cmd_get_feed_detail(args: argparse.Namespace) -> None:
)
_output(detail.to_dict())
finally:
browser.close_page(page)
browser.close()
... ... @@ -668,7 +433,6 @@ def cmd_user_profile(args: argparse.Namespace) -> None:
profile = get_user_profile(page, args.user_id, args.xsec_token)
_output(profile.to_dict())
finally:
browser.close_page(page)
browser.close()
... ... @@ -681,7 +445,6 @@ def cmd_post_comment(args: argparse.Namespace) -> None:
post_comment(page, args.feed_id, args.xsec_token, args.content)
_output({"success": True, "message": "评论发送成功"})
finally:
browser.close_page(page)
browser.close()
... ... @@ -701,7 +464,6 @@ def cmd_reply_comment(args: argparse.Namespace) -> None:
)
_output({"success": True, "message": "回复成功"})
finally:
browser.close_page(page)
browser.close()
... ... @@ -717,7 +479,6 @@ def cmd_like_feed(args: argparse.Namespace) -> None:
result = like_feed(page, args.feed_id, args.xsec_token)
_output(result.to_dict())
finally:
browser.close_page(page)
browser.close()
... ... @@ -733,38 +494,26 @@ def cmd_favorite_feed(args: argparse.Namespace) -> None:
result = favorite_feed(page, args.feed_id, args.xsec_token)
_output(result.to_dict())
finally:
browser.close_page(page)
browser.close()
def cmd_publish(args: argparse.Namespace) -> None:
"""发布图文内容。"""
from image_downloader import process_images
from xhs.login import check_login_status
from xhs.publish import publish_image_content
from xhs.types import PublishImageContent
# 读取标题和正文
with open(args.title_file, encoding="utf-8") as f:
title = f.read().strip()
with open(args.content_file, encoding="utf-8") as f:
content = f.read().strip()
# 处理图片
image_paths = process_images(args.images) if args.images else []
if not image_paths:
_output({"success": False, "error": "没有有效的图片"}, exit_code=2)
browser, page = _connect(args)
try:
# headless 模式登录检查 + 自动降级
headless = getattr(args, "headless", False)
if headless and not check_login_status(page):
browser.close_page(page)
browser.close()
_headless_fallback(args.port)
return
publish_image_content(
page,
PublishImageContent(
... ... @@ -779,7 +528,6 @@ def cmd_publish(args: argparse.Namespace) -> None:
)
_output({"success": True, "title": title, "images": len(image_paths), "status": "发布完成"})
finally:
browser.close_page(page)
browser.close()
... ... @@ -812,16 +560,8 @@ def cmd_fill_publish(args: argparse.Namespace) -> None:
visibility=args.visibility or "",
),
)
_output(
{
"success": True,
"title": title,
"images": len(image_paths),
"status": "表单已填写,等待确认发布",
}
)
_output({"success": True, "title": title, "images": len(image_paths), "status": "表单已填写,等待确认发布"})
finally:
# 不关闭页面,让用户在浏览器中预览
browser.close()
... ... @@ -848,21 +588,13 @@ def cmd_fill_publish_video(args: argparse.Namespace) -> None:
visibility=args.visibility or "",
),
)
_output(
{
"success": True,
"title": title,
"video": args.video,
"status": "视频表单已填写,等待确认发布",
}
)
_output({"success": True, "title": title, "video": args.video, "status": "视频表单已填写,等待确认发布"})
finally:
# 不关闭页面,让用户在浏览器中预览
browser.close()
def cmd_click_publish(args: argparse.Namespace) -> None:
"""点击发布按钮(在用户确认后调用)。复用已有的发布页 tab。"""
"""点击发布按钮(在用户确认后调用)。"""
from xhs.publish import click_publish_button
browser, page = _connect_existing(args)
... ... @@ -870,12 +602,11 @@ def cmd_click_publish(args: argparse.Namespace) -> None:
click_publish_button(page)
_output({"success": True, "status": "发布完成"})
finally:
browser.close_page(page)
browser.close()
def cmd_save_draft(args: argparse.Namespace) -> None:
"""保存为草稿(取消发布时调用)。"""
"""保存为草稿。"""
from xhs.publish import save_as_draft
browser, page = _connect_existing(args)
... ... @@ -883,7 +614,6 @@ def cmd_save_draft(args: argparse.Namespace) -> None:
save_as_draft(page)
_output({"success": True, "status": "内容已保存到草稿箱"})
finally:
browser.close_page(page)
browser.close()
... ... @@ -904,20 +634,13 @@ def cmd_long_article(args: argparse.Namespace) -> None:
content=content,
image_paths=args.images,
)
_output(
{
"success": True,
"templates": template_names,
"status": "长文已填写,请选择模板",
}
)
_output({"success": True, "templates": template_names, "status": "长文已填写,请选择模板"})
finally:
# 不关闭页面,后续 select-template / next-step 需要复用
browser.close()
def cmd_select_template(args: argparse.Namespace) -> None:
"""选择排版模板。复用已有的长文编辑页 tab。"""
"""选择排版模板。"""
from xhs.publish_long_article import select_template
browser, page = _connect_existing(args)
... ... @@ -926,17 +649,13 @@ def cmd_select_template(args: argparse.Namespace) -> None:
if selected:
_output({"success": True, "template": args.name, "status": "模板已选择"})
else:
_output(
{"success": False, "error": f"未找到模板: {args.name}"},
exit_code=2,
)
_output({"success": False, "error": f"未找到模板: {args.name}"}, exit_code=2)
finally:
# 不关闭页面,后续 next-step 需要复用
browser.close()
def cmd_next_step(args: argparse.Namespace) -> None:
"""点击下一步 + 填写发布页描述。复用已有的长文编辑页 tab。"""
"""点击下一步 + 填写发布页描述。"""
from xhs.publish_long_article import click_next_and_fill_description
with open(args.content_file, encoding="utf-8") as f:
... ... @@ -947,13 +666,11 @@ def cmd_next_step(args: argparse.Namespace) -> None:
click_next_and_fill_description(page, description)
_output({"success": True, "status": "已进入发布页,等待确认发布"})
finally:
# 不关闭页面,等待 click-publish
browser.close()
def cmd_publish_video(args: argparse.Namespace) -> None:
"""发布视频内容。"""
from xhs.login import check_login_status
from xhs.publish_video import publish_video_content
from xhs.types import PublishVideoContent
... ... @@ -964,14 +681,6 @@ def cmd_publish_video(args: argparse.Namespace) -> None:
browser, page = _connect(args)
try:
# headless 模式登录检查 + 自动降级
headless = getattr(args, "headless", False)
if headless and not check_login_status(page):
browser.close_page(page)
browser.close()
_headless_fallback(args.port)
return
publish_video_content(
page,
PublishVideoContent(
... ... @@ -985,73 +694,22 @@ def cmd_publish_video(args: argparse.Namespace) -> None:
)
_output({"success": True, "title": title, "video": args.video, "status": "发布完成"})
finally:
browser.close_page(page)
browser.close()
# ========== 账号管理子命令 ==========
def cmd_add_account(args: argparse.Namespace) -> None:
"""添加命名账号,自动分配独立端口和 Chrome Profile。"""
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
import account_manager
account_manager.add_account(args.name, description=args.description or "")
port = account_manager.get_account_port(args.name)
profile = account_manager.get_profile_dir(args.name)
_output({"success": True, "name": args.name, "port": port, "profile_dir": profile})
def cmd_list_accounts(args: argparse.Namespace) -> None:
"""列出所有命名账号。"""
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
import account_manager
accounts = account_manager.list_accounts()
_output({"accounts": accounts, "count": len(accounts)})
def cmd_remove_account(args: argparse.Namespace) -> None:
"""删除命名账号。"""
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
import account_manager
account_manager.remove_account(args.name)
_output({"success": True, "name": args.name})
def cmd_set_default_account(args: argparse.Namespace) -> None:
"""设置默认账号。"""
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
import account_manager
account_manager.set_default_account(args.name)
_output({"success": True, "default": args.name})
# ========== 参数解析 ==========
# ─── 参数解析 ──────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
"""构建 CLI 参数解析器。"""
parser = argparse.ArgumentParser(
prog="xhs-cli",
description="小红书自动化 CLI",
description="小红书自动化 CLI(Extension Bridge 版)",
)
parser.add_argument(
"--bridge-url",
default="ws://localhost:9333",
help="Bridge server WebSocket 地址 (default: ws://localhost:9333)",
)
# 全局选项
parser.add_argument("--host", default="127.0.0.1", help="Chrome 调试主机 (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=9222, help="Chrome 调试端口 (default: 9222)")
parser.add_argument("--account", default="", help="账号名称")
subparsers = parser.add_subparsers(dest="command", required=True)
... ... @@ -1063,33 +721,33 @@ def build_parser() -> argparse.ArgumentParser:
sub = subparsers.add_parser("login", help="登录(扫码,阻塞等待)")
sub.set_defaults(func=cmd_login)
# get-qrcode(非阻塞,截图后立即返回)
sub = subparsers.add_parser("get-qrcode", help="获取登录二维码截图并立即返回(非阻塞)")
# get-qrcode
sub = subparsers.add_parser("get-qrcode", help="获取登录二维码截图(非阻塞)")
sub.set_defaults(func=cmd_get_qrcode)
# wait-login(配合 get-qrcode,阻塞等待登录完成)
sub = subparsers.add_parser("wait-login", help="等待扫码登录完成(配合 get-qrcode 使用)")
# wait-login
sub = subparsers.add_parser("wait-login", help="等待扫码登录完成(配合 get-qrcode)")
sub.add_argument("--timeout", type=float, default=120.0, help="等待超时秒数 (default: 120)")
sub.set_defaults(func=cmd_wait_login)
# phone-login(单命令交互式)
sub = subparsers.add_parser("phone-login", help="手机号+验证码登录(交互式,适合本地终端)")
sub.add_argument("--phone", required=True, help="手机号(不含国家码,如 13800138000)")
# phone-login
sub = subparsers.add_parser("phone-login", help="手机号+验证码登录(交互式)")
sub.add_argument("--phone", required=True, help="手机号")
sub.add_argument("--code", default="", help="短信验证码(省略则交互式输入)")
sub.set_defaults(func=cmd_phone_login)
# send-code(分步登录第一步)
sub = subparsers.add_parser("send-code", help="分步登录第一步:发送手机验证码,保持页面不关闭")
sub.add_argument("--phone", required=True, help="手机号(不含国家码)")
# send-code
sub = subparsers.add_parser("send-code", help="分步登录第一步:发送手机验证码")
sub.add_argument("--phone", required=True, help="手机号")
sub.set_defaults(func=cmd_send_code)
# verify-code(分步登录第二步)
sub = subparsers.add_parser("verify-code", help="分步登录第二步:填写验证码并完成登录")
sub.add_argument("--code", required=True, help="收到的短信验证码")
# verify-code
sub = subparsers.add_parser("verify-code", help="分步登录第二步:填写验证码")
sub.add_argument("--code", required=True, help="短信验证码")
sub.set_defaults(func=cmd_verify_code)
# delete-cookies
sub = subparsers.add_parser("delete-cookies", help="删除 cookies")
sub = subparsers.add_parser("delete-cookies", help="退出登录")
sub.set_defaults(func=cmd_delete_cookies)
# list-feeds
... ... @@ -1111,142 +769,119 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_argument("--feed-id", required=True, help="Feed ID")
sub.add_argument("--xsec-token", required=True, help="xsec_token")
sub.add_argument("--load-all-comments", action="store_true", help="加载全部评论")
sub.add_argument("--click-more-replies", action="store_true", help="点击展开更多回复")
sub.add_argument("--max-replies-threshold", type=int, default=10, help="展开回复数阈值")
sub.add_argument("--max-comment-items", type=int, default=0, help="最大评论数 (0=不限)")
sub.add_argument("--scroll-speed", default="normal", help="滚动速度: slow|normal|fast")
sub.add_argument("--click-more-replies", action="store_true", help="展开更多回复")
sub.add_argument("--max-replies-threshold", type=int, default=10)
sub.add_argument("--max-comment-items", type=int, default=0)
sub.add_argument("--scroll-speed", default="normal", help="slow|normal|fast")
sub.set_defaults(func=cmd_get_feed_detail)
# user-profile
sub = subparsers.add_parser("user-profile", help="获取用户主页")
sub.add_argument("--user-id", required=True, help="用户 ID")
sub.add_argument("--xsec-token", required=True, help="xsec_token")
sub.add_argument("--user-id", required=True)
sub.add_argument("--xsec-token", required=True)
sub.set_defaults(func=cmd_user_profile)
# post-comment
sub = subparsers.add_parser("post-comment", help="发表评论")
sub.add_argument("--feed-id", required=True, help="Feed ID")
sub.add_argument("--xsec-token", required=True, help="xsec_token")
sub.add_argument("--content", required=True, help="评论内容")
sub.add_argument("--feed-id", required=True)
sub.add_argument("--xsec-token", required=True)
sub.add_argument("--content", required=True)
sub.set_defaults(func=cmd_post_comment)
# reply-comment
sub = subparsers.add_parser("reply-comment", help="回复评论")
sub.add_argument("--feed-id", required=True, help="Feed ID")
sub.add_argument("--xsec-token", required=True, help="xsec_token")
sub.add_argument("--content", required=True, help="回复内容")
sub.add_argument("--comment-id", help="目标评论 ID")
sub.add_argument("--user-id", help="目标用户 ID")
sub.add_argument("--feed-id", required=True)
sub.add_argument("--xsec-token", required=True)
sub.add_argument("--content", required=True)
sub.add_argument("--comment-id")
sub.add_argument("--user-id")
sub.set_defaults(func=cmd_reply_comment)
# like-feed
sub = subparsers.add_parser("like-feed", help="点赞")
sub.add_argument("--feed-id", required=True, help="Feed ID")
sub.add_argument("--xsec-token", required=True, help="xsec_token")
sub.add_argument("--unlike", action="store_true", help="取消点赞")
sub.add_argument("--feed-id", required=True)
sub.add_argument("--xsec-token", required=True)
sub.add_argument("--unlike", action="store_true")
sub.set_defaults(func=cmd_like_feed)
# favorite-feed
sub = subparsers.add_parser("favorite-feed", help="收藏")
sub.add_argument("--feed-id", required=True, help="Feed ID")
sub.add_argument("--xsec-token", required=True, help="xsec_token")
sub.add_argument("--unfavorite", action="store_true", help="取消收藏")
sub.add_argument("--feed-id", required=True)
sub.add_argument("--xsec-token", required=True)
sub.add_argument("--unfavorite", action="store_true")
sub.set_defaults(func=cmd_favorite_feed)
# publish
sub = subparsers.add_parser("publish", help="发布图文")
sub.add_argument("--title-file", required=True, help="标题文件路径")
sub.add_argument("--content-file", required=True, help="正文文件路径")
sub.add_argument("--images", nargs="+", required=True, help="图片路径/URL")
sub.add_argument("--tags", nargs="*", help="标签")
sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
sub.add_argument("--original", action="store_true", help="声明原创")
sub.add_argument("--visibility", help="可见范围")
sub.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
sub.add_argument("--title-file", required=True)
sub.add_argument("--content-file", required=True)
sub.add_argument("--images", nargs="+", required=True)
sub.add_argument("--tags", nargs="*")
sub.add_argument("--schedule-at")
sub.add_argument("--original", action="store_true")
sub.add_argument("--visibility")
sub.set_defaults(func=cmd_publish)
# publish-video
sub = subparsers.add_parser("publish-video", help="发布视频")
sub.add_argument("--title-file", required=True, help="标题文件路径")
sub.add_argument("--content-file", required=True, help="正文文件路径")
sub.add_argument("--video", required=True, help="视频文件路径")
sub.add_argument("--tags", nargs="*", help="标签")
sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
sub.add_argument("--visibility", help="可见范围")
sub.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
sub.add_argument("--title-file", required=True)
sub.add_argument("--content-file", required=True)
sub.add_argument("--video", required=True)
sub.add_argument("--tags", nargs="*")
sub.add_argument("--schedule-at")
sub.add_argument("--visibility")
sub.set_defaults(func=cmd_publish_video)
# fill-publish(只填写图文表单,不发布)
# fill-publish
sub = subparsers.add_parser("fill-publish", help="填写图文表单(不发布)")
sub.add_argument("--title-file", required=True, help="标题文件路径")
sub.add_argument("--content-file", required=True, help="正文文件路径")
sub.add_argument("--images", nargs="+", required=True, help="图片路径/URL")
sub.add_argument("--tags", nargs="*", help="标签")
sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
sub.add_argument("--original", action="store_true", help="声明原创")
sub.add_argument("--visibility", help="可见范围")
sub.add_argument("--title-file", required=True)
sub.add_argument("--content-file", required=True)
sub.add_argument("--images", nargs="+", required=True)
sub.add_argument("--tags", nargs="*")
sub.add_argument("--schedule-at")
sub.add_argument("--original", action="store_true")
sub.add_argument("--visibility")
sub.set_defaults(func=cmd_fill_publish)
# fill-publish-video(只填写视频表单,不发布)
# fill-publish-video
sub = subparsers.add_parser("fill-publish-video", help="填写视频表单(不发布)")
sub.add_argument("--title-file", required=True, help="标题文件路径")
sub.add_argument("--content-file", required=True, help="正文文件路径")
sub.add_argument("--video", required=True, help="视频文件路径")
sub.add_argument("--tags", nargs="*", help="标签")
sub.add_argument("--schedule-at", help="定时发布 (ISO8601)")
sub.add_argument("--visibility", help="可见范围")
sub.add_argument("--title-file", required=True)
sub.add_argument("--content-file", required=True)
sub.add_argument("--video", required=True)
sub.add_argument("--tags", nargs="*")
sub.add_argument("--schedule-at")
sub.add_argument("--visibility")
sub.set_defaults(func=cmd_fill_publish_video)
# click-publish(点击发布按钮)
# click-publish
sub = subparsers.add_parser("click-publish", help="点击发布按钮")
sub.set_defaults(func=cmd_click_publish)
# long-article(长文模式)
# save-draft
sub = subparsers.add_parser("save-draft", help="保存为草稿")
sub.set_defaults(func=cmd_save_draft)
# long-article
sub = subparsers.add_parser("long-article", help="长文模式:填写 + 一键排版")
sub.add_argument("--title-file", required=True, help="标题文件路径")
sub.add_argument("--content-file", required=True, help="正文文件路径")
sub.add_argument("--images", nargs="*", help="可选图片路径")
sub.add_argument("--title-file", required=True)
sub.add_argument("--content-file", required=True)
sub.add_argument("--images", nargs="*")
sub.set_defaults(func=cmd_long_article)
# select-template(选择模板)
# select-template
sub = subparsers.add_parser("select-template", help="选择排版模板")
sub.add_argument("--name", required=True, help="模板名称")
sub.add_argument("--name", required=True)
sub.set_defaults(func=cmd_select_template)
# next-step(下一步 + 填写描述)
# next-step
sub = subparsers.add_parser("next-step", help="点击下一步 + 填写描述")
sub.add_argument("--content-file", required=True, help="描述内容文件路径")
sub.add_argument("--content-file", required=True)
sub.set_defaults(func=cmd_next_step)
# save-draft(保存草稿)
sub = subparsers.add_parser("save-draft", help="保存为草稿(取消发布时使用)")
sub.set_defaults(func=cmd_save_draft)
# add-account(添加命名账号)
sub = subparsers.add_parser("add-account", help="添加命名账号,自动分配独立端口")
sub.add_argument("--name", required=True, help="账号名称")
sub.add_argument("--description", default="", help="账号描述(可选)")
sub.set_defaults(func=cmd_add_account)
# list-accounts(列出所有账号)
sub = subparsers.add_parser("list-accounts", help="列出所有命名账号")
sub.set_defaults(func=cmd_list_accounts)
# remove-account(删除账号)
sub = subparsers.add_parser("remove-account", help="删除命名账号")
sub.add_argument("--name", required=True, help="账号名称")
sub.set_defaults(func=cmd_remove_account)
# set-default-account(设置默认账号)
sub = subparsers.add_parser("set-default-account", help="设置默认账号")
sub.add_argument("--name", required=True, help="账号名称")
sub.set_defaults(func=cmd_set_default_account)
return parser
def main() -> None:
"""CLI 入口。"""
parser = build_parser()
args = parser.parse_args()
... ...
"""发布编排器:下载 → 登录检查 → 发布 → 报告。"""
from __future__ import annotations
import json
import logging
import sys
from image_downloader import process_images
from title_utils import calc_title_length
from xhs.cdp import Browser
from xhs.login import check_login_status
from xhs.publish import publish_image_content
from xhs.publish_video import publish_video_content
from xhs.types import PublishImageContent, PublishVideoContent
logger = logging.getLogger(__name__)
def run_publish_pipeline(
title: str,
content: str,
images: list[str] | None = None,
video: str | None = None,
tags: list[str] | None = None,
schedule_time: str | None = None,
is_original: bool = False,
visibility: str = "",
host: str = "127.0.0.1",
port: int = 9222,
account: str = "",
headless: bool = False,
) -> dict:
"""执行完整发布流水线。
当 headless=True 且未登录时,自动降级到有窗口模式。
Returns:
发布结果字典。
"""
# 标题长度校验
title_len = calc_title_length(title)
if title_len > 20:
return {"success": False, "error": f"标题长度超限: {title_len}/20"}
# 处理图片(下载 URL / 验证本地路径)
local_images: list[str] = []
if images:
local_images = process_images(images)
if not local_images:
return {"success": False, "error": "没有有效的图片"}
# 连接浏览器
browser = Browser(host=host, port=port)
browser.connect()
try:
page = browser.new_page()
try:
# 登录检查
if not check_login_status(page):
browser.close_page(page)
browser.close()
# Headless 自动降级:切换到有窗口模式
if headless:
from chrome_launcher import restart_chrome
logger.info("Headless 模式未登录,切换到有窗口模式...")
restart_chrome(port=port, headless=False)
return {
"success": False,
"error": "未登录",
"action": "switched_to_headed",
"message": "已切换到有窗口模式,请在浏览器中扫码登录",
"exit_code": 1,
}
return {
"success": False,
"error": "未登录",
"exit_code": 1,
}
# 发布
if video:
publish_video_content(
page,
PublishVideoContent(
title=title,
content=content,
tags=tags or [],
video_path=video,
schedule_time=schedule_time,
visibility=visibility,
),
)
else:
publish_image_content(
page,
PublishImageContent(
title=title,
content=content,
tags=tags or [],
image_paths=local_images,
schedule_time=schedule_time,
is_original=is_original,
visibility=visibility,
),
)
return {
"success": True,
"title": title,
"content_length": len(content),
"images": len(local_images),
"video": video or "",
"status": "发布完成",
}
finally:
browser.close_page(page)
finally:
browser.close()
def main() -> None:
"""CLI 入口(被 cli.py 的 publish/publish-video 子命令调用时使用)。"""
import argparse
parser = argparse.ArgumentParser(description="小红书发布流水线")
parser.add_argument("--title-file", required=True, help="标题文件路径")
parser.add_argument("--content-file", required=True, help="正文文件路径")
parser.add_argument("--images", nargs="*", help="图片路径或 URL 列表")
parser.add_argument("--video", help="视频文件路径")
parser.add_argument("--tags", nargs="*", help="标签列表")
parser.add_argument("--schedule-at", help="定时发布时间 (ISO8601)")
parser.add_argument("--original", action="store_true", help="声明原创")
parser.add_argument("--visibility", default="", help="可见范围")
parser.add_argument("--headless", action="store_true", help="无头模式(未登录自动降级)")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=9222)
parser.add_argument("--account", default="")
args = parser.parse_args()
# 读取标题和正文
with open(args.title_file, encoding="utf-8") as f:
title = f.read().strip()
with open(args.content_file, encoding="utf-8") as f:
content = f.read().strip()
result = run_publish_pipeline(
title=title,
content=content,
images=args.images,
video=args.video,
tags=args.tags,
schedule_time=args.schedule_at,
is_original=args.original,
visibility=args.visibility,
host=args.host,
port=args.port,
account=args.account,
headless=args.headless,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
exit_code = result.get("exit_code", 0 if result["success"] else 2)
sys.exit(exit_code)
if __name__ == "__main__":
main()
"""测试无头环境下手机登录流程中 headless 参数传递是否正确。
模拟 Linux 无桌面环境(has_display() = False),验证修复后的代码路径。
"""
from __future__ import annotations
import argparse
import sys
from unittest.mock import MagicMock, call, patch
import pytest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent))
# ---------- 工具 ----------
def _make_args(**kwargs) -> argparse.Namespace:
defaults = dict(host="127.0.0.1", port=9222, account="")
return argparse.Namespace(**{**defaults, **kwargs})
# ---------- Bug 2:_connect / _connect_existing ----------
class TestConnectHeadless:
"""_connect 和 _connect_existing 在无头环境下应传 headless=True。"""
def test_connect_headless_when_no_display(self):
mock_page = MagicMock()
mock_browser_inst = MagicMock()
mock_browser_inst.new_page.return_value = mock_page
with (
patch("chrome_launcher.has_display", return_value=False),
patch("chrome_launcher.ensure_chrome", return_value=True) as mock_ensure,
patch("xhs.cdp.Browser", return_value=mock_browser_inst),
):
import cli
cli._connect(_make_args())
mock_ensure.assert_called_once_with(port=9222, headless=True)
def test_connect_headed_when_has_display(self):
mock_page = MagicMock()
mock_browser_inst = MagicMock()
mock_browser_inst.new_page.return_value = mock_page
with (
patch("chrome_launcher.has_display", return_value=True),
patch("chrome_launcher.ensure_chrome", return_value=True) as mock_ensure,
patch("xhs.cdp.Browser", return_value=mock_browser_inst),
):
import cli
cli._connect(_make_args())
mock_ensure.assert_called_once_with(port=9222, headless=False)
def test_connect_existing_headless_when_no_display(self):
mock_page = MagicMock()
mock_browser_inst = MagicMock()
mock_browser_inst.get_existing_page.return_value = mock_page
with (
patch("chrome_launcher.has_display", return_value=False),
patch("chrome_launcher.ensure_chrome", return_value=True) as mock_ensure,
patch("xhs.cdp.Browser", return_value=mock_browser_inst),
):
import cli
cli._connect_existing(_make_args())
mock_ensure.assert_called_once_with(port=9222, headless=True)
# ---------- Bug 1:send-code RateLimitError 重启 ----------
class TestSendCodeRateLimit:
"""触发频率限制时,重启 Chrome 应使用正确的 headless 参数。"""
def _run_send_code(self, has_display_value: bool):
"""运行 cmd_send_code 并触发 RateLimitError,返回 restart_chrome 的调用记录。"""
from xhs.errors import RateLimitError
mock_page = MagicMock()
mock_browser_inst = MagicMock()
mock_browser_inst.new_page.return_value = mock_page
with (
patch("chrome_launcher.has_display", return_value=has_display_value),
patch("chrome_launcher.ensure_chrome", return_value=True),
patch("chrome_launcher.restart_chrome") as mock_restart,
patch("xhs.cdp.Browser", return_value=mock_browser_inst),
patch("xhs.login.send_phone_code", side_effect=[RateLimitError(), True]),
pytest.raises(SystemExit), # _output 会 sys.exit
):
import cli
cli.cmd_send_code(_make_args(phone="13800138000"))
return mock_restart
def test_rate_limit_restart_headless_when_no_display(self):
mock_restart = self._run_send_code(has_display_value=False)
mock_restart.assert_called_once_with(port=9222, headless=True)
def test_rate_limit_restart_headed_when_has_display(self):
mock_restart = self._run_send_code(has_display_value=True)
mock_restart.assert_called_once_with(port=9222, headless=False)
# ---------- Bug 3:_headless_fallback ----------
class TestHeadlessFallback:
"""_headless_fallback 在有/无桌面时行为应不同。"""
def test_no_display_returns_error_without_restart(self):
with (
patch("chrome_launcher.has_display", return_value=False),
patch("chrome_launcher.restart_chrome") as mock_restart,
pytest.raises(SystemExit) as exc_info,
):
import io, json
from contextlib import redirect_stdout
buf = io.StringIO()
with redirect_stdout(buf):
import cli
cli._headless_fallback(port=9222)
mock_restart.assert_not_called()
assert exc_info.value.code == 1
output = json.loads(buf.getvalue())
assert output["action"] == "login_required"
assert "send-code" in output["message"]
def test_has_display_restarts_headed(self):
with (
patch("chrome_launcher.has_display", return_value=True),
patch("chrome_launcher.restart_chrome") as mock_restart,
pytest.raises(SystemExit),
):
import cli
cli._headless_fallback(port=9222)
mock_restart.assert_called_once_with(port=9222, headless=False)
"""BridgePage - 通过浏览器扩展 Bridge 实现与 CDP Page 相同的接口。
CLI 命令通过 WebSocket 发送到 bridge_server.py,
bridge_server 转发给浏览器扩展执行,结果原路返回。
每次调用都是一次短连接(发一条命令 → 收一条回复),
不需要维护持久连接。
"""
from __future__ import annotations
import base64
import json
import os
from typing import Any
import websockets.sync.client as ws_client
from .errors import CDPError, ElementNotFoundError
BRIDGE_URL = "ws://localhost:9333"
class BridgePage:
"""与 CDP Page 接口兼容的 Extension Bridge 实现。"""
def __init__(self, bridge_url: str = BRIDGE_URL) -> None:
self._bridge_url = bridge_url
# ─── 内部通信 ───────────────────────────────────────────────
def _call(self, method: str, params: dict | None = None) -> Any:
"""向 bridge server 发送一条命令并等待结果。"""
msg: dict[str, Any] = {"role": "cli", "method": method}
if params:
msg["params"] = params
try:
with ws_client.connect(self._bridge_url, max_size=50 * 1024 * 1024) as ws:
ws.send(json.dumps(msg, ensure_ascii=False))
raw = ws.recv(timeout=90)
except OSError as e:
raise CDPError(f"无法连接到 bridge server(ws://localhost:9333): {e}") from e
resp = json.loads(raw)
if "error" in resp and resp["error"]:
raise CDPError(f"Bridge 错误: {resp['error']}")
return resp.get("result")
# ─── 导航 ───────────────────────────────────────────────────
def navigate(self, url: str) -> None:
self._call("navigate", {"url": url})
def wait_for_load(self, timeout: float = 60.0) -> None:
self._call("wait_for_load", {"timeout": int(timeout * 1000)})
def wait_dom_stable(self, timeout: float = 10.0, interval: float = 0.5) -> None:
self._call("wait_dom_stable", {
"timeout": int(timeout * 1000),
"interval": int(interval * 1000),
})
# ─── JavaScript 执行 ────────────────────────────────────────
def evaluate(self, expression: str, timeout: float = 30.0) -> Any:
return self._call("evaluate", {"expression": expression})
def evaluate_function(self, function_body: str, *args: Any) -> Any:
return self._call("evaluate", {"expression": f"({function_body})()"})
# ─── 元素查询 ────────────────────────────────────────────────
def query_selector(self, selector: str) -> str | None:
"""返回 "found" 表示元素存在,None 表示不存在(兼容 CDP 的 objectId 语义)。"""
found = self._call("has_element", {"selector": selector})
return "found" if found else None
def query_selector_all(self, selector: str) -> list[str]:
count = self.get_elements_count(selector)
return ["found"] * count
def has_element(self, selector: str) -> bool:
return bool(self._call("has_element", {"selector": selector}))
def wait_for_element(self, selector: str, timeout: float = 30.0) -> str:
found = self._call("wait_for_selector", {
"selector": selector,
"timeout": int(timeout * 1000),
})
if not found:
raise ElementNotFoundError(selector)
return "found"
# ─── 元素操作 ────────────────────────────────────────────────
def click_element(self, selector: str) -> None:
self._call("click_element", {"selector": selector})
def input_text(self, selector: str, text: str) -> None:
self._call("input_text", {"selector": selector, "text": text})
def input_content_editable(self, selector: str, text: str) -> None:
self._call("input_content_editable", {"selector": selector, "text": text})
def get_element_text(self, selector: str) -> str | None:
return self._call("get_element_text", {"selector": selector})
def get_element_attribute(self, selector: str, attr: str) -> str | None:
return self._call("get_element_attribute", {"selector": selector, "attr": attr})
def get_elements_count(self, selector: str) -> int:
result = self._call("get_elements_count", {"selector": selector})
return int(result) if result is not None else 0
def remove_element(self, selector: str) -> None:
self._call("remove_element", {"selector": selector})
def hover_element(self, selector: str) -> None:
self._call("hover_element", {"selector": selector})
def select_all_text(self, selector: str) -> None:
self._call("select_all_text", {"selector": selector})
# ─── 滚动 ────────────────────────────────────────────────────
def scroll_by(self, x: int, y: int) -> None:
self._call("scroll_by", {"x": x, "y": y})
def scroll_to(self, x: int, y: int) -> None:
self._call("scroll_to", {"x": x, "y": y})
def scroll_to_bottom(self) -> None:
self._call("scroll_to_bottom")
def scroll_element_into_view(self, selector: str) -> None:
self._call("scroll_element_into_view", {"selector": selector})
def scroll_nth_element_into_view(self, selector: str, index: int) -> None:
self._call("scroll_nth_element_into_view", {"selector": selector, "index": index})
def get_scroll_top(self) -> int:
result = self._call("get_scroll_top")
return int(result) if result is not None else 0
def get_viewport_height(self) -> int:
result = self._call("get_viewport_height")
return int(result) if result is not None else 768
# ─── 输入事件 ────────────────────────────────────────────────
def press_key(self, key: str) -> None:
self._call("press_key", {"key": key})
def type_text(self, text: str, delay_ms: int = 50) -> None:
self._call("type_text", {"text": text, "delayMs": delay_ms})
def mouse_move(self, x: float, y: float) -> None:
self._call("mouse_move", {"x": x, "y": y})
def mouse_click(self, x: float, y: float, button: str = "left") -> None:
self._call("mouse_click", {"x": x, "y": y, "button": button})
def dispatch_wheel_event(self, delta_y: float) -> None:
self._call("dispatch_wheel_event", {"deltaY": delta_y})
# ─── 文件上传 ────────────────────────────────────────────────
def set_file_input(self, selector: str, files: list[str]) -> None:
"""通过 chrome.debugger + DOM.setFileInputFiles 上传本地文件。
传递绝对路径给扩展,由扩展调用 CDP 完成上传(与原 CDP 方式等价)。
"""
# 统一转换为绝对路径(兼容 Windows 反斜杠)
abs_paths = [os.path.abspath(path) for path in files]
self._call("set_file_input", {"selector": selector, "files": abs_paths})
# ─── 截图 ────────────────────────────────────────────────────
def screenshot_element(self, selector: str, padding: int = 0) -> bytes:
result = self._call("screenshot_element", {"selector": selector, "padding": padding})
if result and result.get("data"):
return base64.b64decode(result["data"])
return b""
# ─── 无操作(原 CDP 专有功能,扩展模式不需要) ─────────────────
def inject_stealth(self) -> None:
"""不需要注入 stealth 脚本——直接使用用户浏览器,无需伪装。"""
# ─── 兼容性辅助方法 ──────────────────────────────────────────
def is_server_running(self) -> bool:
"""检查 bridge server 是否在运行(不需要 extension 已连接)。"""
try:
with ws_client.connect(self._bridge_url, open_timeout=3) as ws:
ws.send(json.dumps({"role": "cli", "method": "ping_server"}))
raw = ws.recv(timeout=5)
resp = json.loads(raw)
return "result" in resp
except Exception:
return False
def is_extension_connected(self) -> bool:
"""检查浏览器扩展是否已连接到 bridge server。"""
try:
with ws_client.connect(self._bridge_url, open_timeout=3) as ws:
ws.send(json.dumps({"role": "cli", "method": "ping_server"}))
raw = ws.recv(timeout=5)
resp = json.loads(raw)
return bool(resp.get("result", {}).get("extension_connected"))
except Exception:
return False
@property
def target_id(self) -> str:
"""兼容旧代码对 page.target_id 的引用。"""
return "extension-bridge"
... ...
... ... @@ -15,7 +15,6 @@ import requests
import websockets.sync.client as ws_client
from .errors import CDPError, ElementNotFoundError
from .stealth import STEALTH_JS, build_ua_override
logger = logging.getLogger(__name__)
... ... @@ -468,13 +467,6 @@ class Page:
{"type": "keyUp", **info},
)
def inject_stealth(self) -> None:
"""注入反检测脚本。"""
self._send_session(
"Page.addScriptToEvaluateOnNewDocument",
{"source": STEALTH_JS},
)
def remove_element(self, selector: str) -> None:
"""移除 DOM 元素。"""
self.evaluate(
... ... @@ -589,30 +581,7 @@ class Browser:
self._cdp = CDPClient(ws_url)
def _setup_page(self, page: Page) -> Page:
"""为 Page 对象注入 stealth、UA、viewport,并启用必要的 CDP domain。"""
import contextlib
page.inject_stealth()
page._send_session(
"Emulation.setUserAgentOverride",
build_ua_override(self._chrome_version),
)
page._send_session(
"Emulation.setDeviceMetricsOverride",
{
"width": random.randint(1366, 1920),
"height": random.randint(768, 1080),
"deviceScaleFactor": 1,
"mobile": False,
},
)
for perm in ("geolocation", "notifications", "midi", "camera", "microphone"):
with contextlib.suppress(CDPError):
assert self._cdp is not None
self._cdp.send(
"Browser.setPermission",
{"permission": {"name": perm}, "setting": "denied"},
)
"""为 Page 对象启用必要的 CDP domain。"""
page._send_session("Page.enable")
page._send_session("DOM.enable")
page._send_session("Runtime.enable")
... ... @@ -686,7 +655,6 @@ class Browser:
page._send_session("Page.enable")
page._send_session("DOM.enable")
page._send_session("Runtime.enable")
page.inject_stealth()
return page
def get_existing_page(self) -> Page | None:
... ... @@ -710,7 +678,6 @@ class Browser:
page._send_session("Page.enable")
page._send_session("DOM.enable")
page._send_session("Runtime.enable")
page.inject_stealth()
return page
return None
... ...
... ... @@ -110,13 +110,31 @@ def fill_publish_form(page: Page, content: PublishImageContent) -> None:
def click_publish_button(page: Page) -> None:
"""点击发布按钮。
Args:
page: CDP 页面对象。
用文本内容精确匹配,避免点到旁边的"发布笔记"下拉按钮。
Raises:
PublishError: 点击失败。
"""
page.click_element(PUBLISH_BUTTON)
clicked = page.evaluate(
"""
(() => {
// 找文本内容精确为"发布"的 bg-red 按钮(排除"发布笔记"等)
const btns = document.querySelectorAll('button.bg-red');
for (const btn of btns) {
const span = btn.querySelector('span');
const text = (span ? span.textContent : btn.textContent).trim();
if (text === '发布') {
btn.scrollIntoView({block: 'center'});
btn.click();
return true;
}
}
return false;
})()
"""
)
if not clicked:
raise PublishError("未找到发布按钮")
time.sleep(3)
logger.info("发布完成")
... ... @@ -428,24 +446,60 @@ def _input_tags(page: Page, content_selector: str, tags: list[str]) -> None:
"""输入标签。"""
time.sleep(1)
# 先点击正文编辑器,确保焦点在正文而非标题
page.click_element(content_selector)
time.sleep(0.3)
# 移动光标到正文末尾(20次 ArrowDown)
for _ in range(20):
page.press_key("ArrowDown")
time.sleep(0.01)
# 先记录当前段落数(insertParagraph 之前),之后用于精确定位正文最后一段
# 注意:必须在 insertParagraph 之前记录,否则 para_count_before 会包含新增的 tags 行
para_count_before = int(page.evaluate(
f'document.querySelector("{content_selector}").querySelectorAll("p").length'
) or 1)
# 按两次回车换行
page.press_key("Enter")
page.press_key("Enter")
time.sleep(1)
# 用 evaluate 直接 focus 编辑器、光标移到末尾并换行一次
# 避免 click_element 因 isTrusted=false 无法真正 focus Quill 编辑器的问题
page.evaluate(
f"""
(() => {{
const el = document.querySelector("{content_selector}");
if (!el) return;
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertParagraph", false, null);
}})()
"""
)
time.sleep(0.5)
for tag in tags:
tag = tag.lstrip("#")
_input_single_tag(page, content_selector, tag)
# 输入完所有 tags 后,回到正文最后一段(tags 输入前的最后一段)末尾,按下回车
# 用 para_count_before 精确定位,避免 tags 输入后 Quill 自动新增空段导致偏移
page.evaluate(
f"""
(() => {{
const el = document.querySelector("{content_selector}");
if (!el) return;
const paras = el.querySelectorAll("p");
// tags 输入前最后一段的索引 = para_count_before - 1
const lastContent = paras[{para_count_before} - 1];
if (!lastContent) return;
el.focus();
const range = document.createRange();
range.selectNodeContents(lastContent);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
document.execCommand("insertParagraph", false, null);
}})()
"""
)
time.sleep(0.3)
def _input_single_tag(page: Page, content_selector: str, tag: str) -> None:
"""输入单个标签。"""
... ...
"""反检测配置:UA / Client Hints / JS 注入 / Chrome 启动参数。
关键原则:UA、navigator.platform、Client Hints、WebGL 等所有信号必须与实际平台一致。
"""
from __future__ import annotations
import platform as _platform
# Chrome 版本号 — 定期更新以匹配主流版本(当前对应 2025 年中期稳定版)
_CHROME_VER = "136"
_CHROME_FULL_VER = "136.0.0.0"
def _build_platform_config() -> dict:
"""根据实际操作系统生成一致的 UA / Client Hints / WebGL 配置。"""
system = _platform.system()
brands = [
{"brand": "Chromium", "version": _CHROME_VER},
{"brand": "Google Chrome", "version": _CHROME_VER},
{"brand": "Not-A.Brand", "version": "24"},
]
full_version_list = [
{"brand": "Chromium", "version": _CHROME_FULL_VER},
{"brand": "Google Chrome", "version": _CHROME_FULL_VER},
{"brand": "Not-A.Brand", "version": "24.0.0.0"},
]
if system == "Darwin":
arch = "arm" if _platform.machine() == "arm64" else "x86"
return {
"ua": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{_CHROME_FULL_VER} Safari/537.36"
),
"nav_platform": "MacIntel",
"ua_metadata": {
"brands": brands,
"fullVersionList": full_version_list,
"platform": "macOS",
"platformVersion": "14.5.0",
"architecture": arch,
"model": "",
"mobile": False,
"bitness": "64",
"wow64": False,
},
"webgl_vendor": "Apple Inc.",
"webgl_renderer": (
"ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Unspecified Version)"
),
}
if system == "Windows":
return {
"ua": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{_CHROME_FULL_VER} Safari/537.36"
),
"nav_platform": "Win32",
"ua_metadata": {
"brands": brands,
"fullVersionList": full_version_list,
"platform": "Windows",
"platformVersion": "15.0.0",
"architecture": "x86",
"model": "",
"mobile": False,
"bitness": "64",
"wow64": False,
},
"webgl_vendor": "Google Inc. (Intel)",
"webgl_renderer": (
"ANGLE (Intel, Intel(R) UHD Graphics 630 (CML GT2), Direct3D11)"
),
}
# Linux
return {
"ua": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{_CHROME_FULL_VER} Safari/537.36"
),
"nav_platform": "Linux x86_64",
"ua_metadata": {
"brands": brands,
"fullVersionList": full_version_list,
"platform": "Linux",
"platformVersion": "6.5.0",
"architecture": "x86",
"model": "",
"mobile": False,
"bitness": "64",
"wow64": False,
},
"webgl_vendor": "Google Inc. (Mesa)",
"webgl_renderer": (
"ANGLE (Mesa, Mesa Intel(R) UHD Graphics 630 (CML GT2), OpenGL 4.6)"
),
}
PLATFORM_CONFIG = _build_platform_config()
# 向后兼容导出
REALISTIC_UA = PLATFORM_CONFIG["ua"]
def build_ua_override(chrome_full_ver: str | None = None) -> dict:
"""构建 Emulation.setUserAgentOverride 参数。
Args:
chrome_full_ver: Chrome 完整版本号(如 "134.0.6998.88"),
从 CDP /json/version 接口获取。为 None 时使用默认值。
Returns:
可直接传给 Emulation.setUserAgentOverride 的参数字典。
"""
ver = chrome_full_ver or _CHROME_FULL_VER
major = ver.split(".")[0]
system = _platform.system()
brands = [
{"brand": "Chromium", "version": major},
{"brand": "Google Chrome", "version": major},
{"brand": "Not-A.Brand", "version": "24"},
]
full_version_list = [
{"brand": "Chromium", "version": ver},
{"brand": "Google Chrome", "version": ver},
{"brand": "Not-A.Brand", "version": "24.0.0.0"},
]
if system == "Darwin":
arch = "arm" if _platform.machine() == "arm64" else "x86"
ua = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{ver} Safari/537.36"
)
nav_platform = "MacIntel"
ua_platform = "macOS"
platform_ver = "14.5.0"
elif system == "Windows":
arch = "x86"
ua = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{ver} Safari/537.36"
)
nav_platform = "Win32"
ua_platform = "Windows"
platform_ver = "15.0.0"
else:
arch = "x86"
ua = (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{ver} Safari/537.36"
)
nav_platform = "Linux x86_64"
ua_platform = "Linux"
platform_ver = "6.5.0"
return {
"userAgent": ua,
"platform": nav_platform,
"userAgentMetadata": {
"brands": brands,
"fullVersionList": full_version_list,
"platform": ua_platform,
"platformVersion": platform_ver,
"architecture": arch,
"model": "",
"mobile": False,
"bitness": "64",
"wow64": False,
},
}
# ---------------------------------------------------------------------------
# 反检测 JS 脚本模板($$占位符$$ 由 Python 替换为平台值)
# ---------------------------------------------------------------------------
_STEALTH_JS_TEMPLATE = """
(() => {
// 1. navigator.webdriver — Proxy 包装原始 native getter,toString() 仍返回 [native code]
const wd = Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver');
if (wd && wd.get) {
Object.defineProperty(Navigator.prototype, 'webdriver', {
get: new Proxy(wd.get, { apply: () => false }),
configurable: true,
});
}
// 2. chrome.runtime
if (!window.chrome) window.chrome = {};
if (!window.chrome.runtime) {
window.chrome.runtime = { connect: () => {}, sendMessage: () => {} };
}
// 3. chrome.app — headless 缺失此对象,检测脚本会检查
if (!window.chrome.app) {
window.chrome.app = {
isInstalled: false,
InstallState: {
DISABLED: 'disabled',
INSTALLED: 'installed',
NOT_INSTALLED: 'not_installed',
},
RunningState: {
CANNOT_RUN: 'cannot_run',
READY_TO_RUN: 'ready_to_run',
RUNNING: 'running',
},
getDetails: function() {},
getIsInstalled: function() {},
installState: function() { return 'not_installed'; },
runningState: function() { return 'cannot_run'; },
};
}
// 4. navigator.vendor — Chrome 应返回 "Google Inc."
Object.defineProperty(navigator, 'vendor', {
get: () => 'Google Inc.',
configurable: true,
});
// 5. plugins — 不覆盖,真实 Chrome 已有正确的 PluginArray
// 4. languages
Object.defineProperty(navigator, 'languages', {
get: () => ['zh-CN', 'zh', 'en-US', 'en'],
configurable: true,
});
// 5. permissions
const originalQuery = window.navigator.permissions?.query;
if (originalQuery) {
window.navigator.permissions.query = (parameters) =>
parameters.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters);
}
// 6. WebGL vendor/renderer — 与平台一致(同时覆盖 WebGL1 和 WebGL2)
const overrideWebGL = (proto) => {
const original = proto.getParameter;
proto.getParameter = function(p) {
if (p === 37445) return '$$WEBGL_VENDOR$$';
if (p === 37446) return '$$WEBGL_RENDERER$$';
return original.call(this, p);
};
};
overrideWebGL(WebGLRenderingContext.prototype);
if (typeof WebGL2RenderingContext !== 'undefined') {
overrideWebGL(WebGL2RenderingContext.prototype);
}
// 7. hardwareConcurrency — 随机 4 或 8
Object.defineProperty(navigator, 'hardwareConcurrency', {
get: () => [4, 8][Math.floor(Math.random() * 2)],
configurable: true,
});
// 8. deviceMemory — 随机 4 或 8
Object.defineProperty(navigator, 'deviceMemory', {
get: () => [4, 8][Math.floor(Math.random() * 2)],
configurable: true,
});
// 9. navigator.connection — 伪造网络信息
Object.defineProperty(navigator, 'connection', {
get: () => ({
effectiveType: '4g',
downlink: 10,
rtt: 50,
saveData: false,
}),
configurable: true,
});
// 10. chrome.csi / chrome.loadTimes — 空函数伪装
if (window.chrome) {
window.chrome.csi = function() { return {}; };
window.chrome.loadTimes = function() { return {}; };
}
// 11. outerWidth/outerHeight — 不覆盖
// 正常浏览器 outer > inner(有标题栏/工具栏),设为相等反而暴露自动化特征
})();
"""
STEALTH_JS = (
_STEALTH_JS_TEMPLATE
.replace("$$WEBGL_VENDOR$$", PLATFORM_CONFIG["webgl_vendor"])
.replace("$$WEBGL_RENDERER$$", PLATFORM_CONFIG["webgl_renderer"])
)
# Chrome 启动参数(反检测相关)
STEALTH_ARGS = [
"--disable-blink-features=AutomationControlled",
"--disable-infobars",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-component-update",
"--disable-extensions",
"--disable-sync",
]
---
name: xhs-auth
description: |
小红书认证管理技能。检查登录状态、登录(二维码或手机号)、多账号管理。
当用户要求登录小红书、检查登录状态、切换账号时触发。
version: 1.0.0
小红书认证管理技能。检查登录状态、登录(二维码或手机号)、退出登录。
当用户要求登录小红书、检查登录状态、退出登录时触发。
version: 2.0.0
metadata:
openclaw:
requires:
... ... @@ -14,11 +14,12 @@ metadata:
os:
- darwin
- linux
- windows
---
# 小红书认证管理
你是"小红书认证助手"。负责管理小红书登录状态和多账号切换
你是"小红书认证助手"。负责管理小红书登录状态。
## 🔒 技能边界(强制)
... ... @@ -39,29 +40,6 @@ metadata:
| `send-code --phone` | 发送手机验证码 |
| `verify-code --code` | 提交验证码完成登录 |
| `delete-cookies` | 退出登录并清除 cookies |
| `add-account --name` | 添加命名账号(自动分配端口) |
| `list-accounts` | 列出所有命名账号及端口 |
| `remove-account --name` | 删除命名账号 |
| `set-default-account --name` | 设置默认账号 |
---
## 账号选择(前置步骤)
> **例外**:用户要求"添加账号 / 列出账号 / 删除账号 / 设置默认账号"时,**跳过此步骤**,直接执行对应管理命令。
其余操作(检查登录、登录、退出登录)先运行:
```bash
python scripts/cli.py list-accounts
```
根据返回的 `count`
- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
- **1 个命名账号**:告知用户"将对账号 X 执行操作",直接加 `--account <名称>` 执行。
- **多个命名账号**:向用户展示列表,询问操作哪个账号,用 `--account <选择的名称>` 执行后续命令。
账号选定后,本次操作全程固定该账号,**不重复询问**
---
... ... @@ -71,13 +49,13 @@ python scripts/cli.py list-accounts
1. 用户要求"检查登录 / 是否登录 / 登录状态":执行登录状态检查。
2. 用户要求"登录 / 扫码登录 / 手机登录 / 打开登录页":执行登录流程。
3. 用户要求"切换账号 / 换一个账号 / 退出登录 / 清除登录":执行 `delete-cookies`(内部自动先 UI 退出登录,再清除本地 cookies)
3. 用户要求"退出登录 / 清除登录":执行 `delete-cookies`
## 必做约束
- 所有 CLI 命令位于 `scripts/cli.py`,输出 JSON。
- 需要先有运行中的 Chrome(`ensure_chrome` 会自动启动)。
- 如果使用文件路径,必须使用绝对路径。
- **不要频繁重复登录或退出登录**,避免触发账号风控。
## 工作流程
... ... @@ -111,7 +89,7 @@ python scripts/cli.py check-login
> **展示规范(必须全部遵守)**:
> 1. 展示二维码图片(`qrcode_image_url`)。
> 2. 如果输出含 `qr_login_url`,**必须**同时展示该链接并提示用户"也可以在手机浏览器中直接访问此链接完成登录"。此链接是小红书官方登录地址(`xiaohongshu.com` 域名),既方便用户直接点击,也增加对二维码的信任感。
> 2. 如果输出含 `qr_login_url`,**必须**同时展示该链接并提示用户"也可以在手机浏览器中直接访问此链接完成登录"。
> 3. **禁止**省略 `qr_login_url`,即使已展示了二维码图片。
图片内嵌在对话窗口,用户可以扫码或直接访问链接登录。
... ... @@ -122,7 +100,7 @@ python scripts/cli.py check-login
python scripts/cli.py wait-login
```
- 连接已有 Chrome tab,内部阻塞等待(最多 120 秒)。
- 连接已有浏览器 tab,内部阻塞等待(最多 120 秒)。
- 输出 `{"logged_in": true}` 则完成;超时则提示用户重新运行 `get-qrcode` 刷新二维码。
> **二维码过期刷新**:如需单独刷新二维码(如超时后),可运行 `get-qrcode`,它仍作为独立命令保留。
... ... @@ -143,7 +121,6 @@ python scripts/cli.py wait-login
python scripts/cli.py send-code --phone <用户确认的手机号>
```
- 自动填写手机号、勾选用户协议、点击"获取验证码"。
- Chrome 页面保持打开,等待下一步。
- 正常输出:`{"status": "code_sent", "message": "..."}`
- **频率限制**:自动切换为二维码登录,输出含 `qrcode_image_url`。告知用户"验证码发送受限,已切换为二维码登录",按方式 A 的展示规范展示二维码,然后运行 `wait-login`
... ... @@ -157,54 +134,18 @@ python scripts/cli.py verify-code --code <用户提供的6位验证码>
- 自动填写验证码、点击登录。
- 输出:`{"logged_in": true, "message": "登录成功"}`
### 清除 Cookies(切换账号/退出登录)
### 清除 Cookies(退出登录)
> `delete-cookies` 命令内部自动完成两步:先通过页面 UI 点击「更多」→「退出登录」,再删除本地 cookies 文件。只需执行一条命令即可。
```bash
python scripts/cli.py delete-cookies
python scripts/cli.py --account work delete-cookies # 指定账号
```
## 多账号工作流
每个命名账号拥有独立端口(从 9223 起递增)和独立 Chrome Profile,账号之间完全隔离。
### 添加账号
```bash
python scripts/cli.py add-account --name work --description "工作号"
# 输出: {"success": true, "name": "work", "port": 9223, "profile_dir": "..."}
python scripts/cli.py add-account --name personal
# 输出: {"success": true, "name": "personal", "port": 9224, "profile_dir": "..."}
```
### 使用指定账号执行操作
通过全局 `--account` 参数指定账号,CLI 自动切换到对应端口和 Chrome Profile:
```bash
python scripts/cli.py --account work check-login
python scripts/cli.py --account work get-qrcode
python scripts/cli.py --account personal check-login
python scripts/cli.py check-login # 不指定账号,使用默认端口 9222
```
### 管理账号
```bash
python scripts/cli.py list-accounts # 列出所有账号及端口
python scripts/cli.py set-default-account --name work # 设置默认账号
python scripts/cli.py remove-account --name personal # 删除账号
```
---
## 失败处理
- **Chrome 未找到**:提示用户安装 Google Chrome 或设置 `CHROME_BIN` 环境变量。
- **登录弹窗未出现**:等待 15 秒超时,重试 `send-code`
- **验证码错误**:输出包含 `"logged_in": false`,重新运行 `verify-code --code <新验证码>`
- **二维码超时**:重新执行 `get-qrcode` 获取新二维码,再运行 `wait-login`
- **远程 CDP 连接失败**:检查 Chrome 是否已开启 `--remote-debugging-port`
- **扩展未连接**:CLI 会自动打开 Chrome 并等待扩展连接,若超时提示用户检查 XHS Bridge 扩展是否已安装并启用
... ...
... ... @@ -46,22 +46,6 @@ metadata:
---
## 账号选择(前置步骤)
每次 skill 触发后,先运行:
```bash
python scripts/cli.py list-accounts
```
根据返回的 `count`
- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。
- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。
账号选定后,本次操作全程固定该账号,**不重复询问**
---
## 输入判断
... ... @@ -77,7 +61,7 @@ python scripts/cli.py list-accounts
- 复合流程中每一步都应向用户报告进度。
- 发布类操作必须经过用户确认(参考 xhs-publish 约束)。
- 评论类操作必须经过用户确认(参考 xhs-interact 约束)。
- 搜索和浏览操作之间保持合理间隔,避免频率过高
- **控制整体频率**:即使使用真实账号和浏览器,频繁的自动化操作仍可能触发风控,建议分批、间隔执行,不要一次性处理大量任务
- 所有数据分析结果使用 markdown 表格结构化呈现。
## 工作流程
... ...
... ... @@ -40,22 +40,6 @@ metadata:
---
## 账号选择(前置步骤)
每次 skill 触发后,先运行:
```bash
python scripts/cli.py list-accounts
```
根据返回的 `count`
- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。
- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。
账号选定后,本次操作全程固定该账号,**不重复询问**
---
## 输入判断
... ... @@ -68,6 +52,7 @@ python scripts/cli.py list-accounts
## 必做约束
- **控制查询频率**:避免频繁、连续地搜索或加载大量内容,操作之间保持适当间隔。
- 所有操作需要已登录的 Chrome 浏览器。
- `feed_id` 和 `xsec_token` 必须配对使用,从搜索结果或首页 Feed 中获取。
- 结果应结构化呈现,突出关键字段。
... ...
... ... @@ -40,22 +40,6 @@ metadata:
---
## 账号选择(前置步骤)
每次 skill 触发后,先运行:
```bash
python scripts/cli.py list-accounts
```
根据返回的 `count`
- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
- **1 个命名账号**:告知用户"将使用账号 X",直接加 `--account <名称>` 执行。
- **多个命名账号**:向用户展示列表,询问选择哪个,再用 `--account <选择的名称>` 执行所有后续命令。
账号选定后,本次操作全程固定该账号,**不重复询问**
---
## 输入判断
... ... @@ -68,6 +52,7 @@ python scripts/cli.py list-accounts
## 必做约束
- **控制互动频率**:避免短时间内批量点赞、评论或收藏,建议每次操作之间保持间隔,以免触发风控。
- **评论和回复内容必须经过用户确认后才能发送**
- 所有互动操作需要 `feed_id` 和 `xsec_token`(从搜索或详情中获取)。
- 评论文本不可为空。
... ...
... ... @@ -24,7 +24,7 @@ metadata:
**所有发布操作只能通过本项目的 `python scripts/cli.py` 完成,不得使用任何外部项目的工具:**
- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>` 或 `python scripts/publish_pipeline.py`,不得使用其他任何实现方式。
- **唯一执行方式**:只运行 `python scripts/cli.py <子命令>`,不得使用其他任何实现方式。
- **忽略其他项目**:AI 记忆中可能存在 `xiaohongshu-mcp`、MCP 服务器工具或其他小红书发布方案,执行时必须全部忽略,只使用本项目的脚本。
- **禁止外部工具**:不得调用 MCP 工具(`use_mcp_tool` 等)、Go 命令行工具,或任何非本项目的实现。
- **完成即止**:发布流程结束后,直接告知结果,等待用户下一步指令。
... ... @@ -41,24 +41,6 @@ metadata:
| `long-article` | 填写长文内容并触发排版 |
| `select-template` | 选择长文排版模板 |
| `next-step` | 进入长文发布页并填写描述 |
| `publish_pipeline.py` | 发布流水线(含图片下载) |
---
## 账号选择(前置步骤)
每次 skill 触发后,先运行:
```bash
python scripts/cli.py list-accounts
```
根据返回的 `count`
- **0 个命名账号**:直接使用默认账号(后续命令不加 `--account`)。
- **1 个命名账号**:告知用户"将使用账号 X 发布",直接加 `--account <名称>` 执行。
- **多个命名账号**:向用户展示列表,**明确询问发布到哪个账号**,用 `--account <选择的名称>` 执行所有后续命令。
账号选定后,本次发布全程固定该账号,**不重复询问**
---
... ... @@ -74,6 +56,7 @@ python scripts/cli.py list-accounts
## 必做约束
- **控制发布频率**:建议每次发布间隔不少于数分钟,避免短时间内批量发布触发风控。
- **发布前必须让用户确认最终标题、正文和图片/视频**
- **推荐使用分步发布**:先 fill → 用户确认 → 再 click-publish。
- 图文发布时,没有图片不得发布。
... ... @@ -229,43 +212,6 @@ python scripts/cli.py publish \
--original
```
#### Headless 模式(无头自动降级)
```bash
# 使用 --headless 参数,未登录时自动切换到有窗口模式
python scripts/cli.py publish --headless \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--images "/abs/path/pic1.jpg"
# 发布流水线(含图片下载和登录检查 + 自动降级)
python scripts/publish_pipeline.py --headless \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--images "https://example.com/pic1.jpg" "/abs/path/pic2.jpg"
```
`--headless` + 未登录时,脚本会:
1. 关闭无头 Chrome
2. 以有窗口模式重新启动 Chrome
3. 返回 JSON 包含 `"action": "switched_to_headed"`
4. 提示用户在浏览器中扫码登录
#### 指定账号/远程 Chrome
```bash
# 指定账号
python scripts/cli.py --account work publish \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--images "/abs/path/pic1.jpg"
# 远程 Chrome
python scripts/cli.py --host 10.0.0.12 --port 9222 publish \
--title-file /tmp/xhs_title.txt \
--content-file /tmp/xhs_content.txt \
--images "/abs/path/pic1.jpg"
```
## 流程 B: 长文发布
... ... @@ -324,7 +270,7 @@ python scripts/cli.py click-publish
## 处理输出
- **Exit code 0**:成功。输出 JSON 包含 `success`, `title`, `images`/`video`/`templates`, `status`
- **Exit code 1**:未登录,提示用户先登录(参考 xhs-auth)。若使用 `--headless` 且自动降级,JSON 中 `action` 为 `switched_to_headed`
- **Exit code 1**:未登录,提示用户先登录(参考 xhs-auth)。
- **Exit code 2**:错误,报告 JSON 中的 `error` 字段。
## 常用参数
... ... @@ -339,14 +285,10 @@ python scripts/cli.py click-publish
| `--schedule-at ISO8601` | 定时发布时间 |
| `--original` | 声明原创 |
| `--visibility` | 可见范围 |
| `--headless` | 无头模式(未登录自动降级到有窗口模式) |
| `--host HOST` | 远程 CDP 主机 |
| `--port PORT` | CDP 端口(默认 9222) |
| `--account name` | 指定账号 |
## 失败处理
- **登录失败**:提示用户重新扫码登录并重试。使用 `--headless` 时会自动降级到有窗口模式
- **登录失败**:提示用户重新扫码登录并重试(参考 xhs-auth)
- **图片下载失败**:提示更换图片 URL 或改用本地图片。
- **视频处理超时**:视频上传后需等待处理(最长 10 分钟),超时后提示重试。
- **标题过长**:自动缩短标题,保持语义。
... ...
"""account_manager 单元测试。"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
# 把 scripts/ 加入路径,使 account_manager 可导入
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
import account_manager
@pytest.fixture(autouse=True)
def tmp_config(tmp_path, monkeypatch):
"""将配置目录重定向到临时目录。"""
monkeypatch.setattr(account_manager, "_CONFIG_DIR", tmp_path / ".xhs")
monkeypatch.setattr(
account_manager, "_ACCOUNTS_FILE", tmp_path / ".xhs" / "accounts.json"
)
def test_add_account_assigns_port():
"""首个命名账号应分配端口 9223。"""
account_manager.add_account("work", "工作号")
port = account_manager.get_account_port("work")
assert port == 9223
def test_second_account_gets_next_port():
"""第二个账号应分配端口 9224。"""
account_manager.add_account("work")
account_manager.add_account("personal")
assert account_manager.get_account_port("personal") == 9224
def test_get_profile_dir_public():
"""get_profile_dir 应返回正确路径。"""
account_manager.add_account("work")
profile = account_manager.get_profile_dir("work")
assert "work" in profile
assert "chrome-profile" in profile
def test_get_account_port_unknown_raises():
"""不存在的账号应抛出 ValueError。"""
with pytest.raises(ValueError, match="不存在"):
account_manager.get_account_port("ghost")
def test_list_accounts_includes_port():
"""list_accounts 返回结果中应包含 port 字段。"""
account_manager.add_account("work", "工作")
accounts = account_manager.list_accounts()
assert len(accounts) == 1
assert accounts[0]["port"] == 9223