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

38 KiB
Raw Permalink Blame History

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


三、架构设计:插件式多平台

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 }>;
}

新增平台只需要:

  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 中的函数,不重复业务逻辑。

// 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_speedclick_more_repliesreply_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

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() 没有幂等保护,setTimeoutfinally 可能双重触发。 现在 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 的 TimeoutErrorTargetClosedError 等异常统一转为 MCP 的 isError: true 响应,不会导致连接断开。 AI 助手可以根据 error 字段判断是否需要重试、重新登录、或报告错误。

Review 修正:原设计中 withErrorHandling 不是 async 却用了 await(编译不通过), 且返回类型 TMcpToolResult 不匹配。已修正。 新增错误分类 + 消息脱敏,防止泄露文件路径等内部信息。

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 POSTContent-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 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: 小红书 — 登录

  1. selectors.ts + types.ts + schemas.ts
  2. login.ts — 扫码登录、状态检查(使用 acquirePagerelease 幂等)
  3. actions.ts 初始化 + index.ts 注册 3 个登录工具
  4. 测试zod schema 校验测试 + MCP Inspector 端到端验证登录流程

Phase 3: 小红书 — 内容浏览

  1. feeds.ts — 首页 Feed 列表(__INITIAL_STATE__ 提取)
  2. search.ts — 搜索 + 筛选
  3. feed-detail.ts — 笔记详情 + 评论加载(scroll_speed 等内部默认配置)
  4. user-profile.ts — 用户主页
  5. 测试__INITIAL_STATE__ JSON 解析单元测试 + MCP Inspector 验证 4 个浏览工具

Phase 4: 小红书 — 发布 + 互动

  1. publish.ts — 图文发布(依赖 downloader + 媒体路径校验)
  2. publish-video.ts — 视频发布
  3. comment.ts — 评论 + 回复
  4. interaction.ts — 点赞 / 收藏
  5. 测试downloader 单元测试 + 写操作手动验证(使用测试账号)

Phase 5: 工程化

  1. REST API 路由(调用 actions 层,复用 MCP 的业务逻辑)+ Bearer token 认证 + 限流
  2. Docker(见下方 Docker 配置要求)
  3. 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.launchBrowser 接口
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_loginxhs_get_login_qrcode(扫码)→ xhs_check_login(确认已登录)→ xhs_delete_cookiesxhs_check_login(确认已登出)
Phase 3 xhs_list_feeds(有数据返回)→ xhs_search(关键词+筛选)→ xhs_get_feed_detail(取第一条的详情)→ xhs_get_user_profile
Phase 4 xhs_publish_image(测试账号发一条)→ xhs_post_commentxhs_likexhs_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_(微博)。

安全审计建议用 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 配置要求

# 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"
  }
}

附录 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 中不实用