【Python】不点浏览器发博客:逆向 CSDN 创作中心接口做 md-publish 技能

每篇稿子都要开浏览器、粘 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 个代码块的文章为例):

  1. 读文件 → 摘标题 【Python】...,正文 10844 字符;
  2. 渲染 HTML,体积从 10.8KB 涨到 13.5KB(表格和代码块里的标签开销);
  3. 组 payload,JSON 序列化后约 25KB;
  4. 签名后 POST,接口 1 秒内回 articleId;
  5. 读回确认 categories/tags/markdownChars;
  6. 文件从 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 经验

  1. 前端包是最好的协议文档:签名算法、校验规则、字段默认值全在里面,比任何二手资料都准;
  2. 「回 200」不等于成功:静默失败(坑 1、坑 10)是最贵的坑,必须读回校验;
  3. 把服务端校验搬回本地:多花十分钟复刻规则,能省掉无数次「发出去被拒、再改再发」的往返;
  4. 状态机要按结果态建模 :64、16 都是服务端算出来的中间/结果状态,入参只该用文档化的那几个;
  5. 自动化要带护栏:脱敏预检、幂等、归档,三者缺一个就会在几十篇的规模上开始还债。

6.3 后续计划

  • 支持从 Obsidian/Notion 导出的双链语法降级;
  • 图片本地路径自动上传替换,目前仍需先 upload-image 再改 md;
  • 多账号(个人号/团队号)配置隔离,Cookie 与专栏分别绑定。

参考

相关推荐
lisw0535 分钟前
智能化机器人系统的模块化设计自动化!
机器学习·机器人·自动化
Tt有了t1 小时前
设计模式速查
java·python·设计模式
deepseek231 小时前
Python日志拖慢请求?QueueHandler排队、丢失与退出实测
python·性能优化·logging·后端开发·queuehandler
用户91294046848421 小时前
Codex 从零到能交付(05):第一次改代码:生成一个文件清单脚本
python
Dmz丶1 小时前
前端转 AI 100 天 Day 38:Token 管理与成本优化——别让 API 账单爆炸
python
用户298698530142 小时前
Python 自动化处理 Word 页眉页脚的几种典型场景
后端·python·api
終不似少年遊*3 小时前
【安全业务Agent】从场景到编排
python·react.js·ai·langchain·agent·安全架构·工作流
AKA__Zas3 小时前
接口自动化测试(1.0 初认识
python·ci/cd·学习方法
用户0332126663673 小时前
使用 Python 在 Excel 文档中添加水印
python·excel