环境: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 就好。