Halo 是一款功能强大的开源博客系统,提供了丰富的 API 接口。本文将详细介绍如何通过 API 实现文章的自动化创建、更新和发布,包含完整的代码示例和踩坑记录。

目录

环境准备

项目 说明
Halo 版本 2.22+(推荐 2.22.14)
认证方式 PAT(Personal Access Token)或 Basic Auth
开发语言 Python 3.10+
依赖库 requests

注意: Halo 2.20+ 默认关闭了 Basic Auth,强烈推荐使用 PAT 进行认证。

认证方式选择

PAT 认证(推荐)

在 Halo 后台 → 个人中心 → 个人令牌 中创建 PAT,权限选择「全部」。

import os

def read_env_var(name):
    """从 .env 文件逐行读取变量,避免 shell 截断长 token"""
    env_path = os.path.expanduser("~/.hermes/.env")
    with open(env_path, "r") as f:
        for line in f:
            line = line.strip()
            if line.startswith(f"{name}=") and not line.startswith("#"):
                return line.split("=", 1)[1].strip()
    raise ValueError(f"{name} not found in .env")

HALO_TOKEN = read_env_var("HALO_API_TOKEN")
HALO_BASE = read_env_var("HALO_BASE_URL").rstrip("/")

HEADERS = {
    "Authorization": f"Bearer {HALO_TOKEN}",
    "Content-Type": "application/json",
}

Basic Auth(备选)

如果不想用 PAT,也可以用 Basic Auth,但需要在 Halo 配置中手动开启:

import base64

credentials = base64.b64encode(b"username:password").decode()
HEADERS = {
    "Authorization": f"Basic {credentials}",
    "Content-Type": "application/json",
}

⚠️ 关键陷阱

PAT token 通常非常长(2000+ 字符)。不要用 os.environ.get() 或 grep 来读取——shell 会截断长 token 导致认证失败。必须用 Python 逐行读取 .env 文件。

核心 API 端点一览

Halo 2.22+ 使用 User-center(UC)API,所有操作都以 /apis/uc.api.content.halo.run/v1alpha1/posts 为基础路径。

操作 方法 路径
获取文章列表 GET /apis/uc.api.content.halo.run/v1alpha1/posts
获取单篇文章 GET /apis/uc.api.content.halo.run/v1alpha1/posts/{name}
创建文章 POST /apis/uc.api.content.halo.run/v1alpha1/posts
获取草稿内容 GET /posts/{name}/draft?patched=true
更新草稿 PUT /posts/{name}/draft
发布文章 PUT /posts/{name}/publish
取消发布 PUT /posts/{name}/unpublish
删除文章 PUT /posts/{name}/recycle

完整工作流详解

正确的文章创建流程分为四步:创建 → 获取草稿 → 更新内容 → 发布。

第一步:创建文章(POST)

创建文章时必须包含完整的 spec 字段和 content-json annotations:

import json
import hashlib
from datetime import datetime

def generate_slug(title):
    """根据标题生成 URL 友好的 slug"""
    ts = datetime.now().strftime("%Y%m%d%H%M")
    h = hashlib.md5(title.encode()).hexdigest()[:6]
    en_parts = re.findall(r'[a-zA-Z0-9]+', title)
    if en_parts:
        slug = "-".join(en_parts[:5]).lower()
        if len(slug) > 5:
            return f"{slug}-{h[:4]}"
    return f"post-{ts}-{h[:6]}"

def create_post(title, markdown, categories=None, tags=None):
    """创建文章草稿"""
    slug = generate_slug(title)

    content_json = json.dumps({
        "content": "",
        "raw": markdown,
        "rawType": "markdown"
    }, ensure_ascii=False)

    payload = {
        "apiVersion": "content.halo.run/v1alpha1",
        "kind": "Post",
        "metadata": {
            "name": slug,
            "annotations": {
                "content.halo.run/preferred-editor": "default",
                "content.halo.run/content-json": content_json,
            }
        },
        "spec": {
            "title": title,
            "slug": slug,
            "excerpt": {"autoGenerate": True, "raw": ""},
            "cover": "",
            "pinned": False,
            "allowComment": True,
            "publish": False,
            "visible": "PUBLIC",
            "categories": categories or [],
            "tags": tags or [],
            "owner": "",
        },
    }

    resp = requests.post(
        f"{HALO_BASE}/apis/uc.api.content.halo.run/v1alpha1/posts",
        headers=HEADERS, json=payload, timeout=30
    )
    resp.raise_for_status()
    return resp.json()

