# 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](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; shutdown?(): Promise; 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 schema(MCP 工具参数) ├── 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 { ... } export async function checkLoginStatus(page: Page): Promise { ... } // 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 ```typescript import { Browser, BrowserContext, Page } from 'rebrowser-playwright'; class BrowserManager { private browser: Browser | null = null; private contexts = new Map(); private queues = new Map>(); // per-platform 串行队列 private queueDepths = new Map(); // 队列深度计数 private launchPromise: Promise | null = null; // 启动锁 private readonly MAX_QUEUE_DEPTH = 10; // 分级超时:不同操作类型使用不同超时时间 static readonly OPERATION_TIMEOUTS: Record = { 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( platform: string, fn: (page: Page) => Promise, timeoutMs?: number, ): Promise { // 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; const result = await Promise.race([ fn(page), new Promise((_, 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 }> { 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 { 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; async saveCookies(platform: string): Promise; // 等待所有队列排空(用于优雅关闭) async drain(): Promise { await Promise.allSettled(Array.from(this.queues.values())); } async close(): Promise; } ``` 关键设计点: - **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 { 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) { // 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// // 文件权限: 0o600(仅 owner 可读写) // 目录权限: 0o700 getPath(platform: string): string; async load(platform: string): Promise; // 原子写入:先写临时文件再 rename,防止崩溃导致文件损坏 async save(platform: string, state: StorageState): Promise { 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; } ``` > **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, ): Promise { 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 5(REST 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` — AppServer(Express + 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` — 扫码登录、状态检查(使用 acquirePage,release 幂等) 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 进程 ~300MB,Docker 镜像较大 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" } } ``` --- ## 附录 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 中不实用 | — |