文章背景图

用脚本给 Halo 发文章:一次完整的 API 踩坑记录

2026-11-01
0
- 字
- 分钟
|

环境:Halo 2.27.1(Halo Pro)+ MySQL 8.4,站点跑在 8090 端口。本文记录的是从零用脚本发布一篇文章的完整过程,以及中间踩到的几个坑。

为什么要用 API 发文章

手动点后台当然可以,但一旦有了这些场景,脚本就更省心:把本地 Markdown 目录批量迁移进 Halo、在 CI 里根据 commit 自动发周报、或者让一个定时任务每天汇总监控数据发一篇日报。这些都不适合用人手点。

Halo 的控制台接口(api.console.halo.run)是完整的 REST API,只是文档里没强调几个细节。

第一步:登录不是简单的表单提交

直觉做法是 POST /login 带上 username 和 password:

curl -X POST /login \
  -d "username=admin" -d "password=your-password"

结果是一个 302 跳到 /login?error=invalid-credential。密码明明是错的?不是。

原因在登录页的源码里:Halo 2.23 之后,登录页会内嵌一把 4096 位的 RSA 公钥,前端用 jsencrypt 把密码加密成 Base64 再提交。

<script src="/webjars/jsencrypt/bin/jsencrypt.min.js" defer></script>
<script>
  const publicKey = "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA...";
  function encryptPassword(password) {
    const encrypt = new JSEncrypt();
    encrypt.setPublicKey(publicKey);
    return encrypt.encrypt(password);
  }
</script>

所以脚本要先做同样的事。Node 里一行 publicEncrypt 就够:

const crypto = require('crypto');

const html = await (await fetch('/login')).text();
const der = html.match(/const publicKey = "([^"]+)"/)[1].replace(/\\\//g, '/');
const pem = `-----BEGIN PUBLIC KEY-----\n${der.match(/.{1,64}/g).join('\n')}\n-----END PUBLIC KEY-----\n`;

const encrypted = crypto.publicEncrypt(
  { key: pem, padding: crypto.constants.RSA_PKCS1_PADDING },
  Buffer.from(password, 'utf8')
).toString('base64');

第二步:CSRF 与会话

Halo 用 Spring Security 的会话 + CSRF 校验,两步都不能省:

# 1. 取登录页,同时保存会话 cookie 与 _csrf
curl -s -c jar.txt /login -o login.html
CSRF=$(grep -oP 'name="_csrf" value="\K[^"]+' login.html | head -1)

# 2. 用加密后的密码登录
curl -s -b jar.txt -c jar.txt -H "X-CSRF-TOKEN: $CSRF" \
  --data-urlencode "_csrf=$CSRF" \
  --data-urlencode "username=admin" \
  --data-urlencode "password=$ENCRYPTED" \
  /login

成功的标志是 302 跳到 /uc,并且 cookie jar 里出现 SESSION。之后所有请求带上这个 cookie 即可,不用再管 CSRF——GET 请求本身不校验它。

第三步:创建文章

文章走控制台聚合接口,而不是直接写 content.halo.run 的资源,因为控制台接口会顺带帮你生成快照(snapshot):

POST /apis/api.console.halo.run/v1alpha1/posts
Content-Type: application/json

请求体由两部分组成,post 是资源本体,content 是正文:

{
  "post": {
    "apiVersion": "content.halo.run/v1alpha1",
    "kind": "Post",
    "metadata": {
      "name": "",
      "generateName": "post-",
      "labels": { "halo.run/slug": "my-slug" }
    },
    "spec": {
      "title": "文章标题",
      "slug": "my-slug",
      "publish": true,
      "visible": "PUBLIC",
      "allowComment": true,
      "excerpt": { "autoGenerate": true, "raw": "" },
      "categories": ["<分类的 metadata.name>"],
      "tags": [],
      "htmlMetas": [],
      "owner": "admin"
    }
  },
  "content": {
    "raw": "# Markdown 正文\n\n从这里开始写。",
    "content": "<h1>Markdown 正文</h1><p>从这里开始写。</p>",
    "rawType": "markdown"
  }
}

几个容易忽略的字段:

  • metadata.name 必须留空,由 generateName 生成;自己填了会和后端的命名规则冲突。
  • spec.categories 要填分类的 metadata.name(一串 UUID),不是 slug。
  • content.rawType 固定 markdown;如果想直接传 HTML,改成 html 并把渲染结果放进 content.content。

服务端不会帮你渲染 Markdown

这是最容易掉进去的坑:接口不会把 content.raw 渲染成 HTML。raw 只是原文留档,真正展示的是 content.content 里的 HTML。如果这里留空,文章会正常发布、状态是 PUBLISHED,但打开页面只有标题,正文一片空白——连报错都没有。

所以脚本里要自带一个渲染器,比如 marked:

import { marked } from 'marked';

const raw = fs.readFileSync('article.md', 'utf8');
const html = marked.parse(raw, { gfm: true });

渲染完把两者一起提交,原文和 HTML 各归各位。回头在后台编辑器里修改时,Halo 会用 raw 重新生成 HTML,所以原文一定要是完整的。

第四步:发布

上一步即使写了 "publish": true,返回的 status.phase 仍然是 DRAFT。真正上线要再调一次:

PUT /apis/api.console.halo.run/v1alpha1/posts/{name}/publish
Content-Type: application/json

{}

空对象就行。成功的响应里会带上最终地址:

{
  "status": {
    "phase": "PUBLISHED",
    "permalink": "/archives/my-slug"
  }
}

踩坑清单

  • 明文密码会得到 invalid-credential,看起来像密码错,其实是少了 RSA 加密这一步。
  • 连续试错会触发限流,错误码变成 rate-limit-exceeded,等几分钟再试即可。
  • 创建成功不等于发布成功,一定要检查 phase 是否为 PUBLISHED。
  • content.content 为空会得到一篇只有标题的正文,且不会报错,最容易漏。
  • 分类填 UUID 不填 slug,填错会直接 400。
  • 别信自己拼的链接,用响应里的 status.permalink。

小结

整套流程只需要三次请求加一次加密:登录、建稿、发布。把这几个步骤封成一个函数,就能把 Halo 接进任何内容流水线里——剩下的工作,交给 cron 或者 CI 就好。

原创

用脚本给 Halo 发文章:一次完整的 API 踩坑记录

本文链接: 用脚本给 Halo 发文章:一次完整的 API 踩坑记录

本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

评论交流

文章目录