用 WorkBuddy 定时采集资讯并自动发布到 Halo
本文记录了把「手动搬运资讯」这件事改造成全自动流水线的完整过程。
最终效果:每天早上 8:30 和中午 12:30,程序自动从资讯源抓取当天的行业动态,按五个方向拆成独立文章,分门别类发布到 Halo 博客的分类下。全程无需人工介入。
下文把整条链路、踩过的坑和完整代码都摊开写一遍。
一、先定清楚几个前提
动手前有几个决策必须先定,否则后面容易反复返工。
决策 1:采集端放在哪台机器?
如果在云服务器上直接跑采集,很可能撞上出网限制 —— 不少云厂商默认拦截服务器主动访问外网,访问外部接口直接返回 AccessDeny。
因此推荐的架构是:采集在本地机器上跑,发布通过 HTTP API 打到服务器。服务器只负责存储和展示,不主动出网。这样既绕开了出网限制,也把采集负载从服务器上摘了出去。
决策 2:一天发一篇,还是一天发多篇?
最初是"一天一篇大杂烩",把所有版块塞进一篇文章。用下来很别扭:只想看论文的人得翻过一堆产品发布。后来改成 每个版块独立成篇、各归各的分类,阅读体验明显更好。
决策 3:重复执行会不会产生重复文章?
定时任务总有可能跑两次(比如定时触发后又手动补跑)。所以脚本必须幂等 —— 用固定的 slug 做唯一标识,同样的日期重复跑只做替换,不会堆积。
二、整体架构
┌─────────────────────────────────────────────┐
│ 本地机器 │
│ │
│ WorkBuddy 定时任务 │
│ ↓ 触发 │
│ publish_to_halo.py │
│ ├─ 拉取 AI HOT 日报 API │
│ ├─ 按版块拆分 + 渲染 HTML │
│ └─ POST 到 Halo REST API │
└──────────────────┬──────────────────────────┘
│ HTTP
↓
┌─────────────────────────────────────────────┐
│ 云服务器 │
│ │
│ Nginx :80/:443 │
│ ↓ 反向代理 │
│ Halo :8090 ── PostgreSQL :5432 │
└─────────────────────────────────────────────┘
服务器侧是三个 Docker 容器,用 docker-compose 编排。Halo 只监听容器内网,由 Nginx 统一对外;数据库完全不暴露。
三、服务器配置推荐
这套栈对资源的要求不高,但内存是最容易踩线的一项 —— Halo 是 Spring Boot 应用,JVM 堆开得过大在低内存机器上会被 OOM Killer 干掉,开得过小又会频繁 GC。
| 场景 | CPU | 内存 | 系统盘 | 说明 |
|---|---|---|---|---|
| 最低可用 | 2 核 | 2 GB | 60 GB SSD | 必须配 Swap,并限制 JVM 堆 |
| 推荐 | 2 核 | 4 GB | 80 GB SSD | 余量充足,后台操作流畅 |
| 内容量大 | 4 核 | 8 GB | 100 GB SSD | 适合有大量图片、访问量较高的场景 |
2 GB 内存的机器是可以跑的,但有两个前提:
- 必须加 Swap,建议 2 GB,防止内存尖峰直接触发 OOM;
- 必须限制 JVM 堆,通过
JVM_OPTS=-Xmx320m -Xms256m这类参数把上限压住。
如果预算允许,4 GB 是更省心的选择 —— 省下来的运维时间通常比硬件差价更值钱。
其他配置建议:
- 带宽:资讯站以文本为主,3 Mbps 左右足够;如果放了较多图片,按 5 Mbps 起算。
- 系统:Ubuntu 22.04 / Debian 12 均可,Docker 与 Compose 直接装官方源版本。
- 备份:数据库目录(
pgdata)和 Halo 数据目录(data)必须定期备份,这两个目录丢了等于站点重建。
四、服务器:Halo 部署
docker-compose.yml
services:
halo:
image: registry.fit2cloud.com/halo/halo:2.27
container_name: halo
restart: on-failure:3
networks:
- webnet
volumes:
- ./data:/root/.halo2
ports:
- "8090:8090"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8090/actuator/health/readiness"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
environment:
- JVM_OPTS=-Xmx320m -Xms256m
command:
- --spring.r2dbc.url=r2dbc:pool:postgresql://halodb/halo
- --spring.r2dbc.username=halo
- --spring.r2dbc.password=halopass2026
- --spring.sql.init.platform=postgresql
- --halo.external-url=https://your-domain.com/
- --halo.console.location=/console
depends_on:
halodb:
condition: service_healthy
halodb:
image: postgres:15.4-alpine
container_name: halodb
restart: on-failure:3
networks:
- webnet
volumes:
- ./pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U halo"]
interval: 10s
timeout: 5s
retries: 5
environment:
- POSTGRES_PASSWORD=halopass2026
- POSTGRES_USER=halo
- POSTGRES_DB=halo
- PGUSER=halo
nginx:
image: nginx:alpine
container_name: web-nginx
restart: unless-stopped
networks:
- webnet
ports:
- "80:80"
- "443:443"
volumes:
- ./conf:/etc/nginx/conf.d:ro
- ./logs:/var/log/nginx
- ./static:/usr/share/nginx/static:ro
depends_on:
- halo
networks:
webnet:
driver: bridge
三个要点值得单独说:
JVM_OPTS=-Xmx320m -Xms256m —— 这是 2 GB 内存机器的必填项。不加限制,JVM 会按物理内存比例申请堆,容器很容易被 OOM Killer 终止。
depends_on: condition: service_healthy —— 这不是简单的"启动顺序",而是等数据库健康检查通过后才启动 Halo。少了这一项,Halo 会因为连不上库直接退出,然后 restart 反复重启。
数据库选 PostgreSQL 而不是 H2 —— H2 看着能省掉一个容器,但 r2dbc-h2 驱动会把 file:///root/... 规范成 file:root/...,前导斜杠丢失,导致永久连接失败。在数据库上省事往往要付出更多代价,这是整个项目最深刻的一条教训。
Nginx 反向代理
upstream halo_backend {
server halo:8090;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name _;
charset utf-8;
client_max_body_size 100m;
# 主要站点:全部转发给 Halo
location / {
proxy_pass http://halo_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "";
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# 静态备用页
location /static/ {
alias /usr/share/nginx/static/;
index index.html;
}
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_comp_level 5;
}
proxy_set_header Upgrade 和 Connection "" 两行不要漏 —— 缺少它们,Halo 后台的 WebSocket 相关功能会失效。
五、Halo 的 API 特性
这是整个项目最花时间的部分。Halo 2.x 的 API 有几个反直觉的设计,先讲清楚,后面代码就好懂。
5.1 鉴权:Personal Access Token
在后台「设置 → 个人资料 → 个人令牌」生成,勾选所需的权限。请求时带上:
Authorization: Bearer pat_xxxxxxxx
一个容易忽视的点:生成 token 时如果只勾了「文章」,那么 singlepages、categories 等接口全部返回 403,而报错信息只写"禁止访问",不会告知缺少哪个权限。排查时容易误以为是路径写错。
5.2 域名的差异:带不带 api. 前缀
Halo 的 API 分成两套域名,很容易混淆:
| 操作 | 路径 | 说明 |
|---|---|---|
| 创建文章 | POST /apis/api.console.halo.run/v1alpha1/posts |
带 api. |
| 删除文章 | DELETE /apis/content.halo.run/v1alpha1/posts/{name} |
不带 api. |
| 列表查询 | GET /apis/api.console.halo.run/v1alpha1/posts |
带 api. |
| 正文更新(单页) | PUT /apis/api.console.halo.run/v1alpha1/singlepages/{name}/content |
带 api. |
| 分类管理 | GET/POST /apis/content.halo.run/v1alpha1/categories |
不带 api. |
规律可以这样记:写操作(创建文章、更新正文)通常走 api.console,资源操作(删除、查分类)走 content。写错了就是 404 或 403,试两次基本能对上。
5.3 一次 POST 搞定文章 + 正文 + 发布
Halo 2.x 的文章正文不能单独创建(POST /contents 返回 403),必须在创建文章时一并提交:
{
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {
"generateName": "post-",
"annotations": {"content.halo.run/preferred-editor": "default"}
},
"spec": {
"title": "文章标题",
"slug": "ai-daily-2026-10-20-model",
"publish": true,
"visible": "PUBLIC",
"categories": ["category-hkrxqvyf"],
"excerpt": {"autoGenerate": false, "raw": "摘要"}
}
},
"content": {
"raw": "<p>HTML 正文</p>",
"content": "<p>HTML 正文</p>",
"rawType": "HTML"
}
}
generateName 让 Halo 自动分配 name(形如 post-i6dqubaj),省去自己构造唯一 ID 的麻烦。
5.4 改稿只能"删旧建新"
Halo 的正文有独立版本管理,GET /posts/{name}/content 会返回 403,且没有可写的正文端点。因此修改一篇文章的正文,只能:
DELETE 旧文章 → POST 新文章(同 slug)
这也是脚本必须按 slug 判重的原因 —— 每次运行本质是"替换"而非"追加"。
六、采集脚本
6.1 资讯源
示例用的是 AI HOT(aihot.virxact.com),它提供当日日报接口:
GET https://aihot.virxact.com/api/public/daily
GET https://aihot.virxact.com/api/public/daily/2026-10-19 # 指定日期
GET https://aihot.virxact.com/api/public/dailies?take=180 # 存档列表
返回结构:
{
"date": "2026-10-20",
"sections": [
{
"label": "模型发布/更新",
"items": [
{
"title": "MiniMax 发布 M3.1 Flash",
"summary": "中文摘要……",
"sourceUrl": "https://...",
"sourceName": "机器之心",
"permalink": "https://aihot.virxact.com/items/abc123"
}
]
}
]
}
固定的五个版块:模型发布/更新、产品发布/更新、行业动态、论文研究、技巧与观点。
必须注意的点:该接口会校验 User-Agent,不带浏览器 UA 直接返回 403。因此请求头需要伪装成浏览器:
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
6.2 分类映射
脚本需要知道"哪个版块对应哪个 Halo 分类"。用 setup_categories.py 幂等创建分类,输出映射文件:
{
"ai-models": "category-hkrxqvyf",
"ai-products": "category-saeztrtg",
"industry": "category-d4wtjxlq",
"paper": "category-pllrnouj",
"tip": "category-8yb140dr"
}
版块中文名 → key → 分类 name,两层映射:
LABEL_TO_KEY = {
"模型发布/更新": "model",
"产品发布/更新": "product",
"行业动态": "industry",
"论文研究": "paper",
"技巧与观点": "tip",
}
KEY_TO_SLUG = {
"model": "ai-models",
"product": "ai-products",
"industry": "industry",
"paper": "paper",
"tip": "tip",
}
6.3 核心请求封装
所有 HTTP 请求走同一个函数,带重试与证书容错:
def _ctx():
"""部分开发环境走代理时证书吊销检查会失败,这里关闭校验"""
c = ssl.create_default_context()
c.check_hostname = False
c.verify_mode = ssl.CERT_NONE
return c
def request(url, method="GET", body=None, token=None, retries=3):
data = None
headers = {"User-Agent": UA, "Accept": "application/json"}
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
last = None
for attempt in range(retries):
try:
req = urllib.request.Request(url, data=data, headers=headers,
method=method)
with urllib.request.urlopen(req, timeout=30, context=_ctx()) as r:
raw = r.read().decode("utf-8")
return r.status, (json.loads(raw) if raw.strip() else {})
except urllib.error.HTTPError as e:
detail = ""
try:
detail = e.read().decode("utf-8")[:400]
except Exception:
pass
last = "HTTP %s %s" % (e.code, detail)
# 这几个错误码重试没有意义,直接跳出
if e.code in (400, 401, 403, 404, 409):
break
except Exception as e:
last = str(e)
time.sleep(2 * (attempt + 1))
raise RuntimeError("%s %s 失败: %s" % (method, url, last))
两个细节:
ssl.CERT_NONE—— 在部分 Windows 环境(尤其走代理时),curl和 Python 都会因为"证书吊销列表检查失败"(CRYPT_E_NO_REVOCATION_CHECK)报错。关闭校验是最省事的解法。生产环境若在意安全,应正确配置根证书而非关闭校验。- 400/401/403/404 不重试 —— 这些都是逻辑错误,重试再多次也是同样结果,只会浪费时间。
6.4 发布单篇:publish_section
这是最核心的函数,把一个版块发成一篇文章:
def publish_section(d, section, token, publish_now=True, owner=None):
"""把一个版块发布为独立文章(归入对应分类)。返回结果 dict 或 None。"""
date_str = d.get("date")
label = section.get("label") or ""
items = section.get("items") or []
if not items:
return None # 该版块当天没内容,不发空文章
key = LABEL_TO_KEY.get(label)
if not key:
log("未知版块,跳过: %s" % label)
return None
cat_name = CATEGORY_MAP.get(key)
items = items[:MAX_ITEMS]
# 标题取该版块第一条,截断 60 字,不加任何前缀
title = (items[0].get("title") or "").strip()[:60] or label
html = render_html(d, {}, section, label)
# slug 是幂等的关键:同一天同一个版块永远是这个 slug
slug = "ai-daily-%s-%s" % (date_str, key)
spec = {
"title": title,
"slug": slug,
"template": "",
"cover": "",
"deleted": False,
"publish": bool(publish_now),
"pinned": False,
"allowComment": True,
"visible": "PUBLIC",
"priority": 0,
"excerpt": {
"autoGenerate": False,
"raw": (items[0].get("summary") or "")[:150],
},
"categories": [cat_name] if cat_name else [],
"tags": [],
"htmlMetas": [],
}
if owner:
spec["owner"] = owner
# 同 slug 已存在 -> 删旧建新(Halo 正文无法原地更新)
old_name, _ = find_existing(slug, token)
if old_name:
request("%s/apis/content.halo.run/v1alpha1/posts/%s"
% (HALO_BASE, old_name), "DELETE", token=token)
payload = {
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {
"generateName": "post-",
"annotations": {"content.halo.run/preferred-editor": "default"},
},
"spec": spec,
},
"content": {"raw": html, "content": html, "rawType": "HTML"},
}
st, body = request("%s/apis/api.console.halo.run/v1alpha1/posts" % HALO_BASE,
"POST", payload, token)
name = ((body or {}).get("metadata") or {}).get("name", "?")
log("[%s] %s (%d 条) -> %s" % (label, title[:30], len(items), name))
return {"label": label, "key": key, "name": name,
"title": title, "items": len(items)}
三个设计要点:
if not items: return None—— 版块当天没内容就不发。硬凑条数只会让读者觉得站点内容稀薄。slug固定生成 ——ai-daily-{日期}-{版块},这是幂等性的全部依据。- 先删后建 —— 因为 Halo 正文改不了,只能整体替换。
6.5 遍历所有版块
def publish(d, token, publish_now=True, owner=None):
"""把一个日期的所有有内容的版块,各发一篇。"""
results = []
for sec in (d.get("sections") or []):
try:
r = publish_section(d, sec, token, publish_now, owner)
if r:
results.append(r)
except Exception as e:
# 单个版块失败不影响其他版块
log("[%s] 发布失败: %s" % (sec.get("label"), e))
return {
"date": d.get("date"),
"action": "per-section",
"arts": len(results),
"items": sum(r["items"] for r in results),
"sections": [r["label"] for r in results],
"published": publish_now,
}
逐个 try/except 很关键:某个版块渲染出问题,不应拖垮整天的发布。
6.6 判重与清理旧文章
def find_existing(slug, token):
"""按 slug 精确查找文章,返回 (name, title)"""
st, body = request(
"%s/apis/api.console.halo.run/v1alpha1/posts?page=0&size=100"
% HALO_BASE, token=token)
for it in (body.get("items") or []):
p = it.get("post") or {}
spec = p.get("spec") or {}
if spec.get("slug") == slug:
return (p.get("metadata") or {}).get("name"), spec.get("title")
return None, None
def list_all_posts(token):
"""列出全部文章,返回 (name, slug, title) 三元组"""
_, body = request(
"%s/apis/api.console.halo.run/v1alpha1/posts?page=0&size=200"
% HALO_BASE, token=token)
out = []
for it in (body.get("items") or []):
p = it.get("post") or {}
meta = p.get("metadata") or {}
spec = p.get("spec") or {}
out.append((meta.get("name"), spec.get("slug"), spec.get("title")))
return out
def already_published(date_str, token):
"""当日是否已有任一版块文章"""
for _n, slug, _t in list_all_posts(token):
if slug and slug.startswith("ai-daily-%s" % date_str):
return True
return False
一个小坑:out.append((a, b, c)) 如果写成 out.append(a, b, c),会直接抛 TypeError: list.append() takes exactly one argument。三元组必须用括号包起来。
6.7 渲染 HTML
纯文字卡片流。注意:CSS 中不要写死任何颜色。
STYLE = """<style>
.aihot{font-size:16px;line-height:1.75;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
.aihot *{box-sizing:border-box}
.aihot .hd{display:flex;flex-wrap:wrap;align-items:center;gap:10px;
padding:0 0 18px;border-bottom:1px solid;margin-bottom:22px}
.aihot .hd .sub{font-size:13.5px;opacity:.6}
.aihot .card{padding:18px 20px;margin-bottom:12px;
border:1px solid;border-radius:10px;transition:box-shadow .2s,border-color .2s}
.aihot .card .hd2{display:flex;gap:10px;align-items:baseline;margin-bottom:8px}
.aihot .card .no{flex:0 0 auto;font-size:12.5px;font-weight:700;
font-variant-numeric:tabular-nums;letter-spacing:.5px}
.aihot .card .tt{font-size:16.5px;font-weight:650;line-height:1.5}
.aihot .card .ds{font-size:14.5px;line-height:1.8;
margin:0 0 10px;padding-left:22px;opacity:.85}
.aihot .card .ft{display:flex;align-items:center;gap:12px;padding-left:22px;
font-size:12.5px;opacity:.55}
.aihot .card .ft .go{margin-left:auto;font-weight:500}
@media (max-width:600px){
.aihot .card{padding:15px 16px}
.aihot .card .ds,.aihot .card .ft{padding-left:0}
}
</style>"""
def render_html(d, articles=None, section=None, label=None):
"""渲染单篇文章(一个版块)"""
articles = articles or {}
out = [STYLE, '<div class="aihot">']
items_all = (section or {}).get("items") or []
items = items_all[:MAX_ITEMS]
label = label or (section or {}).get("label") or ""
# 顶部信息条:版块名 + 条数
extra = "" if len(items_all) <= MAX_ITEMS else "(当日共 %d 条,取前 %d)" % (
len(items_all), len(items))
out.append('<div class="hd"><span class="sub">%s · 本期 %d 条%s</span></div>'
% (esc(label), len(items), esc(extra)))
for idx, it in enumerate(items, 1):
url = it.get("sourceUrl") or it.get("permalink") or "#"
out.append('<div class="card">')
out.append('<div class="hd2"><span class="no">%02d</span>' % idx)
out.append('<span class="tt"><a href="%s" target="_blank" rel="noopener">%s</a></span></div>'
% (esc(url), esc(it.get("title"))))
summary = it.get("summary") or ""
if summary:
out.append('<div class="ds">%s</div>' % esc(summary))
out.append('<div class="ft"><span>%s</span>'
'<a class="go" href="%s" target="_blank" rel="noopener">阅读原文 →</a></div>'
% (esc(it.get("sourceName") or ""), esc(url)))
out.append("</div>")
out.append('<div class="ft">数据来自 '
'<a href="https://aihot.virxact.com" target="_blank">AI HOT</a>,'
'中文摘要由 AI 自动生成。</div>')
out.append("</div>")
return "".join(out)
为什么 CSS 里不写颜色 —— 这一点踩过坑。第一版给整个容器写了 color: #1f2328(深灰近黑),浅色模式下看不出问题。切到暗色模式后,文字依然是黑的,压在深色背景上完全无法阅读。
原因是 Halo 主题的正文容器是 <article class="markdown-body">,暗色模式靠 .dark .markdown-body 把文字换成浅色;而内联 color 优先级更高,直接盖掉了主题规则。
解法是不写任何字面颜色:文字色继承,需要弱化就用 opacity(0.55 ~ 0.85),需要边框就用 border:1px solid(自动继承 currentColor)。这样浅色、暗色都能自适应。
6.8 主流程
def main():
args = sys.argv[1:]
date_str = None
publish_now = PUBLISH_NOW
check_only = False
force = False
for a in args:
if a == "--draft":
publish_now = False
elif a == "--check":
check_only = True
elif a == "--force":
force = True
elif not a.startswith("-"):
date_str = a
if "--cleanup-legacy" in args:
token = get_token() # 即使 dry-run 也要 token,列文章需要鉴权
killed = cleanup_legacy(token, dry=check_only)
log("清理旧版整日文章 %d 篇: %s" % (len(killed), ", ".join(killed) or "-"))
return 0
token = None if check_only else get_token()
try:
d = fetch_daily(date_str)
except Exception as e:
log("拉取日报失败: %s" % e)
return 1
date_got = d.get("date") or date_str
total = sum(len(s.get("items") or []) for s in (d.get("sections") or []))
log("拿到日报 %s:%d 条" % (date_got, total))
if check_only:
log("检查模式,不发布")
return 0
if total == 0:
log("当天无内容,跳过")
return 0
if not force and already_published(date_got, token):
log("%s 已发布过,跳过(要重发请加 --force)" % date_got)
return 0
try:
r = publish(d, token, publish_now, os.environ.get("HALO_OWNER") or None)
log("完成: %s" % json.dumps(r, ensure_ascii=False))
# 记录状态
st = load_state()
if r["date"] not in st["dates"]:
st["dates"].append(r["date"])
st["dates"] = sorted(set(st["dates"]), reverse=True)
st["last_run"] = datetime.now(CST).isoformat()
save_state(st)
return 0
except Exception as e:
log("发布失败: %s" % e)
return 2
退出码约定:0 成功/跳过,1 拉取失败,2 发布失败。定时任务可根据退出码决定是否告警。
七、分类初始化脚本
setup_categories.py —— 幂等创建五个分类,并输出映射文件:
CATEGORIES = [
{"key": "model", "label": "模型发布/更新", "displayName": "模型发布",
"slug": "ai-models", "priority": 1,
"description": "大模型与基座模型的发布、版本更新、能力升级。"},
{"key": "product", "label": "产品发布/更新", "displayName": "产品发布",
"slug": "ai-products", "priority": 2,
"description": "AI 应用、工具与平台的产品发布和功能更新。"},
{"key": "industry", "label": "行业动态", "displayName": "行业动态",
"slug": "industry", "priority": 3,
"description": "AI 行业新闻、公司动态、融资与政策监管。"},
{"key": "paper", "label": "论文研究", "displayName": "论文研究",
"slug": "paper", "priority": 4,
"description": "前沿论文与研究进展解读。"},
{"key": "tip", "label": "技巧与观点", "displayName": "技巧观点",
"slug": "tip", "priority": 5,
"description": "实践经验、使用技巧与行业观点。"},
]
def ensure_category(cat, token, existing):
"""存在就返回,不存在就创建 —— 可以重复执行"""
slug = cat["slug"]
for c in existing:
if (c.get("spec") or {}).get("slug") == slug:
return c["metadata"]["name"], "exists"
payload = {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Category",
"metadata": {"generateName": "category-"},
"spec": {
"displayName": cat["displayName"],
"slug": slug,
"description": cat.get("description", ""),
"cover": "",
"template": "",
"priority": cat.get("priority", 0),
"children": [],
"preventParentPostCascadeQuery": False,
"hideFromList": False,
},
}
st, body = request("%s/apis/content.halo.run/v1alpha1/categories" % HALO_BASE,
"POST", payload, token)
return (body.get("metadata") or {}).get("name"), "created"
执行后生成 categories.json,publish_to_halo.py 启动时读取:
def resolve_categories():
"""返回 {key: categoryName},如 {"model": "category-hkrxqvyf"}"""
m = load_category_names()
out = {}
for key, slug in KEY_TO_SLUG.items():
n = m.get(slug)
if n:
out[key] = n
return out
CATEGORY_MAP = resolve_categories() # 模块级加载
八、回填历史脚本
需要补发历史日期时使用 backfill.py:
def get_available():
"""返回可用日期列表(新 -> 旧)"""
_, d = request("https://aihot.virxact.com/api/public/dailies?take=180")
return [x["date"] for x in (d.get("items") or [])]
def main():
args = sys.argv[1:]
days = 10
start = end = None
for i, a in enumerate(args):
if a == "--days" and i + 1 < len(args):
days = int(args[i + 1])
elif a.count("-") == 2 and len(a) == 10:
if start is None:
start = a
else:
end = a
avail = get_available()
today = datetime.now(CST).strftime("%Y-%m-%d")
if start:
end = end or start
targets = [d for d in avail if start <= d <= end]
else:
past = [d for d in avail if d < today]
targets = past[:days]
targets = sorted(set(targets))
token = get_token()
ok = skip = fail = 0
for i, dt in enumerate(targets, 1):
print("\n[%d/%d] %s" % (i, len(targets), dt))
try:
d = fetch_daily(dt)
except Exception as e:
print(" 拉取失败: %s" % e)
fail += 1
continue
total = sum(len(s.get("items") or []) for s in (d.get("sections") or []))
if total == 0:
print(" 当天无内容,跳过")
skip += 1
continue
try:
r = publish(d, token, publish_now=True)
print(" -> %d 篇 / %d 条 | %s"
% (r["arts"], r["items"], "、".join(r["sections"])))
ok += 1
except Exception as e:
print(" 发布失败: %s" % e)
fail += 1
time.sleep(1.5) # 控制节奏,避免触发限流
print("\n===== 完成 =====")
print("成功 %d / 跳过 %d / 失败 %d" % (ok, skip, fail))
time.sleep(1.5) 不要省 —— 连续几十个请求不带间隔,很容易被限流,或者把 Halo 的连接池拖垮。
九、单页更新脚本
Halo 内置的「关于 / 隐私政策 / 用户协议」是 SinglePage,与文章完全是两套 API。而且它的正文使用快照(Snapshot)模型,比文章更绕。
9.1 页面结构
SinglePage
├── spec.headSnapshot ← 草稿版本(编辑器里看到的)
├── spec.releaseSnapshot ← 线上版本(访客看到的)
└── spec.baseSnapshot ← 上个发布版本
这三个指针必须同时指向同一个快照,否则页面会 500。
9.2 正确做法
def create_snapshot(page_name, html, token):
"""新建一个完整快照(非增量),返回快照 name"""
payload = {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Snapshot",
"metadata": {
"generateName": "snapshot-",
"annotations": {"content.halo.run/keep-raw": "true"},
},
"spec": {
"rawPatch": html, # 注意:全文,不是 diff
"contentPatch": html,
"rawType": "HTML",
"owner": "admin",
"contributors": ["admin"],
"subjectRef": {
"group": "content.halo.run",
"version": "v1alpha1",
"kind": "SinglePage",
"name": page_name,
},
},
}
_, body = request("%s/apis/content.halo.run/v1alpha1/snapshots" % HALO_BASE,
"POST", payload, token)
return (body.get("metadata") or {}).get("name")
def update_page(key, token):
cfg = PAGES[key]
pg = find_page(key, token)
if not pg:
return False, "未找到页面 %s" % key
name = (pg.get("metadata") or {}).get("name")
# 1) 新建完整快照
snap = create_snapshot(name, cfg["html"], token)
# 2) 三个指针全部指向新快照
spec = dict(pg.get("spec") or {})
spec["headSnapshot"] = snap
spec["releaseSnapshot"] = snap
spec["baseSnapshot"] = snap
spec["title"] = cfg["title"]
spec["publish"] = True
spec["visible"] = "PUBLIC"
# 摘要要一起换,否则 <meta description> 还是旧内容
spec["excerpt"] = {"autoGenerate": False, "raw": cfg["excerpt"]}
md = pg.get("metadata") or {}
payload = {
"apiVersion": pg.get("apiVersion"),
"kind": pg.get("kind"),
"metadata": {
"name": name,
"version": md.get("version"),
"annotations": md.get("annotations") or {},
"labels": md.get("labels") or {},
},
"spec": spec,
}
request("%s/apis/content.halo.run/v1alpha1/singlepages/%s"
% (HALO_BASE, name), "PUT", payload, token)
return True, "%s -> 快照 %s" % (cfg["title"], snap[:8])
9.3 三个易错点
易错点 1:PUT .../content 只写草稿,线上不生效。
PUT /apis/api.console.halo.run/v1alpha1/singlepages/{name}/content 返回 200,看起来成功,但访客访问到的仍是旧内容 —— 因为它只更新了 head 快照。
易错点 2:直接把 releaseSnapshot 指向已有的 head 快照会 500。
head 快照存的是增量 diff,形如:
{"contentPatch": "[{\"source\":{\"position\":0,\"lines\":[...]},\"target\":{\"position\":0,\"lines\":[...]}}]"}
它缺少 rawPatch、rawType 等字段,单独作为发布版会让渲染崩掉。正确做法是新建一个完整快照。
易错点 3:spec.excerpt 不改,meta description 仍是旧内容。
页面标题对了、正文对了,但搜索引擎抓到的描述还是初始化时的占位文字。这个字段在 spec.excerpt.raw 里,需要一并更新。
十、定时任务配置
WorkBuddy 支持用 RRULE 配置定时任务。推荐配置两个:
主跑 —— 每天 08:30
FREQ=DAILY;BYHOUR=8;BYMINUTE=30
补跑 —— 每天 12:30
FREQ=DAILY;BYHOUR=12;BYMINUTE=30
任务是让 WorkBuddy 在本地执行命令:
cd /d "D:\path\to\collector"
"C:\...\python.exe" publish_to_halo.py
RRULE 的一个限制:BYHOUR 必须是 0-23 的单个整数,不支持逗号列表。写成 BYHOUR=8,12 会直接报错 BYHOUR must be an integer between 0 and 23。若需要一天两次,只能拆成两个独立任务。
补跑任务必须加 --force,否则会被"当日已发布"的判断挡住。
十一、Windows 环境的常见问题
如果采集端也是 Windows,以下几点大概率会遇上:
1. curl 访问 HTTPS 报证书吊销检查失败
CRYPT_E_NO_REVOCATION_CHECK
走代理时 schannel 拿不到吊销列表。解法是加 --ssl-no-revoke,或者改用 Python 并关闭校验。
2. cmd 脚本编码
.bat 文件里有中文路径时,务必在开头加:
chcp 65001 >nul
否则路径中的中文会变成乱码,直接找不到文件。
3. 不要用 PowerShell 的 Set-Content 生成带中文路径的脚本
用 PowerShell 生成带中文路径的 .ps1 时,容易出现编码错乱、文件名变乱码。应直接写文件,或使用无 BOM 的 UTF-8 编码。
十二、运行效果
稳定运行一段时间后,线上累计 39 篇分类文章:
| 分类 | slug | 文章数 |
|---|---|---|
| 模型发布 | ai-models |
8 |
| 产品发布 | ai-products |
9 |
| 行业动态 | industry |
9 |
| 论文研究 | paper |
6 |
| 技巧观点 | tip |
7 |
每个分类页 /categories/{slug} 均返回 200,且只展示本分类文章。
内容量不均匀属于正常现象。十天里有三天五个分类全满,也有几天只有一个分类有内容 —— 因为资讯源本身的信息量就在波动,「论文研究」这类版块最容易空缺。硬凑条数没有意义,空着就空着。
十三、五条经验
1. 不要在数据库上将就。 H2 的路径规范化 bug 会让人耗费大量时间排查,换 PostgreSQL 一次通过。
2. 写操作与资源操作的 API 域名不同。 记住 api.console 与 content 的区别,能省下大量试错。
3. 幂等靠业务键,不靠数据库。 用固定 slug + 先删后建,比想办法"更新"要简单可靠得多。
4. CSS 中永远不要写死颜色。 只要使用 Halo 主题,就必须让主题的暗色模式能生效。需要弱化文字时用 opacity。
5. 单页比文章复杂,不要想当然。 快照模型的三个指针必须同步,且不能复用增量快照。
整条链路跑通之后,每天只需确认发布结果,其余全部自动完成。这套方案谈不上精致,但足够稳定 —— 对长期运行的自动化系统而言,稳定比优雅更重要。