Files
kurihada 8da5f40c9f feat: social-mcp 初始实现
多平台社交自动化 MCP 服务,首批支持小红书。

- 13 个 MCP 工具:登录管理、内容浏览、发布、互动
- 13 个 REST API 端点,支持 Bearer token 认证和限流
- BrowserManager:串行队列、背压、崩溃恢复
- Cookie 持久化:原子写入、0600 权限
- 安全:DNS rebinding 防御、错误脱敏、深层日志 redact
- Docker 部署支持
- 28 个单元测试全部通过
2026-02-28 22:57:22 +08:00

912 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Social Auto Hub — 多平台社交自动化 MCP 服务
## 一、项目定位
通过浏览器自动化,让 AI 助手(Claude 等)能操控多个社交平台。
首批支持:**小红书**。后续按需扩展小黑盒、B站、微博等。
---
## 二、技术栈选型
| 组件 | 选择 | 理由 |
|------|------|------|
| 语言 | **TypeScript 5.x** | 浏览器自动化生态最强,MCP SDK 是官方参考实现 |
| 运行时 | **Node.js 22 LTS** | 当前 LTSNode 20 即将进入 maintenance |
| 浏览器自动化 | **rebrowser-playwright** | Playwright 的 drop-in 替代,内置反自动化检测补丁,活跃维护 |
| MCP SDK | **@modelcontextprotocol/sdk ^1.27** | 官方 TypeScript 参考实现,锁定 v1.x 稳定版 |
| HTTP 服务 | **Express ^4** | MCP SDK 原生支持 Express 集成 |
| 包管理 | **pnpm** | 快,磁盘占用小 |
| 构建 | **tsup** | 零配置 TS 打包,基于 esbuild |
| 日志 | **pino ^9** | 高性能结构化日志,内置 redact 脱敏 |
| 校验 | **zod ^3.25** | 运行时类型校验,MCP SDK 原生支持。**不用 v4**(与 SDK 有兼容问题) |
| 容器化 | **Docker** | 含 Playwright 浏览器 |
### 为什么用 rebrowser-playwright 而不是 playwright-extra
`playwright-extra` 最后发布 4.3.6 是 3 年前,已停止维护,随时会与新版 Playwright 不兼容。
`rebrowser-playwright` 是 Playwright 的 patched fork2026.2 仍在更新),直接 `npm install rebrowser-playwright` 即可,
代码中 `import { chromium } from 'rebrowser-playwright'` 替代原 `playwright`,零改动。
它在底层修复 CDP 泄漏等检测点,比 JS 层面的 stealth 脚本更彻底。
### 为什么锁定 zod ^3.25
MCP SDK v1.x 的 `server.tool()` 内部依赖 zod v3 的 schema 结构。
zod v4 有 breaking change`.describe()` 丢失、`_parse` 不兼容),
参见 [Issue #925](https://github.com/modelcontextprotocol/typescript-sdk/issues/925)。
---
## 三、架构设计:插件式多平台
### 3.1 设计原则
**不做统一 Platform 接口。** 各平台业务差异大(小红书的笔记 vs B站的弹幕 vs 微博的转发),强行抽象只会产生大量 `NotSupported`
真正共享的是**基础设施**,业务逻辑各平台独立:
```
共享基础设施 各平台独立
────────── ──────────
BrowserManager MCP 工具定义 + zod schema
CookieStore actions(业务逻辑层)
Config / Logger 页面操作逻辑
MCP Server 框架 CSS 选择器
Express + 中间件 业务类型定义
错误处理包装 REST API handler
```
### 3.2 平台插件契约
每个平台导出一个 `PlatformPlugin`,把自己的 MCP 工具注册进去:
```typescript
export interface PlatformPlugin {
name: string;
registerTools(server: McpServer, browser: BrowserManager): void;
registerRoutes?(router: express.Router, browser: BrowserManager): void;
// 生命周期钩子(可选,第二个平台接入时再强制要求)
init?(): Promise<void>;
shutdown?(): Promise<void>;
healthCheck?(): Promise<{ healthy: boolean; message?: string }>;
}
```
新增平台只需要:
1.`src/platforms/` 下新建目录
2. 实现 `PlatformPlugin`
3.`src/index.ts` 中 import 并注册
> **Review 备注**`init/shutdown/healthCheck` 当前为可选钩子。
> Phase 1 只有小红书一个平台,暂不强制。等第二个平台接入时评估是否改为必选。
### 3.3 平台内部分层
每个平台内部统一采用 **actions 层** 分离业务逻辑和 handler
```
platforms/xiaohongshu/
├── index.ts # PlatformPlugin: 注册 MCP 工具,调用 actions
├── actions.ts # 业务逻辑层(纯函数,接收 Page 返回数据)
├── selectors.ts # CSS 选择器常量
├── schemas.ts # zod schemaMCP 工具参数)
├── types.ts # 业务类型
├── login.ts # 登录操作(页面交互)
├── search.ts # 搜索操作
├── ... # 其他操作
```
**actions.ts** 是核心:MCP handler 和 REST handler 都调用 actions 中的函数,不重复业务逻辑。
```typescript
// actions.ts — 纯业务逻辑,不关心 MCP/REST
export async function searchFeeds(page: Page, keyword: string, filters?: FilterOption): Promise<Feed[]> { ... }
export async function checkLoginStatus(page: Page): Promise<LoginStatus> { ... }
// index.ts — MCP handler 只做参数解析 + 调用 action + 格式化输出
server.tool('xhs_search', '搜索小红书笔记', SearchSchema, async (args) => {
return browser.withPage('xiaohongshu', async (page) => {
const feeds = await searchFeeds(page, args.keyword, args.filters);
return { content: [{ type: 'text', text: JSON.stringify(feeds) }] };
});
});
```
---
## 四、项目结构
```
social-auto-hub/
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── .env.example
├── .gitignore
├── CLAUDE.md
├── Dockerfile
├── src/
│ ├── index.ts # 入口:配置、注册插件、启动、优雅关闭(drain→browser→http→log
│ │
│ ├── server/
│ │ ├── app.ts # AppServer: Express + MCP 生命周期
│ │ └── middleware.ts # DNS rebinding 防御 + 错误处理 + 优雅关闭 503(不加 CORS)
│ │
│ ├── browser/
│ │ └── manager.ts # BrowserManager: 浏览器 + Context + 串行队列 + 背压 + 启动锁
│ │
│ ├── cookie/
│ │ └── store.ts # CookieStore: 按平台隔离,文件权限 0600,原子写入
│ │
│ ├── config/
│ │ └── index.ts # 全局配置(环境变量,默认 127.0.0.1)
│ │
│ ├── utils/
│ │ ├── logger.ts # pino 日志(深层 redact 脱敏 + 自定义错误序列化)
│ │ ├── errors.ts # 错误分类 + 消息脱敏 + withErrorHandling 包装
│ │ └── downloader.ts # 图片下载 + 媒体路径校验
│ │
│ └── platforms/
│ └── xiaohongshu/
│ ├── index.ts # PlatformPlugin 注册
│ ├── actions.ts # 业务逻辑层(MCP/REST 共享)
│ ├── selectors.ts # CSS 选择器常量
│ ├── types.ts # 小红书业务类型
│ ├── schemas.ts # MCP 工具参数 zod schema
│ ├── login.ts # 登录(特殊 Page 生命周期)
│ ├── search.ts # 搜索
│ ├── feeds.ts # Feed 列表
│ ├── feed-detail.ts # 笔记详情 + 评论
│ ├── publish.ts # 图文发布
│ ├── publish-video.ts # 视频发布
│ ├── comment.ts # 评论 + 回复
│ ├── interaction.ts # 点赞 / 收藏
│ └── user-profile.ts # 用户主页
└── deploy/
└── docker-compose.yml
```
---
## 五、小红书 MCP 工具清单(13 个)
### 5.1 登录管理(3 个)
| MCP 工具名 | 说明 | 读/写 | 参数 |
|-----------|------|-------|------|
| `xhs_check_login` | 检查登录状态 | 只读 | 无 |
| `xhs_get_login_qrcode` | 获取登录二维码图片 | 只读 | 无 |
| `xhs_delete_cookies` | 删除 Cookie,重置登录 | 写(破坏性) | 无 |
### 5.2 内容浏览(4 个)
| MCP 工具名 | 说明 | 读/写 | 参数 |
|-----------|------|-------|------|
| `xhs_list_feeds` | 获取首页推荐 Feed 列表 | 只读 | 无 |
| `xhs_search` | 搜索笔记 | 只读 | `keyword`, `filters?`(排序/类型/时间/范围/位置) |
| `xhs_get_feed_detail` | 获取笔记详情 + 评论 | 只读 | `feed_id`, `xsec_token`, `load_all_comments?` |
| `xhs_get_user_profile` | 获取用户主页信息 | 只读 | `user_id`, `xsec_token` |
> **注意**`xhs_get_feed_detail` 相比参考项目精简了参数。
> `scroll_speed`、`click_more_replies`、`reply_limit` 是浏览器实现细节,
> 不暴露给 AI,改为服务端内部配置(在 config 或 actions 层处理)。
### 5.3 内容发布(2 个)
| MCP 工具名 | 说明 | 读/写 | 参数 |
|-----------|------|-------|------|
| `xhs_publish_image` | 发布图文笔记 | 写 | `title`, `content`, `images[]`, `tags?[]`, `schedule_at?`, `is_original?`, `visibility?` |
| `xhs_publish_video` | 发布视频笔记 | 写 | `title`, `content`, `video`, `tags?[]`, `schedule_at?`, `visibility?` |
### 5.4 互动操作(4 个)
| MCP 工具名 | 说明 | 读/写 | 参数 |
|-----------|------|-------|------|
| `xhs_post_comment` | 发表评论 | 写 | `feed_id`, `xsec_token`, `content` |
| `xhs_reply_comment` | 回复评论 | 写 | `feed_id`, `xsec_token`, `comment_id?`, `user_id?`, `content` |
| `xhs_like` | 点赞/取消点赞 | 写 | `feed_id`, `xsec_token`, `unlike?` |
| `xhs_favorite` | 收藏/取消收藏 | 写 | `feed_id`, `xsec_token`, `unfavorite?` |
### 5.5 REST APIPhase 5,可选)
```
GET /api/xhs/login/status POST /api/xhs/publish/image
GET /api/xhs/login/qrcode POST /api/xhs/publish/video
DELETE /api/xhs/login/cookies POST /api/xhs/comment
GET /api/xhs/feeds POST /api/xhs/comment/reply
POST /api/xhs/search POST /api/xhs/like
POST /api/xhs/feeds/detail POST /api/xhs/favorite
POST /api/xhs/user/profile
```
---
## 六、核心模块设计
### 6.1 BrowserManager
```typescript
import { Browser, BrowserContext, Page } from 'rebrowser-playwright';
class BrowserManager {
private browser: Browser | null = null;
private contexts = new Map<string, BrowserContext>();
private queues = new Map<string, Promise<void>>(); // per-platform 串行队列
private queueDepths = new Map<string, number>(); // 队列深度计数
private launchPromise: Promise<Browser> | null = null; // 启动锁
private readonly MAX_QUEUE_DEPTH = 10;
// 分级超时:不同操作类型使用不同超时时间
static readonly OPERATION_TIMEOUTS: Record<string, number> = {
like: 15_000, // 15s — 快速交互
comment: 20_000, // 20s
feed_list: 30_000, // 30s — 页面加载 + 提取
search: 30_000, // 30s
feed_detail: 60_000, // 60s — 含滚动加载
publish: 300_000, // 5min — 上传可能较慢
login: 300_000, // 5min — 用户交互
default: 60_000, // 1min — 兜底
};
// 核心方法:串行执行 + 超时控制 + 错误恢复 + 背压
async withPage<T>(
platform: string,
fn: (page: Page) => Promise<T>,
timeoutMs?: number,
): Promise<T> {
// 0. 背压:队列深度限制,防止无限排队
const depth = this.queueDepths.get(platform) ?? 0;
if (depth >= this.MAX_QUEUE_DEPTH) {
throw new Error(`平台 ${platform} 队列已满 (${this.MAX_QUEUE_DEPTH}),请稍后重试`);
}
this.queueDepths.set(platform, depth + 1);
const effectiveTimeout = timeoutMs ?? BrowserManager.OPERATION_TIMEOUTS.default;
// 1. 串行化:同一平台的操作排队执行,避免多 Page 互相干扰
const prev = this.queues.get(platform) ?? Promise.resolve();
const task = prev.then(async () => {
// 2. 检查浏览器存活
await this.ensureBrowser();
const ctx = await this.getContext(platform);
const page = await ctx.newPage();
// 设置 Playwright 级别超时,确保底层操作也受控
page.setDefaultTimeout(effectiveTimeout);
page.setDefaultNavigationTimeout(effectiveTimeout);
try {
// 3. 超时控制(带 timer 清理,避免泄漏)
let timer: ReturnType<typeof setTimeout>;
const result = await Promise.race([
fn(page),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`操作超时: ${effectiveTimeout}ms`)),
effectiveTimeout,
);
}),
]);
clearTimeout(timer!);
return result;
} catch (err) {
// 超时时 fn 可能仍在运行,page.close() 会中断它
throw err;
} finally {
await page.close().catch(() => {});
}
});
this.queues.set(platform, task.then(() => {}, () => {}));
// 队列深度计数还原
task.finally(() => {
this.queueDepths.set(platform, (this.queueDepths.get(platform) ?? 1) - 1);
});
return task;
}
// 登录专用:返回 Page 不自动关闭,调用方负责生命周期
// release 必须是幂等的(内部用 released 标志位防止重复调用)
async acquirePage(platform: string): Promise<{ page: Page; release: () => Promise<void> }> {
await this.ensureBrowser();
const ctx = await this.getContext(platform);
const page = await ctx.newPage();
let released = false;
const release = async () => {
if (released) return; // 幂等
released = true;
await page.close().catch(() => {});
};
// 安全网:最长 5 分钟自动释放,防止 release 忘记调用
const maxLifetime = setTimeout(() => {
logger.warn({ platform }, '页面超过最大生命周期,强制释放');
release();
}, 5 * 60 * 1000);
return {
page,
release: async () => {
clearTimeout(maxLifetime);
await release();
},
};
}
// 检查浏览器存活,崩溃则重建
// 用 launchPromise 作为启动锁,防止并发调用时启动多个浏览器进程
private async ensureBrowser(): Promise<Browser> {
if (this.browser?.isConnected()) return this.browser;
if (!this.launchPromise) {
this.launchPromise = (async () => {
// 如果 browser 存在但已断开,先清理
if (this.browser) {
logger.warn('浏览器连接断开,正在重新启动');
this.contexts.clear();
this.browser = null;
}
const browser = await chromium.launch({ headless: config.headless });
// 监听断开事件,主动清理状态
browser.on('disconnected', () => {
logger.error('浏览器进程意外断开');
this.browser = null;
this.contexts.clear();
});
this.browser = browser;
return browser;
})().finally(() => {
this.launchPromise = null;
});
}
return this.launchPromise;
}
private async getContext(platform: string): Promise<BrowserContext>;
async saveCookies(platform: string): Promise<void>;
// 等待所有队列排空(用于优雅关闭)
async drain(): Promise<void> {
await Promise.allSettled(Array.from(this.queues.values()));
}
async close(): Promise<void>;
}
```
关键设计点:
- **per-platform 串行队列**:同一平台操作排队,不同平台可并行
- **队列背压**`MAX_QUEUE_DEPTH = 10`,超出直接拒绝,防止无限排队
- **分级超时**:按操作类型设置不同超时(like 15s vs publish 5min),避免快操作被慢操作的默认超时卡住
- **超时 timer 清理**`Promise.race` 成功后 `clearTimeout`,避免 timer 泄漏和 `unhandledRejection`
- **Playwright 级别超时**`page.setDefaultTimeout()` 确保底层操作也受控,超时时 `page.close()` 会中断正在运行的 fn
- **启动锁**`launchPromise` 防止并发调用 `ensureBrowser()` 时启动多个浏览器进程
- **崩溃恢复**`browser.on('disconnected')` 主动清理状态,下次调用自动重建
- **登录特殊处理**`acquirePage()` 返回幂等的 `release` 函数 + 5 分钟安全网超时
- **优雅关闭**`drain()` 等待所有队列排空后再关闭浏览器
### 6.2 登录流程(特殊 Page 生命周期)
```typescript
// login.ts — 扫码登录不能用 withPage(需要页面保持打开等用户扫码)
export async function getLoginQRCode(browser: BrowserManager): Promise<QRCodeResult> {
const { page, release } = await browser.acquirePage('xiaohongshu');
try {
await page.goto('https://www.xiaohongshu.com/explore');
// ... 检查是否已登录 ...
const qrcodeData = await page.getAttribute('.login-container .qrcode-img', 'src');
// 后台等待扫码完成,然后保存 Cookie 并释放 Page
// 必须 catch 错误,fire-and-forget 不能让异常逃逸
waitForLoginAndRelease(page, browser, release).catch(err => {
logger.error({ err }, '登录等待流程异常');
});
return { qrcodeData, alreadyLoggedIn: false, timeout: '4m' };
} catch (err) {
await release();
throw err;
}
}
async function waitForLoginAndRelease(page: Page, browser: BrowserManager, release: () => Promise<void>) {
// release 已经是幂等的(BrowserManager.acquirePage 保证),
// 所以即使 setTimeout 和 finally 都触发,也不会重复释放。
const timeout = setTimeout(() => release(), 4 * 60 * 1000);
try {
await page.waitForSelector('.user .link-wrapper .channel', { timeout: 4 * 60 * 1000 });
await browser.saveCookies('xiaohongshu');
} catch {} finally {
clearTimeout(timeout);
await release();
}
}
```
> **Review 修正**:原设计中 `release()` 没有幂等保护,`setTimeout` 和 `finally` 可能双重触发。
> 现在 `acquirePage` 内部保证 `release` 幂等(`released` 标志位),彻底消除该竞态。
> 同时 `waitForLoginAndRelease` 的 fire-and-forget 调用必须 `.catch()` 防止异常逃逸。
### 6.3 CookieStore
```typescript
class CookieStore {
// 目录: ~/.social-auto-hub/<platform>/
// 文件权限: 0o600(仅 owner 可读写)
// 目录权限: 0o700
getPath(platform: string): string;
async load(platform: string): Promise<StorageState | null>;
// 原子写入:先写临时文件再 rename,防止崩溃导致文件损坏
async save(platform: string, state: StorageState): Promise<void> {
const filePath = this.getPath(platform);
const tmpPath = `${filePath}.tmp.${process.pid}`;
await fs.writeFile(tmpPath, JSON.stringify(state), { mode: 0o600 });
await fs.rename(tmpPath, filePath);
}
async delete(platform: string): Promise<void>;
}
```
> **Review 补充**Cookie 加密(AES-256-GCM)暂不实现。
> 当前安全模型:文件权限 0600 + 监听 127.0.0.1,对本地自动化工具足够。
> 如果未来需要更高安全级别(多用户共享机器),再引入加密 + OS keychain 集成。
### 6.4 统一错误处理 + 错误分类
浏览器自动化的错误类型多样,AI 助手需要根据错误类型决定下一步操作(重试?重新登录?报告失败?)。
因此引入**错误分类体系**,让 MCP 响应携带结构化的错误信息。
```typescript
// utils/errors.ts
// 错误分类枚举
enum ErrorCategory {
TIMEOUT = 'TIMEOUT', // 操作超时
AUTH_REQUIRED = 'AUTH_REQUIRED', // 需要登录
SELECTOR_NOT_FOUND = 'SELECTOR_NOT_FOUND', // 选择器未找到(平台 UI 可能已变)
NETWORK = 'NETWORK', // 网络错误
PLATFORM_ERROR = 'PLATFORM_ERROR', // 平台返回错误
INTERNAL = 'INTERNAL', // 内部错误
}
// 错误分类函数
function classifyError(err: Error): ErrorCategory {
const msg = err.message.toLowerCase();
if (msg.includes('timeout') || err.name === 'TimeoutError') return ErrorCategory.TIMEOUT;
if (msg.includes('net::err_')) return ErrorCategory.NETWORK;
if (msg.includes('login') || msg.includes('登录')) return ErrorCategory.AUTH_REQUIRED;
if (msg.includes('waiting for selector') || msg.includes('找不到元素')) return ErrorCategory.SELECTOR_NOT_FOUND;
return ErrorCategory.INTERNAL;
}
// 错误消息脱敏:去掉文件路径、长 token 等内部信息
function sanitizeErrorMessage(message: string): string {
return message
.replace(/\/[^\s:]+/g, '[path]') // 文件系统路径
.replace(/https?:\/\/[^\s]+/g, '[url]') // URL(可能含 token
.replace(/[a-f0-9]{32,}/gi, '[hash]') // 长 hex 串
.substring(0, 200);
}
// 统一错误包装(注意:必须是 async 函数)
export async function withErrorHandling(
toolName: string,
fn: () => Promise<McpToolResult>,
): Promise<McpToolResult> {
try {
return await fn();
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const category = classifyError(error);
const safeMessage = sanitizeErrorMessage(error.message);
logger.error({ tool: toolName, category, err: error }, '工具执行失败');
return {
content: [{
type: 'text',
text: JSON.stringify({
tool: toolName,
error: category,
message: safeMessage,
}),
}],
isError: true,
};
}
}
```
每个 MCP 工具 handler 用 `withErrorHandling` 包裹,Playwright 的 `TimeoutError`
`TargetClosedError` 等异常统一转为 MCP 的 `isError: true` 响应,不会导致连接断开。
AI 助手可以根据 `error` 字段判断是否需要重试、重新登录、或报告错误。
> **Review 修正**:原设计中 `withErrorHandling` 不是 `async` 却用了 `await`(编译不通过),
> 且返回类型 `T` 和 `McpToolResult` 不匹配。已修正。
> 新增错误分类 + 消息脱敏,防止泄露文件路径等内部信息。
### 6.5 日志脱敏
```typescript
// utils/logger.ts
import pino from 'pino';
export const logger = pino({
// 使用深层通配 ** 匹配任意嵌套层级,避免漏掉嵌套对象中的敏感字段
redact: {
paths: [
'**.cookie', '**.cookies', '**.set-cookie',
'**.authorization', '**.password', '**.secret',
'**.token', '**.xsec_token', '**.access_token', '**.refresh_token',
'**.api_key', '**.apikey',
'**.sessionid', '**.session_id',
'**.cookies[*].value', // Playwright StorageState 中的 cookie 值
'**.origins[*].localStorage[*].value', // Playwright StorageState 中的 localStorage 值
],
censor: '[REDACTED]',
},
// 自定义错误序列化:生产环境不输出 stack
serializers: {
err: (err: Error) => ({
type: err.constructor.name,
message: err.message,
...(process.env.NODE_ENV === 'development' ? { stack: err.stack } : {}),
}),
},
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
});
// 生产环境禁用 Playwright 调试日志(会绕过 pino 直接输出到 stdout
if (process.env.NODE_ENV === 'production') {
delete process.env.DEBUG;
}
```
> **Review 修正**:原 `*.field` 只匹配一层嵌套,改为 `**.field` 深层匹配。
> 补充了 Playwright StorageState 中 cookie value 的脱敏规则。
> 生产环境禁用 `DEBUG=pw:*` 防止 Playwright 调试日志泄露敏感信息。
---
## 七、安全基线(Phase 1 必须落地)
| 措施 | 说明 |
|------|------|
| **默认监听 127.0.0.1** | 配置项 `HOST=127.0.0.1`,不暴露到局域网/公网 |
| **Host header 校验** | 中间件校验 Host 头只允许 `127.0.0.1` / `localhost`,防御 DNS rebinding 攻击 |
| **拒绝监听 0.0.0.0** | 如果用户配置 `HOST=0.0.0.0`,启动时警告并要求设置 `ALLOW_REMOTE=yes-i-understand-the-risk` |
| **Cookie 文件权限 0600** | `fs.writeFile` 时指定 `mode: 0o600`,目录 0o700 |
| **Cookie 原子写入** | 先写 `.tmp` 文件再 `rename`,防止崩溃导致文件损坏 |
| **不加 CORS** | MCP 客户端走 HTTP 不走浏览器,不需要 CORS header |
| **日志深层脱敏** | pino `**` 深层通配,token/cookie/password 永远不入日志 |
| **错误消息脱敏** | `sanitizeErrorMessage()` 去掉文件路径、长 token,不返回堆栈 |
| **媒体文件路径校验** | 发布图片/视频时校验路径无 `..` 穿越,限制文件大小和 MIME 类型 |
### 7.1 DNS Rebinding 防御
即使监听 `127.0.0.1`,攻击者网页仍可通过 DNS rebinding 向本地服务发送请求。
由于浏览器对 simple POST`Content-Type: text/plain` 等)不做 preflight,写操作会被执行。
```typescript
// server/middleware.ts
function dnsRebindingGuard(req: Request, res: Response, next: NextFunction) {
const host = req.headers.host?.toLowerCase();
const allowed = ['127.0.0.1', 'localhost', `127.0.0.1:${PORT}`, `localhost:${PORT}`];
if (!host || !allowed.includes(host)) {
logger.warn({ host }, 'DNS rebinding 请求被拦截');
return res.status(403).json({ error: 'Forbidden' });
}
next();
}
```
### 7.2 REST API 认证(Phase 5 实现)
REST API 启用时必须加 Bearer token 认证。首次启动生成随机 token 并显示给用户。
使用 `crypto.timingSafeEqual` 做时序安全比较。
### 7.3 媒体文件安全
```typescript
// 发布图片/视频时的路径校验
function validateMediaPath(filePath: string): string {
const resolved = path.resolve(filePath);
if (filePath.includes('..')) throw new Error('路径穿越不允许');
// 校验文件大小(图片 20MB,视频 500MB)
// 通过 magic bytes 校验 MIME 类型,不信任扩展名
return resolved;
}
```
> **Review 说明**DNS rebinding 是安全审计提出的高优先级风险,实现成本极低(一个中间件),Phase 1 必须加。
> Bearer token 认证延迟到 Phase 5REST API 实现时)。MCP 走 stdio transport,不经过 HTTP,不受此影响。
---
## 八、实现计划
### Phase 1: 骨架 + 基础设施
1. 初始化项目(pnpm, tsconfig, tsup, .gitignore, CLAUDE.md, .env.example
2. `src/config/index.ts` — 环境变量(PORT, HOST=127.0.0.1, HEADLESS, BROWSER_BIN+ 启动时校验 HOST(拒绝 0.0.0.0 除非显式确认)
3. `src/utils/logger.ts` — pino + `**` 深层 redact 脱敏 + 自定义错误序列化
4. `src/utils/errors.ts` — withErrorHandling 错误包装 + 错误分类(ErrorCategory+ 消息脱敏
5. `src/browser/manager.ts` — BrowserManager(串行队列 + 分级超时 + timer 清理 + 启动锁 + 背压 + acquirePage 幂等释放 + 崩溃恢复 + drain)
6. `src/cookie/store.ts` — CookieStore(权限 0600 + 原子写入)
7. `src/server/app.ts` — AppServerExpress + MCP,不加 CORS
8. `src/server/middleware.ts` — DNS rebinding 防御中间件(Host header 校验)+ 优雅关闭 503 中间件
9. `src/index.ts` — 入口 + SIGINT/SIGTERM 优雅关闭(先 drain 队列 → 关浏览器 → 关 HTTP → flush 日志)+ 全局 `unhandledRejection` / `uncaughtException` 处理
10. `src/utils/downloader.ts` — 图片下载(发布功能的前置依赖)+ 媒体路径校验
11. `/health` 端点 — 检查 Browser 存活、队列深度、内存使用(从 Phase 5 提前到 Phase 1
12. **测试**BrowserManager 单元测试(队列串行、背压拒绝、超时、启动锁)+ CookieStore 单元测试(读/写/删/原子写入)+ 错误分类单元测试
### Phase 2: 小红书 — 登录
13. `selectors.ts` + `types.ts` + `schemas.ts`
14. `login.ts` — 扫码登录、状态检查(使用 acquirePagerelease 幂等)
15. `actions.ts` 初始化 + `index.ts` 注册 3 个登录工具
16. **测试**zod schema 校验测试 + MCP Inspector 端到端验证登录流程
### Phase 3: 小红书 — 内容浏览
17. `feeds.ts` — 首页 Feed 列表(`__INITIAL_STATE__` 提取)
18. `search.ts` — 搜索 + 筛选
19. `feed-detail.ts` — 笔记详情 + 评论加载(scroll_speed 等内部默认配置)
20. `user-profile.ts` — 用户主页
21. **测试**`__INITIAL_STATE__` JSON 解析单元测试 + MCP Inspector 验证 4 个浏览工具
### Phase 4: 小红书 — 发布 + 互动
22. `publish.ts` — 图文发布(依赖 downloader + 媒体路径校验)
23. `publish-video.ts` — 视频发布
24. `comment.ts` — 评论 + 回复
25. `interaction.ts` — 点赞 / 收藏
26. **测试**downloader 单元测试 + 写操作手动验证(使用测试账号)
### Phase 5: 工程化
27. REST API 路由(调用 actions 层,复用 MCP 的业务逻辑)+ Bearer token 认证 + 限流
28. Docker(见下方 Docker 配置要求)
29. README
---
## 九、测试策略
### 9.1 原则
这个项目的核心是浏览器自动化,大部分逻辑依赖真实页面交互。
**不 mock Playwright Page 来测试页面操作**——成本高、收益低、选择器一变全白写。
只对**可独立测试的纯逻辑**写单元测试,浏览器交互靠端到端手动验证。
### 9.2 测试框架
| 工具 | 用途 |
|------|------|
| **vitest** | 单元测试(快,原生 TS 支持,兼容 Jest API |
| **MCP Inspector** | MCP 工具端到端验证(官方调试工具) |
devDependencies 补充:`"vitest": "^3.0.0"`
### 9.3 单元测试范围
| 模块 | 测什么 | 怎么测 |
|------|--------|--------|
| **BrowserManager** | 串行队列(并发调用按序执行)、超时(超时后正确抛错 + timer 被清理)、启动锁(并发 ensureBrowser 只启动一次)、背压(超出 MAX_QUEUE_DEPTH 拒绝)、崩溃恢复(isConnected=false 时重建) | mock `chromium.launch``Browser` 接口 |
| **CookieStore** | 读/写/删、文件权限 0600、目录自动创建、原子写入(先 tmp 再 rename) | 真实文件系统(临时目录) |
| **withErrorHandling** | 正常返回透传、异常转 `isError: true`、错误分类正确性、消息脱敏(路径/token 被替换) | 纯函数测试 |
| **classifyError** | TIMEOUT / AUTH_REQUIRED / SELECTOR_NOT_FOUND / NETWORK / INTERNAL 各类型识别 | 构造不同错误消息测试 |
| **sanitizeErrorMessage** | 文件路径替换、URL 替换、长 hex 串替换、长度截断 | 纯函数测试 |
| **downloader** | URL 下载、本地路径校验(无 `..` 穿越)、非图片类型拒绝、文件大小限制 | mock HTTP 请求 |
| **数据解析** | `__INITIAL_STATE__` JSON → Feed/Comment 类型的转换 | 固定 JSON fixture 输入,断言输出结构 |
| **zod schema** | 必填字段缺失报错、可选字段默认值、枚举值校验 | 直接调用 `.parse()` / `.safeParse()` |
| **dnsRebindingGuard** | 合法 Host 放行、非法 Host 返回 403 | 构造 mock request 测试 |
### 9.4 端到端验证(手动)
每个 Phase 结束后,用 MCP Inspector 连接服务,逐个调用该阶段的 MCP 工具:
| Phase | 验证内容 |
|-------|---------|
| Phase 2 | `xhs_check_login``xhs_get_login_qrcode`(扫码)→ `xhs_check_login`(确认已登录)→ `xhs_delete_cookies``xhs_check_login`(确认已登出) |
| Phase 3 | `xhs_list_feeds`(有数据返回)→ `xhs_search`(关键词+筛选)→ `xhs_get_feed_detail`(取第一条的详情)→ `xhs_get_user_profile` |
| Phase 4 | `xhs_publish_image`(测试账号发一条)→ `xhs_post_comment``xhs_like``xhs_favorite`。**写操作用测试账号,不用主力账号** |
### 9.5 不做的
- **不 mock Page 测页面操作**:选择器绑定真实 DOM,mock 出来的测试没有意义
- **不做 CI 自动化端到端测试**:依赖真实浏览器 + 真实平台登录态,不适合 CI
- **不追求覆盖率指标**:只测有价值的纯逻辑,不为凑数写无意义的测试
---
## 十、关键设计决策
### 10.1 为什么不做统一 Platform 接口?
各平台业务模型差异大,强行统一 = 大量 `NotSupported` + 失去类型安全。
**插件模式**:共享基础设施,业务逻辑完全独立。
### 10.2 BrowserContext 隔离 + 串行队列
一个 Browser 进程 + 每个平台一个 BrowserContext + per-platform 操作串行化。
- 共享进程,节省资源
- Cookie/Storage 天然隔离
- 串行避免多 Page 互相干扰(同一平台同时只有一个操作在执行)
> **读写分离队列(暂不实现)**:性能工程师建议读操作(搜索、浏览)可以并发执行,只有写操作需要串行。
> 理论上正确,但当前 MCP 工具由 AI 顺序调用,极少出现并发场景。保持简单串行队列,
> 如果未来确实遇到队列积压问题,再升级为读写锁。不过早优化。
> **进程级隔离(暂不实现)**:性能工程师建议每个平台一个浏览器进程,隔离崩溃影响。
> 当前只有一个平台,一个进程足够。每多一个进程 +300MB 内存。等 3+ 平台时再考虑。
### 10.3 登录流程为什么不用 withPage?
`withPage` 是"用完即关",但扫码登录需要:
1. 打开页面获取二维码 → 返回给 AI
2. 用户扫码(页面保持打开)
3. 扫码成功 → 保存 Cookie → 关闭页面
因此登录使用 `acquirePage()` + 手动 release,其他操作用 `withPage()`
`release` 函数是幂等的(内部 `released` 标志位),防止 setTimeout 和 finally 双重触发。
### 10.4 zod 一鱼两吃
- MCP 工具参数 schema(运行时校验)
- TypeScript 类型推导(`z.infer`
- MCP SDK v1.x `server.tool()` 原生支持 zod shape
### 10.5 MCP 工具前缀 `xhs_`
简短、AI 易识别。后续平台:`xhh_`(小黑盒)、`bili_`B站)、`wb_`(微博)。
### 10.6 为什么不在 Phase 1 加 Cookie 加密?
安全审计建议用 AES-256-GCM 加密 cookie 文件并集成 OS keychain。
当前安全模型(文件权限 0600 + 监听 127.0.0.1 + Host 校验)对本地自动化工具足够。
加密引入密钥管理复杂度(密钥存哪里?),收益与成本不匹配。
如果未来需要多用户共享机器或远程部署,再引入加密。
### 10.7 为什么不做选择器 fallback
架构师建议为每个 CSS 选择器准备 fallback 备选。
但选择器失效时通常整个页面结构都变了,fallback 大概率也失效。
保持简单的 `selectors.ts` 集中管理,发现失效时快速修复即可。
---
## 十一、风险和注意事项
1. **CSS 选择器失效** — 集中到 `selectors.ts` 便于快速修复
2. **反爬升级** — rebrowser-playwright 修复底层检测点,比 JS 层 stealth 更彻底,但仍需持续跟进
3. **浏览器资源** — Playwright 进程 ~300MBDocker 镜像较大
4. **Node.js 部署** — Docker 是推荐方式
5. **浏览器崩溃影响** — 所有平台共享一个 Browser 进程,崩溃时全部 context 丢失。`browser.on('disconnected')` 主动清理状态,下次操作自动重建
6. **Cookie 过期** — 平台可能随时吊销 session。当前无主动检测机制,依赖操作失败时的错误分类(`AUTH_REQUIRED`)提示 AI 重新登录
7. **DNS rebinding 攻击** — 即使监听 localhost,攻击者网页可通过 DNS rebinding 发送 POST 请求触发写操作。Phase 1 用 Host header 校验中间件防御
8. **Docker /dev/shm** — 必须配置 `shm_size: 1gb`,否则 Chromium 会因共享内存不足 SIGBUS 崩溃
9. **长操作队列阻塞** — 视频发布可能耗时 1-5 分钟,期间同平台其他操作全部排队等待。已用分级超时缓解(快操作 15s 超时,不会被慢操作的默认超时卡住)
---
## 十二、Docker 配置要求
```yaml
# deploy/docker-compose.yml
services:
social-auto-hub:
build: .
ports:
- "127.0.0.1:3000:3000" # 必须带 127.0.0.1 前缀,不要省略
shm_size: '1gb' # 必须:Chromium 需要足够的共享内存,默认 64MB 会导致 SIGBUS 崩溃
deploy:
resources:
limits:
memory: 2g # 至少 2x 预期峰值
cpus: '2.0' # Chromium 多进程架构需要至少 2 核
reservations:
memory: 1g
cpus: '1.0'
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:size=512m
volumes:
- cookie-data:/home/appuser/.social-auto-hub
environment:
- NODE_ENV=production
volumes:
cookie-data:
```
```dockerfile
# Dockerfile 要点
# - 非 root 用户运行(UID 1001
# - 多阶段构建,不把 .env 或密钥文件复制进镜像层
# - HEALTHCHECK 检查 /health 端点
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1))"
```
---
## 十三、依赖清单
```json
{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.0",
"rebrowser-playwright": "^1.52.0",
"express": "^4.21.0",
"pino": "^9.0.0",
"zod": "^3.25.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"tsup": "^8.0.0",
"vitest": "^3.0.0",
"pino-pretty": "^13.0.0",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0"
}
}
```
---
## 附录 AReview 修正清单
本 Plan 经过四方专业评审(架构师、安全审计、后端架构师、性能工程师),以下是已采纳的修正:
### 已整合进 Plan 的修正(P0/P1
| 优先级 | 问题 | 来源 | 对应章节 |
|--------|------|------|---------|
| **P0** | 超时 timer 泄漏 + fn 在 page 关闭后继续运行 | 全员 | 6.1 BrowserManager |
| **P0** | `ensureBrowser()` 并发竞争可能启动多个浏览器 | 后端+性能 | 6.1 launchPromise |
| **P0** | `release()` 双重调用竞态 | 后端+架构 | 6.1 acquirePage + 6.2 登录流程 |
| **P0** | `withErrorHandling` 缺少 async 关键字 | 后端 | 6.4 错误处理 |
| **P1** | 队列无深度限制(无背压) | 全员 | 6.1 MAX_QUEUE_DEPTH |
| **P1** | 缺少错误分类体系 | 架构+后端 | 6.4 ErrorCategory |
| **P1** | 错误消息泄露文件路径 | 安全 | 6.4 sanitizeErrorMessage |
| **P1** | DNS rebinding 攻击 | 安全 | 7.1 dnsRebindingGuard |
| **P1** | 日志 redact 只匹配一层 | 安全 | 6.5 `**` 深层通配 |
| **P1** | Health check 在 Phase 5 太晚 | 架构+性能 | 8 Phase 1 第 11 项 |
| **P1** | Docker `shm_size` 缺失 | 性能 | 十二 Docker 配置 |
| **P1** | 浏览器崩溃后 contexts Map 残留 | 后端+性能 | 6.1 disconnected 事件 |
### 已整合但标记为延迟实现的
| 问题 | 来源 | 决定 | 对应章节 |
|------|------|------|---------|
| PlatformPlugin 生命周期钩子 | 架构 | 可选钩子,第二个平台时评估 | 3.2 |
| REST API Bearer token | 安全 | Phase 5 实现 | 7.2 |
| 媒体文件路径穿越 | 安全 | Phase 4 publish 时实现 | 7.3 |
| Cookie 原子写入 | 后端 | Phase 1 CookieStore 实现 | 6.3 |
| 分级超时 | 性能 | Phase 1 BrowserManager 实现 | 6.1 |
### 评审后明确不采纳的
| 建议 | 来源 | 不采纳原因 | 对应章节 |
|------|------|-----------|---------|
| Cookie 加密 + OS keychain | 安全 | 本地工具,文件权限已足够,密钥管理增加复杂度 | 10.6 |
| 选择器 fallback 机制 | 架构 | 选择器失效时通常整页变化,fallback 无意义 | 10.7 |
| 读写分离队列 | 性能 | AI 顺序调用极少并发,过早优化 | 10.2 |
| 进程级平台隔离 | 性能 | 只有一个平台,+300MB/进程 成本过高 | 10.2 |
| 内存安全(Buffer 清零) | 安全 | JS 字符串不可变,Node.js 中不实用 | — |