标签:Node.js TypeScript Playwright 自动化 内容分发
背景:为什么要造这个轮子
我们团队做电商内容营销,每天要在 6-8 个平台发布内容。之前的流程是:
- 编辑在飞书文档写好内容
- 手动登录每个平台
- 复制粘贴 + 手动排版 + 手动上传图片
- 记录每个平台的发布链接
一篇文章发 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