AI 可以快速生成日报,但它也可能输出缺字段、错误日期和不可用链接。本文用 Astro Content Collections 把内容约束前移到构建阶段,并通过默认草稿、统一过滤与 CI 校验,避免自动生成内容直接影响生产站点。

自动生成内容为什么需要硬边界

本站的 AI 日报和周报本质上都是 Markdown 文件:生成器收集信息、整理摘要,然后写入仓库。它们看似只是内容,实际上会参与路由生成、列表排序和页面渲染。一旦字段格式漂移,构建就可能失败;更隐蔽的问题则会在上线后出现,例如同一期报告重复、草稿被索引,或者日期字符串导致排序异常。

常见风险可以分成三层:

层次典型问题处理位置
单篇字段缺少标题、日期无效、来源不是 URLZod schema
发布状态生成后未经检查就对外可见draft 默认值与查询过滤
跨文件规则同一天存在两份日报、同一周重复发布构建期断言

我的原则是让生成器只负责“尽量生成正确内容”,而让站点负责“拒绝不正确内容”。即使模型提示词发生变化,内容仓库仍然有一套独立、确定的契约。

目录可以保持简单:

src/
  content/
    reports/
      2025-02-18.md
  content.config.ts
  lib/reports.ts
  pages/reports/[slug].astro

用 Zod 定义日报和周报契约

下面使用 Astro Content Collections 的 Content Layer API。日报使用 YYYY-MM-DD,周报使用 ISO 周格式 YYYY-WwwpublishedAt 在 schema 中统一转换为 Date,避免各页面重复解析。

// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const isISODate = (value: string): boolean => {
  const date = new Date(`${value}T00:00:00Z`);
  return (
    !Number.isNaN(date.getTime()) &&
    date.toISOString().slice(0, 10) === value
  );
};

const commonFields = {
  title: z.string().min(8).max(80),
  summary: z.string().min(20).max(200),
  publishedAt: z.coerce.date(),
  draft: z.boolean().default(true),
  topics: z.array(z.string().min(1)).min(1).max(10),
  sources: z
    .array(
      z.object({
        title: z.string().min(1),
        url: z.string().url(),
      }),
    )
    .min(1)
    .max(30),
  generator: z.object({
    provider: z.string().min(1),
    model: z.string().min(1),
    promptVersion: z.string().min(1),
  }),
};

const reports = defineCollection({
  loader: glob({
    base: "./src/content/reports",
    pattern: "**/*.{md,mdx}",
  }),
  schema: z.discriminatedUnion("kind", [
    z.object({
      ...commonFields,
      kind: z.literal("daily"),
      period: z.string().refine(isISODate, "日报 period 必须是有效的 YYYY-MM-DD"),
    }),
    z.object({
      ...commonFields,
      kind: z.literal("weekly"),
      period: z
        .string()
        .regex(/^\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3])$/, "周报 period 必须是 YYYY-Www"),
    }),
  ]),
});

export const collections = { reports };

这里最关键的设计不是字段数量,而是 draft 默认设为 true。生成器忘记写发布状态时,内容会留在草稿区,而不是意外上线。这是一种失败时保持关闭的策略。

一份可通过校验、但尚未发布的日报如下:

---
title: "AI 日报:工具调用与推理模型动态"
summary: "整理当天值得关注的模型发布、工具调用实践和工程生态更新。"
kind: "daily"
period: "2025-02-18"
publishedAt: "2025-02-18T08:00:00+08:00"
draft: true
topics:
  - "工具调用"
  - "推理模型"
sources:
  - title: "Astro Content Collections"
    url: "https://docs.astro.build/en/guides/content-collections/"
generator:
  provider: "internal-pipeline"
  model: "configured-by-workflow"
  promptVersion: "reports-v3"
---

这是待编辑确认的日报正文。确认来源、标题和摘要后,再将 `draft` 改为 `false`

generator 记录生成上下文,但不参与页面展示。它适合排查某一批内容为何格式变化,也方便后续按提示词版本进行迁移。

集中处理 draft 与跨文件校验

