我用 Node.js 写了个多平台内容分发工具(附架构设计)

5 阅读1分钟

标签Node.js TypeScript Playwright 自动化 内容分发


背景:为什么要造这个轮子

我们团队做电商内容营销,每天要在 6-8 个平台发布内容。之前的流程是:

  1. 编辑在飞书文档写好内容
  2. 手动登录每个平台
  3. 复制粘贴 + 手动排版 + 手动上传图片
  4. 记录每个平台的发布链接

一篇文章发 6 个平台,光复制粘贴就要 40 分钟。更痛苦的是排版——百家号的编辑器和头条号不一样,知乎又是一套,小红书只能用 App 端……

市面上有一些分发工具(比如融媒宝、易媒助手),但它们要么需要授权账号(安全隐患),要么对平台覆盖不全,要么对 Markdown 源文件支持很差。

所以我决定自己写一个。


整体架构

先看架构图:

graph TB
    subgraph Core["核心引擎"]
        Scheduler[任务调度器<br/>node-cron]
        Queue[任务队列<br/>BullMQ + Redis]
        Orchestrator[编排器<br/>流程控制]
    end

    subgraph Browser["浏览器层"]
        CDP[CDP 连接池]
        CookieStore[Cookie 持久化<br/>AES-256 加密存储]
        PagePool[页面池<br/>复用 Browser Context]
    end

    subgraph Adapters["平台适配器"]
        BJH[百家号 Adapter]
        TT[头条号 Adapter]
        ZH[知乎 Adapter]
        JJ[掘金 Adapter]
        XHS[小红书 Adapter]
        WX[微信公众号 Adapter]
    end

    subgraph Transform["内容转换层"]
        MD[Markdown Parser<br/>unified + remark]
        Formatter[平台格式化器]
        ImageUploader[图片上传器<br/>平台专属 CDN]
    end

    Scheduler --> Queue
    Queue --> Orchestrator
    Orchestrator --> CDP
    CDP --> PagePool
    PagePool --> Adapters
    Orchestrator --> Transform
    Transform --> Adapters
    CookieStore --> CDP

核心设计思路:适配器模式 + CDP 连接复用 + Cookie 持久化


核心模块实现

1. 平台适配器(Adapter Pattern)

每个平台的操作逻辑差异很大,但抽象出来核心流程是一致的:登录 → 创建文章 → 填充内容 → 设置分类/标签 → 发布。

// types/adapter.ts
interface PlatformAdapter {
  readonly name: string;
  readonly baseUrl: string;

  // 生命周期
  checkLoginStatus(page: Page): Promise<boolean>;
  login(page: Page, credentials: Credentials): Promise<void>;

  // 内容操作
  createPost(page: Page, content: TransformedContent): Promise<string>;
  uploadImage(page: Page, image: Buffer, filename: string): Promise<string>;

  // 发布
  publish(page: Page): Promise<PublishResult>;

  // 平台特化
  formatContent(raw: ParsedMarkdown): TransformedContent;
  getCategoryMapping(): Map<string, string>;
}

// adapters/baijiahao.adapter.ts
class BaijiahaoAdapter implements PlatformAdapter {
  readonly name = 'baijiahao';
  readonly baseUrl = 'https://baijiahao.baidu.com';

  async checkLoginStatus(page: Page): Promise<boolean> {
    await page.goto(`${this.baseUrl}/builder/rc/edit`);
    // 百家号登录态检测:看是否跳转到了登录页
    const url = page.url();
    return !url.includes('passport.baidu.com');
  }

  async createPost(page: Page, content: TransformedContent): Promise<string> {
    await page.goto(`${this.baseUrl}/builder/rc/edit`);
    await page.waitForSelector('.editor-title');

    // 百家号编辑器是自研的,需要特殊处理
    await page.click('.editor-title');
    await page.keyboard.type(content.title);

    // 正文需要通过剪贴板注入富文本
    await this.injectRichContent(page, content.htmlBody);

    return page.url(); // 返回草稿 URL
  }

  formatContent(raw: ParsedMarkdown): TransformedContent {
    // 百家号特化:
    // 1. 不支持代码块高亮,转为引用块
    // 2. 图片必须通过百家号 CDN,不能外链
    // 3. 标题不能超过 30 字
    return {
      title: raw.title.slice(0, 30),
      htmlBody: this.transformHtml(raw.html),
      cover: raw.images[0] || null,
      tags: raw.tags.slice(0, 5),
    };
  }

