feat: social-mcp 初始实现

多平台社交自动化 MCP 服务,首批支持小红书。

- 13 个 MCP 工具:登录管理、内容浏览、发布、互动
- 13 个 REST API 端点,支持 Bearer token 认证和限流
- BrowserManager:串行队列、背压、崩溃恢复
- Cookie 持久化:原子写入、0600 权限
- 安全:DNS rebinding 防御、错误脱敏、深层日志 redact
- Docker 部署支持
- 28 个单元测试全部通过
This commit is contained in:
2026-02-28 22:57:22 +08:00
commit 8da5f40c9f
38 changed files with 11273 additions and 0 deletions
+911
View File
@@ -0,0 +1,911 @@
# 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 中不实用 | — |