不要在每个页面里分别写一次 !entry.data.draft。过滤逻辑一旦分散,RSS、列表页和详情页就可能采用不同标准。我会提供一个唯一的读取入口,并在这里加入跨文件断言。

// src/lib/reports.ts
import {
  getCollection,
  type CollectionEntry,
} from "astro:content";

type Report = CollectionEntry<"reports">;

function assertUniquePeriods(entries: Report[]): void {
  const seen = new Map<string, string>();

  for (const entry of entries) {
    const key = `${entry.data.kind}:${entry.data.period}`;
    const previous = seen.get(key);

    if (previous) {
      throw new Error(
        `报告周期重复:${key} 同时出现在 ${previous} 和 ${entry.id}`,
      );
    }

    seen.set(key, entry.id);
  }
}

export async function getVisibleReports(): Promise<Report[]> {
  const entries = await getCollection("reports");
  assertUniquePeriods(entries);

  return entries
    .filter((entry) => !import.meta.env.PROD || !entry.data.draft)
    .sort(
      (left, right) =>
        right.data.publishedAt.getTime() - left.data.publishedAt.getTime(),
    );
}

开发环境保留草稿,便于本地预览;生产构建只返回已发布内容。重复周期检查针对全部文件执行,因此即使冲突文件都是草稿,CI 也会提前提醒,而不是把问题留到发布当天。

详情页同样从这个入口生成静态路由:

---
// src/pages/reports/[slug].astro
import { render } from "astro:content";
import { getVisibleReports } from "../../lib/reports";

export async function getStaticPaths() {
  const reports = await getVisibleReports();

  return reports.map((report) => ({
    params: { slug: report.id },
    props: { report },
  }));
}

const { report } = Astro.props;
const { Content } = await render(report);
---

<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{report.data.title}</title>
    <meta name="description" content={report.data.summary} />
  </head>
  <body>
    <main>
      <article>
        <h1>{report.data.title}</h1>
        <time datetime={report.data.publishedAt.toISOString()}>
          {report.data.period}
        </time>
        <Content />
      </article>
    </main>
  </body>
</html>

生产构建时,草稿不会生成静态路径,因此即使有人猜到文件名也无法访问对应页面。需要注意的是,列表页、RSS 和站点地图也应该调用同一个读取函数。

把校验接入 GitHub Actions

AI 流水线最好不要直接向生产分支写入并部署。更稳妥的流程是:生成 Markdown、创建 Pull Request、执行构建校验,最后由人工或受控规则把 draft 改为 false

Astro 在加载集合时会执行 schema 校验;而 [slug].astrogetStaticPaths() 会调用跨文件断言。因此一次正式构建即可覆盖字段和集合级规则。

name: Validate reports

on:
  pull_request:
    paths:
      - "src/content/reports/**"
      - "src/content.config.ts"
      - "src/lib/reports.ts"
      - "src/pages/reports/**"
      - "package-lock.json"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build Astro site
        run: npm run build

将该工作流设为分支保护规则中的必需检查后,字段缺失、URL 格式错误、日期非法和周期重复都会阻止合并。CI 通过只说明内容满足机器规则,并不代表事实正确;来源可信度、摘要是否歪曲原文,仍需要编辑审核。

实际排错时,应优先保留 Astro 和 Zod 输出的原始错误。不要在生成脚本中捕获异常后仍返回成功,否则 GitHub Actions 会显示绿灯,校验也就失去了意义。

总结

AI 日更流水线的重点不是让模型永远不犯错,而是让错误无法静默进入生产环境。本文的几个关键点是:

  • 用 Content Collections 和 Zod 把标题、周期、来源及生成元数据定义成明确契约;
  • draft 默认设为 true,生产环境统一过滤草稿;
  • 通过集中读取函数检查重复周期,并统一排序和发布规则;
  • 在 GitHub Actions 中执行真实的 Astro 构建,把校验设为合并前置条件;
  • 保留人工审核,机器校验负责结构和一致性,不替代事实核查。

这套方案没有依赖复杂的内容平台,Markdown 仍然是最终产物。生成器可以持续演进,但只要 schema、发布状态和构建检查保持稳定,自动化内容就不容易把生产站点一起拖垮。