第二步:获取草稿内容

创建成功后,需要获取草稿以拿到完整的元数据:

def get_draft(post_name):
    """获取文章草稿"""
    resp = requests.get(
        f"{HALO_BASE}/posts/{post_name}/draft?patched=true",
        headers=HEADERS, timeout=30
    )
    resp.raise_for_status()
    return resp.json()

第三步:更新草稿内容

这是最关键的一步! 必须同时更新三个字段,否则前台不会显示新内容:

def update_draft(post_name, draft, markdown):
    """更新草稿内容 —— 必须同时更新三个字段!"""
    content_json = json.dumps({
        "content": "",
        "raw": markdown,
        "rawType": "markdown"
    }, ensure_ascii=False)

    # 修改 annotations 中的 content-json
    draft["metadata"]["annotations"]["content.halo.run/content-json"] = content_json

    # 修改 spec 中的 raw 和 content
    draft["spec"]["raw"] = markdown
    draft["spec"]["content"] = markdown  # 这个也要更新!

    resp = requests.put(
        f"{HALO_BASE}/posts/{post_name}/draft",
        headers=HEADERS, json=draft, timeout=30
    )
    resp.raise_for_status()
    return resp.json()

第四步:发布文章

def publish_post(post_name):
    """发布文章"""
    resp = requests.put(
        f"{HALO_BASE}/apis/uc.api.content.halo.run/v1alpha1/posts/{post_name}/publish",
        headers=HEADERS, timeout=30
    )
    resp.raise_for_status()
    return resp.json()

常见问题与排坑

问题 1:POST 返回 500 错误

错误信息: java.lang.NullPointerException: content is null

原因: 缺少 content-json annotations 或 spec 字段不完整。

解决: 确保创建时包含完整的 spec 和 content.halo.run/content-json annotation。

问题 2:更新后前台不显示新内容

原因: 只更新了 content-json,没有同步更新 patched-content 和 patched-raw。

解决: 更新草稿时必须同时修改三个位置: - annotations.content.halo.run/content-json - spec.raw - spec.content

问题 3:删除文章返回 403

原因: /recycle 接口需要管理员权限,PAT 可能权限不足。

解决: 使用软删除方式,设置 spec.deleted = true,或在后台手动删除。

问题 4:创建文章时报 slug 冲突

原因: 同名 slug 已存在。

解决: 在 slug 中加入时间戳或 hash 后缀,确保唯一性。

实战代码示例

将上面的函数组合成一个完整的发布脚本:

#!/usr/bin/env python3
"""Halo 博客一键发布脚本"""

import requests
import json
import re
import hashlib
import os
from datetime import datetime

# ...(上面的函数定义)

def main():
    title = "我的第一篇自动化文章"
    markdown = """
## Hello World

这是一篇通过 API 自动发布的文章。

```python
print("Hello from Halo API!")

自动化发布让博客管理更轻松。 """

# 1. 创建草稿
post = create_post(title, markdown, categories=["halo-blog"])
post_name = post["metadata"]["name"]
print(f"Draft created: {post_name}")

# 2. 获取草稿
draft = get_draft(post_name)

# 3. 更新内容
update_draft(post_name, draft, markdown)
print("Content updated")

# 4. 发布
publish_post(post_name)
permalink = post.get("status", {}).get("permalink", "")
print(f"Published: {permalink}")

if __name__ == "__main__": main() ```

参考资源


通过本文的介绍,你应该已经掌握了 Halo 博客 API 自动化的核心流程。关键在于理解四步工作流(创建 → 获取 → 更新 → 发布)以及更新时必须同步三个字段的注意事项。祝你的博客自动化之路顺利!