每篇稿子都要开浏览器、粘 Markdown、补标签、选专栏、点发布,五分钟起步还总漏项。本文把创作中心的 X-Ca 网关签名和 saveArticle 协议逆出来,做成一条命令发布的 CLI,含 10 个真实踩坑。
1. 背景与目标
1.1 背景
写博客的链路本来是「本地写 md → 打开创作中心 → 新建文章 → 粘贴 → 填标签 → 选专栏 → 发布」,单篇 3~5 分钟,纯机械劳动。稿子攒到几十篇之后,三个问题越来越明显:
- 重复劳动:内容在本地 md 里已经成型,还要人工搬到网页;
- 容易漏项 :标签忘填直接 400、专栏忘选文章不归类、标题里的
#被一起提交成标题(线上真的存在标题以#开头的文章); - 状态不同步:发布完没人记得哪篇发过、哪篇没发,稿库越堆越乱。
1.2 目标
一条命令完成「本地 md → 线上文章 → 稿子归档」,且满足:
| 目标 | 验收标准 |
|---|---|
| 不点浏览器 | 纯 HTTP,进程内完成,不依赖 DOM |
| 本地前置校验 | 标题长度、标签、敏感信息在提交前拦下,不靠服务端报错 |
| 幂等 | 同标题重跑是覆盖更新,不会产生重复文章 |
| 可验证 | 发布后能读回标题/状态/标签/专栏做比对 |
| 可清理 | 测试稿能彻底删掉,不留在回收站 |
| 成功后归档 | 稿件自动从待发目录移到已发目录 |
成功标准量化一下:单篇 2 秒内完成、零浏览器进程、失败原因能一眼看清楚。
2. 技术选型
| 维度 | 方案 A:手工复制粘贴 | 方案 B:CDP/Playwright 驱动浏览器 | 方案 C:逆向创作中心 HTTP 接口 | 方案 D:官方开放 API |
|---|---|---|---|---|
| 登录依赖 | 人工登录 | 需已登录的 Chrome 实例 | 只要一段 Cookie | 需申请资质 |
| 单篇耗时 | 3~5 分钟 | 20~40 秒 | 1~2 秒 | 1~2 秒 |
| 前端改版影响 | 无(人肉适配) | DOM 一变就挂 | 协议变才挂 | 无 |
| 前置成本 | 0 | 中(找端口、等渲染) | 高(要破签名) | 无通道 |
| 批量能力 | 无 | 半自动 | 完全自动化 | 完全自动化 |
| 可校验 | 人工看 | 靠截图 | 读接口回包 | 读接口回包 |
结论:选 C。理由:
- A 的问题就是我们要解决的问题本身;
- B 只是把「人点」换成「机器点」,DOM 结构、懒加载、编辑器富文本交互全是新的不稳定源,而且还要维护一个常驻浏览器;
- D 不存在------创作中心没有对外开放的发布 API;
- C 的代价是要复刻前端自己的请求签名,但这部分逻辑本来就在前端包里(浏览器能算,Node 当然也能算),属于一次性成本。
选 C 还有一个隐性收益:前端 javascript 包里写着所有校验规则(标签必填、标题 5~100 字、正文非空),可以照搬到本地,把 400 错误变成发布前的本地提示。
3. 架构设计
3.1 总体架构
text
本地 markdown
│ ① 读文件、摘首行 H1 当标题(H1 从正文剥离)
▼
┌─────────────────────────────┐
│ 本地预检(未通过即退出,不联网)│
│ · 标题 5~100 字 │
│ · 发布必须有标签 │
│ · 敏感信息正则(IP/密钥/个人)│
└─────────────────────────────┘
│ ② 组 payload
│ markdowncontent = 源文
│ content = 渲染后的 HTML(非空才收)
│ status/readType/pubStatus/tags/categories
▼
┌─────────────────────────────┐
│ X-Ca 签名器(HMAC-SHA256) │
│ · X-Ca-Key / X-Ca-Nonce │
│ · X-Ca-Signature-Headers │
└─────────────────────────────┘
│ ③ POST /v3/mdeditor/saveArticle
▼
bizapi.csdn.net(阿里云 API 网关)
│ ④ code=200 + articleId/url
▼
┌─────────────────────────────┐
│ 读回校验 getArticle │
│ 归档 sending → sent │
└─────────────────────────────┘
| 组件 | 载体 | 职责 | 关键约束 |
|---|---|---|---|
| CLI 入口 | Node 18+,单文件 .mjs |
子命令分发、参数解析、JSON 输出 | 零依赖,只用内置 fetch/formData |
| 签名器 | 同文件 signHeaders() |
生成 X-Ca-* 四件套 |
nonce 每次新 UUID,待签串逐字一致 |
| payload 构造器 | buildPayload() |
拼 saveArticle body |
content 非空、status 只传 0/2 |
| md→HTML 渲染器 | mdToHtml() |
把 md 转成合法 HTML 塞 content |
只需覆盖标题/列表/表格/代码块等常用语法 |
| 预检器 | sanitizeHits() + 校验 |
提交前拦截 | 命中即拒,给出命中行号 |
| 查重器 | findExisting() |
按标题找同名文章 | 只扫最近约 60 篇(服务端 pageSize 封顶) |
| 归档器 | archiveFile() |
发布成功后移动文件 | 只在 code=200 之后执行 |
端到端数据流走读(以一篇 10800 字符、7 个二级标题、5 张表、8 个代码块的文章为例):
- 读文件 → 摘标题
【Python】...,正文 10844 字符; - 渲染 HTML,体积从 10.8KB 涨到 13.5KB(表格和代码块里的标签开销);
- 组 payload,JSON 序列化后约 25KB;
- 签名后 POST,接口 1 秒内回
articleId; - 读回确认
categories/tags/markdownChars; - 文件从
sending移到sent。
3.2 签名组件:X-Ca 四件套
创作中心所有请求打到 bizapi.csdn.net,走阿里云 API 网关的 X-Ca 签名。前端包里硬编码了 appKey/appSecret(随包公开,此处不贴值),签名算法是标准的:
text
X-Ca-Key: <appKey>
X-Ca-Nonce: <每次新的 UUID v4>
X-Ca-Signature-Headers: x-ca-key,x-ca-nonce
X-Ca-Signature: base64(HMAC-SHA256(appSecret, stringToSign))
stringToSign 的拼法是整件事的唯一难点,逐行:
| 行 | 内容 | 注意 |
|---|---|---|
| 1 | METHOD |
大写 |
| 2 | Accept |
必须与实际发出的头逐字一致 |
| 3 | 空行(Content-MD5) | 恒为空 |
| 4 | Content-Type |
GET 无正文时为 "",POST 必须与发出的头一致 |
| 5 | Date |
没发就是 "" |
| 6 | 参与签名的 x-ca-* 头 |
每行 name:value,name 小写、按 name 升序,排除 signature 与 signature-headers |
| 7 | path |
去掉 scheme+host,如 /blog-console-api/v1/user/info |
| 8 | ?query |
按 key 升序,值不做 URL 编码 |
验证方式很朴素,但有说服力:抓 4 条真实请求,用同一套代码重算签名,逐条对比。4 条全中即可认为算法复刻成功,后续不用再猜。
3.3 发布组件:saveArticle 的字段语义
POST /blog-console-api/v3/mdeditor/saveArticle 的 body 里,真正决定结果的只有这几个:
| 字段 | 取值 | 语义 |
|---|---|---|
id |
空 / 旧 id | 新建 vs 覆盖 |
markdowncontent |
Markdown 源文 | 线上渲染以它为准 |
content |
HTML | 非空才收,空正文直接 400 |
status |
0 / 2 |
0=发布、2=草稿 |
pubStatus |
publish / draft |
与 status 同步 |
readType |
public/private/fans/vip |
可见性 |
tags |
逗号分隔 | 公开发布必填 |
categories |
专栏名 | 填错静默不归类 |
有一个反直觉的结论值得单独说:「仅我可见」不是靠 status 传出来的 。编辑器的真实做法是 status=0 + readType=private,由服务端自己把结果状态算成 64;如果你直接把 64 当入参传,接口回 200「成功」,但什么都没建(见坑 1)。
3.4 预检组件:把服务端的 400 搬回本地
按前端 js 里的校验逻辑逐条复刻,提交前跑:
| 检查 | 规则 | 命中后果 |
|---|---|---|
| 标题长度 | 5~100 字(服务端还会把连续空白压成一个空格) | 本地拒绝 |
| 正文非空 | 去掉换行后不能为空 | 本地拒绝 |
| 标签 | 发布必填,草稿可省 | 本地拒绝,提示 --tags |
| 敏感信息 | 公司代号、个人标识、各类 IP、明文密钥 | 本地拒绝并给出行号 |
脱敏这一层是给公开平台加的护栏:文章里混进内网 IP 或者明文 token,发出去就是事故,所以宁可误杀(提供显式跳过开关),不可放过。
3.5 归档组件:稿库两态
稿库约定成两个目录:csdn_sending/(待发)、csdn_sent/(已发)。发布成功后移动文件,设计上做了几个取舍:
| 决策 | 理由 |
|---|---|
只在 code=200 之后移动 |
失败还把稿子搬走,等于人财两空 |
| 公开才默认归档,草稿/私密留在原地 | 私密发布是「先看看渲染效果」的验证态,稿子还会再发一次 |
| 同名直接覆盖 | 同一篇稿子重发,新版本就是要覆盖旧的 |
rename 失败退化为复制+删源 |
跨盘移动会抛 EXDEV,不能让归档失败牵连发布结果 |
| 源文件已在归档目录就跳过 | 更新已发文章时不产生多余的移动动作 |
3.6 可运维性
- 输出全是 JSON ,
ok字段一眼看成败,方便被别的脚本消费; - 退出码分层:0 成功、1 一般失败、2 用法错误、3 认证失败,shell 里能直接判;
--dry-run:只做本地校验 + 组包,打印标题、标签、专栏、payload 体积、归档目标路径,不发请求;--debug:打印接口原始回包,排查「回 200 但没生效」时全靠它;- 幂等:同标题自动转覆盖,重跑不会造成重复文章。
4. 关键实现
4.1 签名代码
javascript
function signHeaders(method, urlStr, extraHeaders = {}, body = "") {
const u = new URL(urlStr);
const headers = {
Accept: "application/json",
...(body ? { "Content-Type": "application/json;charset=UTF-8" } : {}),
...extraHeaders,
};
const nonce = randomUUID();
headers["X-Ca-Key"] = BLOG_APP_KEY;
headers["X-Ca-Nonce"] = nonce;
headers["X-Ca-Signature-Headers"] = "x-ca-key,x-ca-nonce";
// query 按 key 升序,值不编码
const q = [...u.searchParams.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`)
.join("&");
const lines = [
method.toUpperCase(),
headers.Accept || "",
"", // Content-MD5 恒空
headers["Content-Type"] || "",
headers.Date || "", // 没发 Date 就是空
`x-ca-key:${headers["X-Ca-Key"]}`,
`x-ca-nonce:${nonce}`,
u.pathname, // 不含 host
];
const stringToSign = lines.slice(0, 7).join("\n") + "\n" + u.pathname + (q ? `?${q}` : "");
headers["X-Ca-Signature"] = createHmac("sha256", BLOG_APP_SECRET)
.update(stringToSign, "utf8")
.digest("base64");
return headers;
}
4.2 组包与发布
javascript
const payload = {
id: existingId || "",
title,
markdowncontent: markdown,
content: mdToHtml(markdown), // 非空必需
readType: "public",
level: 0,
tags: "Python,自动化",
status: 0, // 只传 0/2,别传 64
pubStatus: "publish",
categories: "AI办公", // 专栏名,不是 id
type: "original",
not_auto_saved: "1",
source: "pc_mdeditor",
is_new: 1,
creation_statement: 0,
};
4.3 同标题幂等
javascript
async function findExisting(cookie, title) {
for (let page = 1; page <= 3; page++) {
const r = await apiArticleList(cookie, { status: "all_v3", page, size: 20 });
const hit = (r.data.list || []).find((a) => a.title === title);
if (hit) return hit;
}
return null;
}
服务端 pageSize 封顶 20,扫 3 页就是最近 60 篇,覆盖绝大多数重发场景;更老的文章走显式 update <id>。
4.4 两步删除
javascript
await apiDeleteArticle(cookie, id, false); // 先进回收站
await apiDeleteArticle(cookie, id, true); // 再从回收站彻底删除
直接 deep=true 会被服务端拒绝(见坑 5)。
5. 踩坑记录(共 10 个)
坑 1:status=64 静默失败,回 200 但文章不存在
现象 :新建一篇「仅我可见」文章,传 status=64, readType=private,接口回 {"code":200,"data":"成功"};但文章列表、草稿箱、私密计数全部没变,文章 id 也是空串。
原因 :64 是服务端给「仅我可见」文章落的结果 状态,不是可入参。新建时它被当成非法动作静默吞掉------不报错、不创建,只回一句「成功」。编辑器真实做法是 status=0 + readType=private,服务端自己算成 64。
解决:
javascript
const status = isDraft ? 2 : 0; // 只允许 0 或 2
const readType = isPrivate ? "private" : "public";
预防 :任何「接口回 200 但列表没变化」都要当失败处理。发布流程里强制读回:getArticle 拿到 status/标题/正文长度,比对不上就报错,永远不看 HTTP 状态码下结论。
坑 2:发布被 400「请设置文章标签」
现象 :status=0 发布返回 {"code":400,"msg":"请设置文章标签"},草稿同样的 body 却能成功。
原因 :账号维度开了「标签必填」开关(前端 label_required_switch),公开文章强制标签,草稿不受限。
解决 :发布子命令要求 --tags "标签1,标签2",3~5 个、逗号分隔不带空格;草稿可省略。
预防:把这类规则做成本地预检,而不是等接口报错------多一次网络往返、错误信息还是服务端的模糊措辞。
坑 3:连续发几篇被限流
现象 :同一段时间内连续第 5 次左右写入,回 {"code":400,"msg":"文章频繁发布,请稍后再试"}。
原因:账号维度的发布频率限制,草稿和发布都算一次写操作。
解决:等 5~10 分钟再发(实测约 10 分钟后恢复);批量发多篇时每篇之间留间隔。
预防 :先 --dry-run 把校验一次性跑通,再真发,避免「发了又改、改了再发」反复触发限流。
坑 4:getArticle 的参数名必须是 id
现象 :GET /v1/editor/getArticle?articleId=123 回 400 参数错误。
原因 :这个端点的查询参数就叫 id,不是 articleId(同一个 API 家族里 article/list 用 articleId,命名并不统一)。
解决:
javascript
api({ url: `${BLOG_API}/v1/editor/getArticle?id=${id}`, cookie });
预防:逆向出来的端点先在 curl/脚本里逐个打一遍并记录参数名;别凭「看起来该叫什么」去猜。
坑 5:deep=true 直接删被拒,必须走两步
现象 :POST /v1/article/del 带 {articleId, deep:true} 回 {"code":400,"message":"该文章不可以深度删除"}。
原因 :深度删除只对回收站里的文章生效,文章必须先经历一次普通删除。
解决 :两次调用------先 deep=false 进回收站,再 deep=true 彻底删。
预防 :清理测试稿写成一个固定流程,别图省事只调一次;另外注意回收站里有个 clear 类接口,会把整个回收站清空,不适合用来删单篇。
坑 6:自己拼签名总是「签名错误」
现象:手写的签名请求稳定 403/签名校验失败,但浏览器里同样的请求是 200。
原因 :三个细节里任何一个错都会挂------① query 必须按 key 升序(浏览器地址栏里的顺序不算数);② Content-Type/Accept 必须与实际发出的头逐字 一致(多一个空格都不行);③ x-ca-nonce 每次必须新 UUID,复用会被判重放。
解决:
javascript
const q = [...u.searchParams.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`).join("&");
预防:用「抓到的真实请求」做回归验证------重算签名并比对历史值,4 条不同形状的请求(GET 无 query、GET 带 query、POST 带 JSON、带自定义头)全中才继续。
坑 7:标题里的 # 被一起提交,线上真的存在
现象 :线上某篇文章的标题字面是 # 【语音识别】内网环境......,正文开头同样带着 #。
原因 :直接把文件第一行 # 标题 当标题串提交,没剥 Markdown 前缀;服务端不会替你去掉。
解决 :从首个 # H1 取标题,并从正文里剥掉这一行:
javascript
const lines = md.replace(/\r\n?/g, "\n").split("\n");
const idx = lines.findIndex((l) => /^#\s+(.+?)\s*$/.test(l));
const title = /^#\s+(.+?)\s*$/.exec(lines[idx])[1].trim();
const body = lines.filter((_, i) => i !== idx).join("\n").trim();
预防 :生成侧统一以 # 标题 开头,发布侧永远由脚本摘标题,禁止手写 --title "# ..."。
坑 8:图片上传是另一套 appKey
现象:用博客接口的 appKey/appSecret 去调图片上传,404 或签名失败。
原因 :图片走 resource-api,用的是另一个 appKey/appSecret,而且要先把文件 POST 到对象存储、再靠回调拿 CDN 地址。
解决 :单独封装 upload-image 子命令,先取 image/direct/upload/signature 的直传策略,再 multipart 上传,最后取回调里的 URL(拿不到就拼 CDN 域名兜底)。
预防:同一域名下不同微前端可能用不同 appKey,逆向时按「模块 → appKey」建表,别假设全局只有一个。
坑 9:发布成功但线上页 404,40 秒后才正常
现象 :发布接口回 200 带 articleId,立刻 GET 文章页得到 404;getArticle 读回 status=16。约 40 秒后再查,status=1、页面 200。
原因 :新文章要过一道审核/内容处理流水线,处理期间 status=16,详情页路由还没注册。
解决:发布后的校验留缓冲,别在 1 秒内就下结论:
javascript
await sleep(40_000);
const g = await apiGetArticle(cookie, id); // 期望 status=1、readType=public
预防:把「接口 200」和「线上可见」当成两件事分别校验;流水线状态(16)不等于失败,只有超时(比如 5 分钟)后才算异常。
坑 10:专栏写错不报错,只是不归类
现象 :categories 传了一个拼写有误的专栏名,接口照样 200,文章发出来但没进任何专栏。
原因 :专栏是按名称匹配的(不是 id),找不到就静默跳过,不校验、不报错。
解决:先查账号的专栏列表,确认名字存在再发:
javascript
// 注意:这不是 category/get-list(那个回的是官方技术分类树)
const r = await api({ url: `${PHOENIX_API}/v1/article/getQueryCriteriaNew`, cookie });
const columns = r.data.column; // [{id, title}],categories 填这里的 title
预防:专栏固定成一个常量(比如团队约定的「AI办公」),并在发布前用列表接口确认它还存在------专栏被改名/删除后,这一步能立刻发现。
6. 总结
6.1 成果
| 指标 | 结果 |
|---|---|
| 发布耗时 | 单篇 1~2 秒(对比手工 3~5 分钟) |
| 依赖 | 零第三方包,Node 18+ 内置能力即可 |
| 签名复刻验证 | 4/4 条真实请求签名逐字一致 |
| 覆盖接口 | 登录态、列表、读回、发布/存草稿、删除、图片直传、专栏列表 |
| 失败可观测 | 全部 JSON 输出 + 分层退出码 + 命中行号 |
6.2 经验
- 前端包是最好的协议文档:签名算法、校验规则、字段默认值全在里面,比任何二手资料都准;
- 「回 200」不等于成功:静默失败(坑 1、坑 10)是最贵的坑,必须读回校验;
- 把服务端校验搬回本地:多花十分钟复刻规则,能省掉无数次「发出去被拒、再改再发」的往返;
- 状态机要按结果态建模 :
64、16都是服务端算出来的中间/结果状态,入参只该用文档化的那几个; - 自动化要带护栏:脱敏预检、幂等、归档,三者缺一个就会在几十篇的规模上开始还债。
6.3 后续计划
- 支持从 Obsidian/Notion 导出的双链语法降级;
- 图片本地路径自动上传替换,目前仍需先
upload-image再改 md; - 多账号(个人号/团队号)配置隔离,Cookie 与专栏分别绑定。
参考
- CSDN 创作中心:<https://mp.csdn.net/\>
- 阿里云 API 网关签名机制(X-Ca 系列请求头)
- Node.js
crypto.createHmac文档:<https://nodejs.org/api/crypto.html\>