AI 可以快速生成日报,但它也可能输出缺字段、错误日期和不可用链接。本文用 Astro Content Collections 把内容约束前移到构建阶段,并通过默认草稿、统一过滤与 CI 校验,避免自动生成内容直接影响生产站点。
自动生成内容为什么需要硬边界
本站的 AI 日报和周报本质上都是 Markdown 文件:生成器收集信息、整理摘要,然后写入仓库。它们看似只是内容,实际上会参与路由生成、列表排序和页面渲染。一旦字段格式漂移,构建就可能失败;更隐蔽的问题则会在上线后出现,例如同一期报告重复、草稿被索引,或者日期字符串导致排序异常。
常见风险可以分成三层:
| 层次 | 典型问题 | 处理位置 |
|---|---|---|
| 单篇字段 | 缺少标题、日期无效、来源不是 URL | Zod 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-Www;publishedAt 在 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].astro 的 getStaticPaths() 会调用跨文件断言。因此一次正式构建即可覆盖字段和集合级规则。
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、发布状态和构建检查保持稳定,自动化内容就不容易把生产站点一起拖垮。
评论