Administrator
发布于 2026-10-20 / 3 阅读
0
0

用 WorkBuddy 定时采集资讯并自动发布到 Halo

实践记录 · 最后更新 2026 年 10 月 20 日

用 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 内存的机器是可以跑的,但有两个前提:

  1. 必须加 Swap,建议 2 GB,防止内存尖峰直接触发 OOM;
  2. 必须限制 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)}

三个设计要点:

  1. if not items: return None —— 版块当天没内容就不发。硬凑条数只会让读者觉得站点内容稀薄。
  2. slug 固定生成 —— ai-daily-{日期}-{版块},这是幂等性的全部依据。
  3. 先删后建 —— 因为 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. 单页比文章复杂,不要想当然。 快照模型的三个指针必须同步,且不能复用增量快照。

整条链路跑通之后,每天只需确认发布结果,其余全部自动完成。这套方案谈不上精致,但足够稳定 —— 对长期运行的自动化系统而言,稳定比优雅更重要。


评论