需求背景
手上有一批技术文章需要在多个平台发布:CSDN、掘金、51CTO、开源中国、博客园。每个平台都要单独登录、单独排版、单独上传,一篇文章发五个平台要花掉将近一小时。
于是想做一个工具:本地写一份 Markdown,一条命令同步到全部平台。
调研下来,开源方案里比较成熟的是 Wechatsync------它本身是个 Chrome 扩展,2026 年的版本同时提供了 CLI。这篇文章记录实际落地过程中遇到的问题和解决办法,给同样想做多平台同步的人省点时间。
一、先搞清楚架构:CLI 不干活,扩展才干活
很多人第一次用会觉得奇怪:装了 CLI,为什么还要在浏览器里装扩展?
因为 Wechatsync 的设计是扩展干活、CLI 当桥:
bash
本地 Markdown ──> CLI(WebSocket 服务端,监听 9527)
↑
扩展主动连上来(携带 MCP Token)
↓
浏览器已登录的 Cookie / 页面操作
↓
各平台草稿箱 / 文章接口
关键点有三个:
- CLI 只是本地 WebSocket 服务,它没有平台账号,也没有发布能力;
- 扩展必须主动连上来,而且要带着 Token 握手,这个 Token 在扩展弹窗里的「MCP 连接」开关里生成;
- 登录态全靠浏览器,所以必须用一个持久化的浏览器 profile,登录一次之后就不用重复扫码。
理解了这一点,后面所有问题都好定位:连不上扩展查 Token,发不出去查 Cookie,发出去没内容查编辑器。
二、坑一:本地调试端口被代理接管
第一个坑很隐蔽。CLI 起来了、扩展也连上了,但所有请求都返回 502。
排查过程:
bash
# 直接看环境变量
env | grep -i proxy
# http_proxy=http://127.0.0.1:55560
# https_proxy=http://127.0.0.1:55560
本机开着代理软件(Clash 一类),它把 HTTP_PROXY 写进了环境变量。Node.js 的部分网络库处理本地回环地址的逻辑和其他请求不一致,访问 127.0.0.1:9527 的请求也被送进了代理,而代理不认识这个地址,直接返回 502。
解决办法:所有涉及本地端口的命令都要显式排除代理干扰。
bash
# Windows / Git Bash
export NO_PROXY="127.0.0.1,localhost"
export no_proxy="127.0.0.1,localhost"
用 curl 测试时加 --noproxy "*"。这一条不解决,后面所有调试都是在错误的前提下瞎猜。
三、坑二:Playwright 的 CDP 连接被中断
为了用脚本操作浏览器页面,第一反应是 Playwright 的 connectOverCDP。但在 Windows + 沙箱环境下,进程会收到 SIGTERM 直接退出,日志里一行错误都没有。
换成裸 WebSocket 直连 CDP 协议就稳定了:
javascript
const targets = await fetch('http://127.0.0.1:9333/json/list').then(r => r.json());
const page = targets.find(t => t.url.includes('目标页面'));
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise(res => ws.on('open', res));
ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: { expression: 'document.title', returnByValue: true }
}));
启动浏览器时加 --remote-debugging-port=9333,就能通过 /json/list 拿到所有标签页的调试地址。
两个细节值得注意:
- 方法名是
Runtime.evaluate,参数名是expression,写成expr会报mandatory field missing; - 想拿到异步结果,用
awaitPromise: true,或者在页面里挂全局变量轮询,后者在部分老版本里更兼容。
四、坑三:CSDN 的编辑器不是 CodeMirror
这是最容易让人白忙半天的坑。CSDN 的 Markdown 编辑器看起来像 CodeMirror,但 DOM 里其实是一个 contenteditable 的 <pre>:
javascript
// 找 .CodeMirror 是找不到的
document.querySelectorAll('.CodeMirror').length // => 0
// 真正的编辑区
document.querySelector('pre.editor__inner') // => 存在,contenteditable
既然是 contenteditable,直接赋值 innerText 是不会被框架识别的。可行的做法是全选内容后执行一次文本插入:
javascript
const pre = document.querySelector('pre.editor__inner');
pre.focus();
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(pre);
sel.removeAllRanges();
sel.addRange(range);
document.execCommand('insertText', false, newContent);
execCommand 虽然已经标记为废弃,但在 contenteditable 场景下依然是兼容性最好的方案------它走的是浏览器的编辑命令通道,框架能收到真实的 input 事件。
对照着看,其他平台的编辑器又是另一套:
| 平台 | 编辑区 | 可靠方案 |
|---|---|---|
| CSDN | pre.editor__inner(contenteditable) |
全选 + execCommand('insertText') |
| 掘金 | textarea / 富文本双模式 | 切 Markdown 模式后直接写 value |
| 博客园 | 独立编辑器 | 走 API 提交 HTML |
没有一套通用的填内容方案,每个平台都得单独适配。
五、坑四:点击没反应?先查有没有东西盖着
调试中最耗时的场景:按钮明明定位到了,脚本点了,但没有任何请求发出。
这次的原因是一层登录态校验的浮层:
javascript
const e = document.elementFromPoint(x, y);
// => DIV.passport-login-mark2
浮层在页面上不可见(或闪烁一下),但它把点击事件全吞了。后来养成一个习惯:每次点击前先验证落点。
javascript
const btn = [...document.querySelectorAll('button')]
.find(b => b.innerText.trim() === '发布文章');
const r = btn.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
const safeToClick = hit === btn || btn.contains(hit);
顺带一个经验:这类页面上的模态框(模板库、审核提示、新手引导)会不定时弹出,Escape 键是最可靠的关闭方式,比去找那个位置飘忽的关闭按钮省事得多。
六、坑五:批处理脚本里的中文
写了个 .cmd 双击启动浏览器,内容是 UTF-8,注释用中文。结果黑框一闪就消失,浏览器根本没起来。
原因:Windows 命令行按 GBK 读文件,UTF-8 的中文注释变成乱码,可能把后续的启动命令一起吃掉。
结论:.cmd / .bat 里不要写中文注释 ,或者第一行加 chcp 65001 并确保文件无 BOM 保存。纯英文注释是最省事的做法。
七、还有一个认知问题:草稿不等于发布
同步工具普遍只做到「存草稿」,因为各平台的发布接口大多不对外开放,而且一次性发到所有平台,出错的影响面太大。
实测下来各平台行为还不统一:
- CSDN、掘金:进草稿箱,需要人工确认后发布;
- 51CTO:部分情况下直接提交为待审核状态;
- 博客园:走 API 时拿到的是草稿,但接口对 Cookie 完整性要求高。
所以同步完必须回各平台后台核对实际状态,不能只看 CLI 的返回值。
八、这套方案值不值得做
如果只发一两个平台:不值得,手动更快。
如果发三个以上平台,并且长期有内容产出:值得。核心收益不是省下那几十分钟,而是发布流程标准化------排版不会再因为平台不同而走样,错发、漏发、重复发的问题基本消失。
维护成本也要有预期:平台前端改版、Cookie 策略变严、编辑器换实现,都会让某个适配器失效。所以选择有社区维护的方案,比自己从零写一套更划算。
最后提醒一句:工具只解决「发出去」,不解决「发出去能活」。内容本身的合规性、平台社区调性是否匹配,这些是工具管不了的,得自己在选题阶段就想清楚。