  private async injectRichContent(page: Page, html: string): Promise<void> {
    // 通过 CDP 直接操作剪贴板,绕过编辑器的粘贴过滤
    const client = await page.context().newCDPSession(page);
    await client.send('Runtime.evaluate', {
      expression: `
        const dt = new DataTransfer();
        dt.setData('text/html', ${JSON.stringify(html)});
        document.querySelector('.editor-content')
          .dispatchEvent(new ClipboardEvent('paste', {
            clipboardData: dt,
            bubbles: true
          }));
      `
    });
  }
}

每个平台的 Adapter 独立维护,新增平台只需要实现 PlatformAdapter 接口。目前 6 个适配器加起来约 2000 行代码,其中小红书的最复杂(需要模拟移动端 + 处理图文笔记的特殊格式)。

2. CDP 连接池与 Cookie 持久化

这是整个项目最关键的模块。为什么用 CDP 而不是直接用 Playwright 的高层 API?

因为需要连接到已有的浏览器实例。

很多平台(尤其百家号和公众号)的登录涉及扫码、短信验证、滑块验证,自动化登录的成本太高。更好的方案是:第一次手动登录,之后 Cookie 持久化自动保持登录态。

// browser/cdp-pool.ts
class CDPConnectionPool {
  private connections: Map<string, BrowserContext> = new Map();
  private cookieStore: CookieStore;

  constructor(private config: PoolConfig) {
    this.cookieStore = new EncryptedCookieStore(config.cookieDir, config.encryptionKey);
  }

  async getContext(platform: string): Promise<BrowserContext> {
    // 复用已有连接
    if (this.connections.has(platform)) {
      const ctx = this.connections.get(platform)!;
      if (ctx.browser()?.isConnected()) return ctx;
    }

    // 创建新的 Browser Context,加载持久化 Cookie
    const browser = await chromium.connectOverCDP(this.config.cdpEndpoint);
    const context = await browser.newContext({
      userAgent: this.getRotatedUA(platform),
      viewport: { width: 1440, height: 900 },
    });

    // 恢复 Cookie
    const cookies = await this.cookieStore.load(platform);
    if (cookies.length > 0) {
      await context.addCookies(cookies);
    }

    this.connections.set(platform, context);
    return context;
  }

  async persistCookies(platform: string): Promise<void> {
    const ctx = this.connections.get(platform);
    if (!ctx) return;

    const cookies = await ctx.cookies();
    // AES-256-GCM 加密存储,防止 Cookie 泄露
    await this.cookieStore.save(platform, cookies);
  }
}

Cookie 加密存储这一块,用的是 Node.js 原生 crypto 模块的 AES-256-GCM:

// browser/cookie-store.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

class EncryptedCookieStore {
  private algorithm = 'aes-256-gcm' as const;

  async save(platform: string, cookies: Cookie[]): Promise<void> {
    const iv = randomBytes(16);
    const cipher = createCipheriv(this.algorithm, this.key, iv);

    const data = JSON.stringify(cookies);
    const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
    const authTag = cipher.getAuthTag();

    // 存储格式:iv(16) + authTag(16) + encrypted
    const payload = Buffer.concat([iv, authTag, encrypted]);
    await writeFile(this.getPath(platform), payload);
  }

  async load(platform: string): Promise<Cookie[]> {
    const payload = await readFile(this.getPath(platform));
    const iv = payload.subarray(0, 16);
    const authTag = payload.subarray(16, 32);
    const encrypted = payload.subarray(32);

    const decipher = createDecipheriv(this.algorithm, this.key, iv);
    decipher.setAuthTag(authTag);

    const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
    return JSON.parse(decrypted.toString('utf8'));
  }
}

3. 内容转换管线

Markdown 源文件到各平台富文本的转换链:

// transform/pipeline.ts
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';

class ContentPipeline {
  private processor = unified()
    .use(remarkParse)
    .use(remarkGfm)          // 支持表格、任务列表
    .use(remarkRehype)
    .use(rehypeStringify);

