多平台社交自动化 MCP 服务,首批支持小红书。 - 13 个 MCP 工具:登录管理、内容浏览、发布、互动 - 13 个 REST API 端点,支持 Bearer token 认证和限流 - BrowserManager:串行队列、背压、崩溃恢复 - Cookie 持久化:原子写入、0600 权限 - 安全:DNS rebinding 防御、错误脱敏、深层日志 redact - Docker 部署支持 - 28 个单元测试全部通过
38 KiB
Social Auto Hub — 多平台社交自动化 MCP 服务
一、项目定位
通过浏览器自动化,让 AI 助手(Claude 等)能操控多个社交平台。 首批支持:小红书。后续按需扩展小黑盒、B站、微博等。
二、技术栈选型
| 组件 | 选择 | 理由 |
|---|---|---|
| 语言 | TypeScript 5.x | 浏览器自动化生态最强,MCP SDK 是官方参考实现 |
| 运行时 | Node.js 22 LTS | 当前 LTS,Node 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 fork(2026.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。
三、架构设计:插件式多平台
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 工具注册进去:
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 }>;
}
新增平台只需要:
- 在
src/platforms/下新建目录 - 实现
PlatformPlugin - 在
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 schema(MCP 工具参数)
├── types.ts # 业务类型
├── login.ts # 登录操作(页面交互)
├── search.ts # 搜索操作
├── ... # 其他操作
actions.ts 是核心:MCP handler 和 REST handler 都调用 actions 中的函数,不重复业务逻辑。
// 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 API(Phase 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
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 生命周期)
// 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
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 响应携带结构化的错误信息。
// 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 日志脱敏
// 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,写操作会被执行。
// 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 媒体文件安全
// 发布图片/视频时的路径校验
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 5(REST API 实现时)。MCP 走 stdio transport,不经过 HTTP,不受此影响。
八、实现计划
Phase 1: 骨架 + 基础设施
- 初始化项目(pnpm, tsconfig, tsup, .gitignore, CLAUDE.md, .env.example)
src/config/index.ts— 环境变量(PORT, HOST=127.0.0.1, HEADLESS, BROWSER_BIN)+ 启动时校验 HOST(拒绝 0.0.0.0 除非显式确认)src/utils/logger.ts— pino +**深层 redact 脱敏 + 自定义错误序列化src/utils/errors.ts— withErrorHandling 错误包装 + 错误分类(ErrorCategory)+ 消息脱敏src/browser/manager.ts— BrowserManager(串行队列 + 分级超时 + timer 清理 + 启动锁 + 背压 + acquirePage 幂等释放 + 崩溃恢复 + drain)src/cookie/store.ts— CookieStore(权限 0600 + 原子写入)src/server/app.ts— AppServer(Express + MCP,不加 CORS)src/server/middleware.ts— DNS rebinding 防御中间件(Host header 校验)+ 优雅关闭 503 中间件src/index.ts— 入口 + SIGINT/SIGTERM 优雅关闭(先 drain 队列 → 关浏览器 → 关 HTTP → flush 日志)+ 全局unhandledRejection/uncaughtException处理src/utils/downloader.ts— 图片下载(发布功能的前置依赖)+ 媒体路径校验/health端点 — 检查 Browser 存活、队列深度、内存使用(从 Phase 5 提前到 Phase 1)- 测试:BrowserManager 单元测试(队列串行、背压拒绝、超时、启动锁)+ CookieStore 单元测试(读/写/删/原子写入)+ 错误分类单元测试
Phase 2: 小红书 — 登录
selectors.ts+types.ts+schemas.tslogin.ts— 扫码登录、状态检查(使用 acquirePage,release 幂等)actions.ts初始化 +index.ts注册 3 个登录工具- 测试:zod schema 校验测试 + MCP Inspector 端到端验证登录流程
Phase 3: 小红书 — 内容浏览
feeds.ts— 首页 Feed 列表(__INITIAL_STATE__提取)search.ts— 搜索 + 筛选feed-detail.ts— 笔记详情 + 评论加载(scroll_speed 等内部默认配置)user-profile.ts— 用户主页- 测试:
__INITIAL_STATE__JSON 解析单元测试 + MCP Inspector 验证 4 个浏览工具
Phase 4: 小红书 — 发布 + 互动
publish.ts— 图文发布(依赖 downloader + 媒体路径校验)publish-video.ts— 视频发布comment.ts— 评论 + 回复interaction.ts— 点赞 / 收藏- 测试:downloader 单元测试 + 写操作手动验证(使用测试账号)
Phase 5: 工程化
- REST API 路由(调用 actions 层,复用 MCP 的业务逻辑)+ Bearer token 认证 + 限流
- Docker(见下方 Docker 配置要求)
- 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 是"用完即关",但扫码登录需要:
- 打开页面获取二维码 → 返回给 AI
- 用户扫码(页面保持打开)
- 扫码成功 → 保存 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 集中管理,发现失效时快速修复即可。
十一、风险和注意事项
- CSS 选择器失效 — 集中到
selectors.ts便于快速修复 - 反爬升级 — rebrowser-playwright 修复底层检测点,比 JS 层 stealth 更彻底,但仍需持续跟进
- 浏览器资源 — Playwright 进程 ~300MB,Docker 镜像较大
- Node.js 部署 — Docker 是推荐方式
- 浏览器崩溃影响 — 所有平台共享一个 Browser 进程,崩溃时全部 context 丢失。
browser.on('disconnected')主动清理状态,下次操作自动重建 - Cookie 过期 — 平台可能随时吊销 session。当前无主动检测机制,依赖操作失败时的错误分类(
AUTH_REQUIRED)提示 AI 重新登录 - DNS rebinding 攻击 — 即使监听 localhost,攻击者网页可通过 DNS rebinding 发送 POST 请求触发写操作。Phase 1 用 Host header 校验中间件防御
- Docker /dev/shm — 必须配置
shm_size: 1gb,否则 Chromium 会因共享内存不足 SIGBUS 崩溃 - 长操作队列阻塞 — 视频发布可能耗时 1-5 分钟,期间同平台其他操作全部排队等待。已用分级超时缓解(快操作 15s 超时,不会被慢操作的默认超时卡住)
十二、Docker 配置要求
# 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 要点
# - 非 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))"
十三、依赖清单
{
"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"
}
}
附录 A:Review 修正清单
本 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 中不实用 | — |