在 Halo 后台手工发一篇文章很简单,自动化却容易出现一种危险的“假成功”:接口返回 200、后台标题也变了,前台仍在展示旧正文。根因通常不是网络,而是 Halo 2.x 把文章资源、可编辑内容快照和已发布快照分开管理。本文给出一条经过实际调用验证的流程:使用 Console API 写入 PostRequest,取得新的 headSnapshot,显式调用 publish,再同时核对 release-content 与前台页面。
示例适用于 Halo 2.x 的
api.console.halo.run/v1alpha1接口形态。不同 2.x 小版本、插件和权限配置可能有差异,正式脚本应先在测试文章上验证。文中域名、用户名、Cookie、文件名、策略名和文章名均为占位符,不包含真实凭据或内部路径。
一、先理解三个对象
1. Post 资源
文章元数据是 content.halo.run/v1alpha1 的 Post,包含 metadata.name、spec.title、spec.slug、spec.owner、可见性、分类标签以及快照引用等。metadata.name 是 API 主键,不等于标题或 slug;更新、发布都应使用它。
2. PostRequest
Console 编辑接口创建和更新文章时,请求体不是单独的 Post,也不是只传 HTML,而是:
{
"post": { "apiVersion": "content.halo.run/v1alpha1", "kind": "Post" },
"content": {
"content": "<p>渲染后的 HTML</p>",
"raw": "<p>编辑器源内容</p>",
"rawType": "HTML"
}
}
即 PostRequest { post, content }。本轮验证使用 HTML,因此 content.content 与 content.raw 都写完整 HTML,rawType 固定为 HTML。只改 post.spec 而不传正确的 content,不能保证正文快照更新;只传 Markdown 却声明 HTML,也会造成编辑器与前台内容不一致。
官方数据结构中的 content.version 可选,用于表达预期的 head snapshot 版本;冲突时可能产生新的 head snapshot。简单的单写入脚本可不传,但并发编辑系统应先读取 head content/version,再做冲突处理,不能用“最后写入覆盖一切”的心态。
3. Snapshot
headSnapshot 是当前编辑头,releaseSnapshot 是当前发布头。更新正文会生成或切换 head,但前台读取的是 release。于是“更新接口成功”不等于“读者已看到新内容”。发布时必须传本次更新后的 head snapshot,并等待发布完成。
二、认证:正确处理 #HttpOnly_ Cookie
Console API 需要有相应文章和附件权限的已登录会话。不要把账号、密码或 Cookie 写进源码、Git 仓库和日志;示例从环境变量或 Netscape cookie jar 读取。生产自动化更应使用权限最小、可撤销的凭据,并按站点支持方式处理认证。
curl -c cookies.txt 常见的 Netscape 格式会把 HttpOnly 行写成:
#HttpOnly_example.com TRUE / TRUE ... SESSION <REDACTED>
它看起来以 # 开头,但不是普通注释。若脚本用 line.startsWith('#') 全部过滤,会误删会话 Cookie,随后得到 302 登录跳转或 401/403。解析时应保留 #HttpOnly_,去掉该前缀后再读取第 6、7 列:
function cookieHeader(jarText) {
return jarText.split(/\r?\n/)
.filter(line => line && (!line.startsWith('#') || line.startsWith('#HttpOnly_')))
.map(line => {
const cols = line.replace(/^#HttpOnly_/, '').split('\t');
if (cols.length < 7) throw new Error('Cookie jar 格式不完整');
return `${cols[5]}=${cols[6]}`;
})
.join('; ');
}
Cookie 文件应设为仅当前用户可读,脚本不得打印完整请求头。若站点要求 CSRF Token 或 API 认证方式不同,应复用浏览器实际请求中站点要求的机制,不要通过关闭安全校验来“解决”。
三、创建文章:先建草稿,再发布
创建端点为:
POST /apis/api.console.halo.run/v1alpha1/posts
推荐在 Halo Console 新建一篇测试草稿,用开发者工具观察当前版本的请求结构,或读取一个同类型现有 Post 作为模板,然后只保留可写字段。一个示意请求如下,字段仍须结合目标版本核对:
{
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {
"generateName": "post-"
},
"spec": {
"title": "<文章标题>",
"slug": "<article-slug>",
"owner": "<OWNER_METADATA_NAME>",
"template": "",
"cover": "",
"deleted": false,
"publish": false,
"pinned": false,
"allowComment": true,
"visible": "PUBLIC",
"priority": 0,
"excerpt": {
"autoGenerate": true,
"raw": ""
},
"categories": [],
"tags": []
}
},
"content": {
"content": "<p data-acceptance=\"create-v1\">正文</p>",
"raw": "<p data-acceptance=\"create-v1\">正文</p>",
"rawType": "HTML"
}
}
owner 应是 Halo 用户资源的 metadata.name,不是随便填写的昵称。最可靠做法是从当前已登录用户或已有文章中取得合法 owner;不要把另一个环境的 owner 硬编码后搬过来。创建响应中的 metadata.name 必须保存,它是后续 PUT、publish 和验收的依据。
创建时保持 publish:false 更安全:先校验内容与附件,再显式发布。分类和标签也应传资源名称而不是显示名;不确定时先留空,待通过对应 API 查询后再填。
四、更新文章:以服务端资源为底稿
更新端点为:
PUT /apis/api.console.halo.run/v1alpha1/posts/{metadata.name}
先读取最新 Post:
GET /apis/content.halo.run/v1alpha1/posts/{metadata.name}
然后基于返回对象更新 spec.title、spec.slug、spec.cover、spec.publish、spec.visible 等目标字段,正文仍放在同一个 PostRequest 的 content 中。不要从几天前保存的完整 JSON 直接 PUT,否则可能覆盖后台新改的分类、标签、owner 或快照引用。
服务端返回的 status 是只读状态,更新前应删除;metadata.resourceVersion 等并发控制字段则不要臆测,按目标版本的接口行为保留或处理。一个实用模式是“读取—浅拷贝—只改目标字段—删除 status”:
const fresh = await api(`/apis/content.halo.run/v1alpha1/posts/${name}`);
const post = structuredClone(fresh);
delete post.status;
post.spec = {
...post.spec,
title: '<新标题>',
slug: '<new-slug>',
visible: 'PUBLIC',
publish: true
};
const html = '<p data-acceptance="update-v2">新版正文</p>';
const updated = await api(
`/apis/api.console.halo.run/v1alpha1/posts/${name}`,
{ method: 'PUT', json: { post, content: { content: html, raw: html, rawType: 'HTML' } } }
);
Snapshot 陷阱
最常见错误是从更新前的 fresh.spec.headSnapshot 取值并发布。更新正文后 head snapshot 可能已经变化,发布旧值只会再次释放旧内容。必须优先从更新响应读取新值;不同响应包装形态下可兼容:
let head = updated?.spec?.headSnapshot ?? updated?.post?.spec?.headSnapshot;
if (!head) {
const again = await api(`/apis/content.halo.run/v1alpha1/posts/${name}`);
head = again.spec?.headSnapshot;
}
if (!head) throw new Error('更新后仍未取得 headSnapshot,停止发布');
不要猜快照名,也不要把某篇文章的 snapshot 复用到另一篇文章。
五、显式发布并等待
发布端点为:
PUT /apis/api.console.halo.run/v1alpha1/posts/{name}/publish?headSnapshot={head}&async=false
代码中务必 URL 编码:
await api(
`/apis/api.console.halo.run/v1alpha1/posts/${name}/publish` +
`?headSnapshot=${encodeURIComponent(head)}&async=false`,
{ method: 'PUT' }
);
async=false 让请求等待 release snapshot 可用,适合需要立即验收的自动化;它仍不应被理解成“CDN 和所有浏览器缓存都已更新”。发布返回后继续做 API 与前台两层检查。
六、附件上传与正文引用
本地文件上传使用 multipart:
POST /apis/api.console.halo.run/v1alpha1/attachments/upload
表单字段是 file、必填的 policyName,以及可选的 groupName:
curl --fail-with-body \
-H "Cookie: <SESSION_COOKIE>" \
-F "file=@./cover.webp" \
-F "policyName=<STORAGE_POLICY_METADATA_NAME>" \
-F "groupName=<OPTIONAL_GROUP_METADATA_NAME>" \
"https://halo.example.com/apis/api.console.halo.run/v1alpha1/attachments/upload"
也可从远程 URL 转存:
POST /apis/api.console.halo.run/v1alpha1/attachments/-/upload-from-url
请求体包含 url、policyName,可选 groupName 与 filename。上传成功后,应从响应的附件资源中读取实际可访问地址,不能假设 URL 等于原文件名;再把该 URL 放入文章 cover 或 HTML:
<figure data-content-type="image">
<img src="<ATTACHMENT_PUBLIC_URL>" alt="有意义的替代文本">
</figure>
严格验收附件时,单独请求该 URL,确认状态 200、Content-Type 正确且响应体非空。若存储策略生成临时签名 URL,还要按策略语义处理,不能把短期 URL 永久写进正文。
七、可复用 Node.js 脚本
下面脚本基于 Node.js 18+ 原生 fetch,完成更新或创建、发布、release-content 验收,并检查前台。它不负责登录,使用外部 cookie jar;所有敏感值通过环境变量传入。
// halo-publish.mjs
import fs from 'node:fs';
const BASE = must('HALO_BASE').replace(/\/$/, '');
const COOKIE_JAR = must('HALO_COOKIE_JAR');
const HTML_FILE = must('HALO_HTML_FILE');
const TITLE = must('HALO_TITLE');
const SLUG = must('HALO_SLUG');
const OWNER = process.env.HALO_OWNER;
const POST_NAME = process.env.HALO_POST_NAME || '';
const MARKER = process.env.HALO_MARKER || `accept-${Date.now()}`;
function must(key) {
const value = process.env[key];
if (!value) throw new Error(`缺少环境变量 ${key}`);
return value;
}
function cookieHeader(text) {
return text.split(/\r?\n/)
.filter(x => x && (!x.startsWith('#') || x.startsWith('#HttpOnly_')))
.map(x => {
const a = x.replace(/^#HttpOnly_/, '').split('\t');
if (a.length < 7) throw new Error('非法 cookie jar 行');
return `${a[5]}=${a[6]}`;
}).join('; ');
}
const COOKIE = cookieHeader(fs.readFileSync(COOKIE_JAR, 'utf8'));
async function api(path, { method = 'GET', json } = {}) {
const response = await fetch(BASE + path, {
method,
redirect: 'manual',
headers: {
Accept: 'application/json',
Cookie: COOKIE,
...(json ? { 'Content-Type': 'application/json' } : {})
},
body: json ? JSON.stringify(json) : undefined
});
const text = await response.text();
if (response.status >= 300 && response.status < 400)
throw new Error(`${method} ${path}: 被重定向,Cookie 可能失效`);
if (!response.ok)
throw new Error(`${method} ${path}: ${response.status} ${text.slice(0, 500)}`);
return text ? JSON.parse(text) : null;
}
let html = fs.readFileSync(HTML_FILE, 'utf8');
html = `<!-- ${MARKER} -->\n${html}`;
const content = { content: html, raw: html, rawType: 'HTML' };
let name = POST_NAME;
let result;
if (name) {
const current = await api(`/apis/content.halo.run/v1alpha1/posts/${encodeURIComponent(name)}`);
const post = structuredClone(current);
delete post.status;
post.spec = { ...post.spec, title: TITLE, slug: SLUG, publish: true, visible: 'PUBLIC' };
result = await api(`/apis/api.console.halo.run/v1alpha1/posts/${encodeURIComponent(name)}`, {
method: 'PUT', json: { post, content }
});
} else {
if (!OWNER) throw new Error('创建文章时必须提供 HALO_OWNER');
const post = {
apiVersion: 'content.halo.run/v1alpha1', kind: 'Post',
metadata: { generateName: 'post-' },
spec: {
title: TITLE, slug: SLUG, owner: OWNER, template: '', cover: '',
deleted: false, publish: false, pinned: false, allowComment: true,
visible: 'PUBLIC', priority: 0,
excerpt: { autoGenerate: true, raw: '' }, categories: [], tags: []
}
};
result = await api('/apis/api.console.halo.run/v1alpha1/posts', {
method: 'POST', json: { post, content }
});
name = result.metadata?.name ?? result.post?.metadata?.name;
if (!name) throw new Error('创建成功响应中没有 metadata.name');
}
let head = result?.spec?.headSnapshot ?? result?.post?.spec?.headSnapshot;
if (!head) {
const latest = await api(`/apis/content.halo.run/v1alpha1/posts/${encodeURIComponent(name)}`);
head = latest.spec?.headSnapshot;
}
if (!head) throw new Error('没有 headSnapshot,拒绝继续');
await api(
`/apis/api.console.halo.run/v1alpha1/posts/${encodeURIComponent(name)}/publish` +
`?headSnapshot=${encodeURIComponent(head)}&async=false`,
{ method: 'PUT' }
);
const released = await api(
`/apis/api.console.halo.run/v1alpha1/posts/${encodeURIComponent(name)}/release-content`
);
const releasedHtml = released.content ?? '';
if (!releasedHtml.includes(MARKER))
throw new Error('release-content 未出现本次唯一标记');
const latest = await api(`/apis/content.halo.run/v1alpha1/posts/${encodeURIComponent(name)}`);
const permalink = latest.status?.permalink;
if (!permalink) throw new Error('文章没有 status.permalink');
const front = await fetch(new URL(permalink, BASE), { redirect: 'follow' });
const frontHtml = await front.text();
if (!front.ok || !frontHtml.includes(MARKER))
throw new Error(`前台验收失败:HTTP ${front.status} 或标记缺失`);
console.log(JSON.stringify({ name, headSnapshot: head, permalink, marker: MARKER }, null, 2));
运行示例:
HALO_BASE='https://halo.example.com' \
HALO_COOKIE_JAR='./cookies.txt' \
HALO_HTML_FILE='./article.html' \
HALO_TITLE='<文章标题>' \
HALO_SLUG='<article-slug>' \
HALO_POST_NAME='<EXISTING_POST_METADATA_NAME>' \
HALO_MARKER='release-check-v3' \
node halo-publish.mjs
创建时省略 HALO_POST_NAME,并提供 HALO_OWNER='<OWNER_METADATA_NAME>'。实际部署可在此基础上加入附件上传、分类标签查询、重试和结构化日志,但不要对创建/发布请求盲目重试:网络超时后请求可能已成功,重试前应先按 metadata.name 或唯一 slug 查询状态,避免重复文章。
八、严格验收:不要只看 HTTP 200
一篇文章至少通过以下检查才算成功:
- 资源层:重新 GET Post,确认
metadata.name、标题、slug、owner、spec.publish与可见性符合预期;不要仅相信本地请求体。 - 快照层:更新后取得新的
headSnapshot;发布后确认状态已生成 release 引用,而不是沿用旧快照。 - 内容层:调用
GET .../posts/{name}/release-content,检查本次唯一 marker、关键标题、图片 URL 和末段文字。只检查长度不够,旧正文也可能同样长。 - 前台层:请求
status.permalink指向的公开页面,跟随重定向后确认 200,并搜索唯一 marker 或关键句。若主题会删除 HTML 注释,就使用不影响阅读的data-*属性或版本文本作为标记。 - 附件层:逐一请求封面和正文图片,确认 200、正确 MIME、非零长度;必要时校验哈希或尺寸。
- 匿名层:前台验收不要携带 Console Cookie,避免把“登录用户能预览草稿”误判为“公众已可见”。
- 缓存层:若 API release-content 已更新而前台仍旧,检查 Halo 页面缓存、主题缓存、Nginx/CDN 和浏览器缓存。用唯一 marker 判断,不要靠肉眼猜。
九、批量发布与可恢复设计
当脚本从一篇扩展到几十篇时,不要直接用 Promise.all 同时轰击接口。附件上传、快照写入和发布都可能占用存储及后台任务资源;应限制并发,并为每篇文章保存非敏感的执行记录:文章 metadata.name、目标 slug、本地内容哈希、返回的 head snapshot、发布时间和验收结果。日志只保留 Cookie 是否存在、响应状态和短错误摘要,绝不输出 Cookie、密码或完整认证头。
幂等性需要单独设计。更新可以通过固定 metadata.name 定位;创建则应在动作前按计划表确认是否已经取得 name。若创建请求超时,先查询是否已有目标文章,而不是立即再 POST。slug 只能作为辅助键,因为站点规则或人工操作可能修改它。内容可计算 SHA-256,并把哈希与唯一验收标记保存在外部发布清单;下次运行若 release-content 已含同一标记且哈希符合预期,就可跳过写入,减少无意义快照。
错误也要分级:认证失败和请求体校验失败不应重试;429、502、503 可遵循 Retry-After 或指数退避进行有限重试;409 应重新读取最新资源和 head content,判断是否发生人工编辑,不能自动覆盖。批处理某篇失败时,默认停止该篇后续 publish,但可以继续处理互不依赖的其他文章,并在末尾给出明确失败清单。
回滚不能靠“把旧 HTML 从记忆里拼回来”。发布前应读取并保存当前 release-content、Post 元数据以及 release snapshot 标识到受保护的制品目录;发生问题时,优先使用 Halo 提供的快照回退能力,随后仍要重新 publish 并走完整验收。备份文件可能包含未公开正文或内部附件 URL,应按敏感数据管理,不上传到公开仓库。
十、常见误区与故障定位
- 把 Content API 的资源更新当成编辑接口:资源 CRUD 与 Console 编辑工作流职责不同。创建/更新正文应使用对应的 Console PostRequest 端点。
- 创建后直接认为已发布:草稿存在不代表 release snapshot 存在,必须显式 publish。
- 发布更新前的 snapshot:这是前台继续显示旧正文的典型原因。head 必须取自本次 PUT 之后。
- 从更新响应猜字段位置:版本或客户端包装可能让响应是 Post,也可能被调用层包装;读取不到时重新 GET Post,而不是构造一个名称。
- 只检查标题:标题属于 Post spec,可能更新成功,而正文 release 仍旧。验收必须包含正文唯一标记。
- 使用登录会话访问前台验收:管理员可能看到预览或未公开内容,匿名请求才代表普通读者。
- 把附件上传响应状态当成可用:对象存储权限、域名解析或 CDN 仍可能失败,必须请求最终 URL。
- 每次更新都覆盖 owner、分类和标签:应从最新服务端对象出发,只改计划字段;owner 尤其不能写成用户名显示值。
- 忽略 raw 与 content 的一致性:HTML 工作流中两者都写同一份最终 HTML 最清楚;若使用 Markdown,应按 Halo 当前编辑器和转换链路生成匹配的 rendered content,并声明真实 rawType,不能照抄本文的 HTML 方案。
- 把 200 当作最终成功:发布是跨资源、快照和前台缓存的链路,任何一层都可能滞后或失败。
故障定位按链路逆推:401/302 先查认证、Cookie 是否过期及 #HttpOnly_ 是否被误删;403 查账户权限;400 查 PostRequest 字段、枚举和 rawType;409 查资源版本或并发编辑;后台新、release-content 旧,查新 headSnapshot 是否传给 publish;release-content 新、前台旧,查 permalink、主题模板、可见性与多级缓存;前台正文新但图片坏,查附件最终 URL、存储策略和 CSP。
最后,建议在生产发布前准备一篇永不进入导航的验收文章,完整跑一次创建、上传小附件、更新、发布、匿名检查和快照回退。Halo 升级后先对这篇文章运行冒烟测试,再恢复批量任务。这样才能把“接口调用过”提升为“文章确实按预期公开发布,而且流程可验证、可重试、可回滚”。