  async transform(
    markdown: string,
    adapter: PlatformAdapter
  ): Promise<TransformedContent> {
    // Step 1: Parse Markdown AST
    const ast = this.processor.parse(markdown);

    // Step 2: 提取元数据(frontmatter)
    const meta = this.extractFrontmatter(markdown);

    // Step 3: 转 HTML
    const html = String(await this.processor.process(markdown));

    // Step 4: 平台特化格式化
    const parsed: ParsedMarkdown = {
      title: meta.title,
      html,
      images: this.extractImages(ast),
      tags: meta.tags || [],
    };

    return adapter.formatContent(parsed);
  }

  private extractImages(ast: Node): string[] {
    const images: string[] = [];
    visit(ast, 'image', (node: Image) => {
      images.push(node.url);
    });
    return images;
  }
}

4. 任务编排与失败重试

// orchestrator/publish-job.ts
import { Queue, Worker } from 'bullmq';

const publishQueue = new Queue('publish', {
  connection: redisConfig,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: 'exponential', delay: 30_000 }, // 30s, 60s, 120s
    removeOnComplete: { age: 7 * 24 * 3600 }, // 保留 7 天
  }
});

const worker = new Worker('publish', async (job) => {
  const { platform, contentPath, options } = job.data;
  const adapter = AdapterRegistry.get(platform);
  const pool = CDPConnectionPool.getInstance();

  const context = await pool.getContext(platform);
  const page = await context.newPage();

  try {
    // 检查登录态
    const isLoggedIn = await adapter.checkLoginStatus(page);
    if (!isLoggedIn) {
      // Cookie 过期,标记需要手动重新登录
      throw new LoginExpiredError(platform);
    }

    // 转换内容
    const content = await pipeline.transform(
      await readFile(contentPath, 'utf-8'),
      adapter
    );

    // 上传图片到平台 CDN
    for (const img of content.images) {
      const uploaded = await adapter.uploadImage(page, img.buffer, img.name);
      content.htmlBody = content.htmlBody.replace(img.originalUrl, uploaded);
    }

    // 创建并发布
    await adapter.createPost(page, content);
    const result = await adapter.publish(page);

    // 持久化更新后的 Cookie
    await pool.persistCookies(platform);

    return result;
  } finally {
    await page.close();
  }
}, { connection: redisConfig, concurrency: 2 });

// 监听失败事件
worker.on('failed', (job, err) => {
  if (err instanceof LoginExpiredError) {
    // 发送飞书通知,提醒手动重新登录
    notify(`[${err.platform}] Cookie 过期,请手动登录后重试`);
  }
});

运行效果

稳定运行 2 个月后的数据:

指标数值
支持平台数6(百家号/头条/知乎/掘金/小红书/公众号)
日均发布任务15-20 篇
发布成功率96.3%(失败主要是 Cookie 过期)
单篇平均发布时间45 秒/平台
6 平台总耗时~4.5 分钟(之前手动 40 分钟)
Cookie 平均有效期百家号 7 天 / 头条 14 天 / 知乎 30 天

数据统计周期:2026 年 2 月-3 月


踩过的坑

1. 反爬对抗

百家号和头条号会检测自动化特征。解决方案:

  • navigator.webdriver 隐藏:Playwright stealth 插件
  • 操作间隔随机化:每次点击/输入前加 200-800ms 随机延迟
  • 指纹多样化:每个平台用不同的 UserAgent 和 viewport

2. 富文本编辑器适配

每个平台的编辑器实现不一样:百家号是自研、头条用 Draft.js、知乎用 Slate.js、掘金用 ByteMD。直接 page.fill() 在富文本编辑器里基本不work,必须通过剪贴板事件注入 HTML。

3. 图片上传

不能用外链!每个平台都要求图片上传到自己的 CDN。而且上传接口还不一样——有的是标准 multipart/form-data,有的是私有协议。最后每个平台的图片上传逻辑都是单独写的。


开源说明

这个工具是 CallFay 小助手(callfay-assistant)的核心模块之一。CallFay 小助手是我们内部用的电商全链路自动化工具,内容分发只是其中一个能力,还包括竞品监控、数据采集、自动化投放等。

目前整个小助手项目还在内部迭代,如果对适配器模式的实现细节感兴趣,欢迎评论区讨论。


了解更多:callfay.ai