AI 文章摘要

2045 字
10 分钟
AI 文章摘要
文章摘要

方案说明#

常见的 AI 摘要做法是在文章页前端实时调用 API 生成,但这样会暴露密钥,也依赖部署平台的环境变量。本方案改为 本地预生成 + 缓存提交

  • 本地运行脚本读取所有文章,调用 AI API 生成摘要,写入 src/constants/ai-summaries.json
  • 把缓存 JSON 随仓库一起提交
  • 构建时文章页直接读缓存,不调 API、不暴露密钥
  • 摘要文本通过打字机逐字播放

密钥只存在本地 .env,构建环境完全不需要任何 AI 相关的环境变量。

配置 .env#

文件路径:.env

Terminal window
# AI 摘要生成配置(仅本地生成时使用,不需要提交,也不需要配到部署平台)
AI_SUMMARY_API_KEY=sk-xxxxxxxxxxxxxxxx
AI_SUMMARY_API_ENDPOINT=https://api.deepseek.com
AI_SUMMARY_MODEL=deepseek-chat

.env 已在 .gitignore 中,不会被提交。AI_SUMMARY_API_ENDPOINTAI_SUMMARY_MODEL 有默认值(DeepSeek),只配 AI_SUMMARY_API_KEY 也能用。

编写生成脚本#

文件路径:scripts/generate-ai-summaries.ts

脚本逻辑:读取 .env → 遍历文章 → 以正文 hash 为指纹做增量(未变化的跳过)→ 调 API → 写入缓存。

import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { glob } from "glob";
const BLOG_DIR = "src/content/blog";
const OUTPUT_FILE = "src/constants/ai-summaries.json";
// 正文截断长度,避免超长文章浪费 token
const MAX_CONTENT_LENGTH = 6000;
// 轻量解析 .env,避免依赖 node --env-file(npx tsx 下不一定生效),也不引入 dotenv
function loadEnv(file = ".env") {
if (!fs.existsSync(file)) return;
const raw = fs.readFileSync(file, "utf-8");
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
loadEnv();
const API_KEY = process.env.AI_SUMMARY_API_KEY;
const API_ENDPOINT = process.env.AI_SUMMARY_API_ENDPOINT || "https://api.deepseek.com";
const API_MODEL = process.env.AI_SUMMARY_MODEL || "deepseek-chat";
const API_PATH = process.env.AI_SUMMARY_API_PATH || "/v1/chat/completions";

system prompt 强约束输出,禁止以「内容不足」为由拒绝生成:

const SYSTEM_PROMPT =
"你是一个文章摘要助手。请根据给出的标题、描述和正文,用 120-180 个中文字符总结文章核心内容,语言与原文保持一致。写成 2-3 句自然短句,覆盖文章主题、关键做法和读者能获得的价值。即使正文较短或以代码/步骤为主,也必须结合标题与描述给出合理摘要,绝不能输出“无法生成/内容不足/未包含正文”之类的拒绝性语句。只输出摘要正文,不要标题、前缀、列表或解释。";

缓存键与 entry.id 对齐#

这是最关键的一步。缓存键必须和 Astro glob loader 的 entry.id 完全一致,否则文章页查不到摘要:

  • frontmatter 有自定义 slug 时,entry.id 就是该 slug
  • 否则是相对 blog 目录的路径去掉扩展名
// 生成缓存键,与 Astro glob loader 的 entry.id 对齐
function postToKey(file: string, slug: string): string {
if (slug) {
// 去掉首尾斜杠:frontmatter 写 "/AISummary",统一成 "AISummary"
return slug.replace(/^\/+/, "").replace(/\/+$/, "");
}
const rel = path.relative(BLOG_DIR, file).replace(/\\/g, "/");
return rel.replace(/\.[^/.]+$/, "");
}

请求摘要#

async function requestSummary(content: string): Promise<string> {
const res = await fetch(API_ENDPOINT + API_PATH, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: API_MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content },
],
stream: false,
}),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`API 请求失败 (${res.status}) ${detail.slice(0, 200)}`);
}
const json = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const summary = json.choices?.[0]?.message?.content?.trim();
if (!summary) {
throw new Error("API 返回内容为空");
}
return summary;
}

主流程以 hash 做增量:hash 未变直接复用旧摘要,只为新增或改动过的文章调 API,并清理已删除文章的缓存。完整脚本可参考仓库源码。

注册命令#

文件路径:package.json

"scripts": {
"ai-summaries": "npx tsx scripts/generate-ai-summaries.ts"
}

运行 pnpm ai-summaries 即可生成或更新摘要缓存。

添加类型定义#

文件路径:src/types/config.ts

// AI 文章摘要配置
// 摘要由本地脚本预生成并缓存在 src/constants/ai-summaries.json,
// API 密钥等配置在 .env 中,此处仅控制是否展示摘要卡片。
aiSummary?: {
enable: boolean; // 是否启用 AI 摘要功能
};

添加配置项#

文件路径:src/config/siteConfig.ts

// 摘要由本地脚本预生成并缓存在 src/constants/ai-summaries.json,
// 运行 `pnpm ai-summaries` 生成(API 密钥等配置在 .env 中:AI_SUMMARY_API_KEY / AI_SUMMARY_API_ENDPOINT / AI_SUMMARY_MODEL)。
// 此处仅控制是否在文章页展示摘要卡片。
aiSummary: {
enable: true,
},

摘要展示组件#

文件路径:src/components/features/AISummary.astro

组件只负责接收摘要文本并渲染卡片,不调 API、不暴露密钥。打字机脚本不放在这里(原因见末尾的踩坑记录)。

---
// AI 文章摘要组件
// 摘要由本地脚本预生成并缓存在 src/constants/ai-summaries.json,
// 文章页按 entry.id 取出摘要文本通过 summary prop 传入。
import { siteConfig } from "@/config/siteConfig";
interface Props {
summary?: string;
}
const { summary } = Astro.props;
// 未启用或无摘要时不渲染卡片
if (!siteConfig.aiSummary?.enable || !summary?.trim()) {
return;
}
---
<div id="ai-summary" class="onload-animation" data-summary={summary.trim()}>
<div class="ai-summary-header">
<div class="ai-summary-icon">
<!-- 魔法笔图标 -->
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<path fill="currentColor" d="M7.5 5.6L5.6 7.5L8 9.9L9.9 8L7.5 5.6M14.4 3L13.2 5.6L10.6 6.8L13.2 8L14.4 10.6L15.6 8L18.2 6.8L15.6 5.6L14.4 3M19 9.8L17.9 12.1L15.6 13.2L17.9 14.3L19 16.6L20.1 14.3L22.4 13.2L20.1 12.1L19 9.8M4.2 18.4L13.8 8.8L15.2 10.2L5.6 19.8C5.2 20.2 4.6 20.2 4.2 19.8C3.8 19.4 3.8 18.8 4.2 18.4Z"/>
</svg>
</div>
<div class="ai-summary-title">文章摘要</div>
</div>
<div id="ai-summary-content" class="ai-summary-content">
<span class="ai-summary-cursor"></span>
</div>
</div>

样式部分(玻璃质感卡片、光标闪烁、暗色适配)可参考仓库源码,这里省略。

在文章页接入#

文件路径:src/pages/blog/[...slug].astro

导入缓存和组件:

import AISummary from "@/components/features/AISummary.astro";
import aiSummaries from "@/constants/ai-summaries.json";

entry.id 取出摘要。注意:查找时要对 entry.id 做和缓存键一致的归一化(去首尾斜杠),否则带 slug 的文章会因为前导斜杠匹配不上:

// 从本地预生成的缓存中取出 AI 摘要(无则为 undefined,组件不渲染)
// entry.id 在有 slug 时会带前导斜杠(如 "/AISummary"),需归一化后再查
const aiSummaryKey = entry.id.replace(/^\/+/, "").replace(/\/+$/, "");
const aiSummaryText = (aiSummaries as Record<string, { summary: string }>)[
aiSummaryKey
]?.summary;

在正文上方渲染组件:

<AISummary summary={aiSummaryText} />
<Markdown class="mb-6 markdown-content onload-animation">
<Content />
</Markdown>

全局打字机脚本#

文件路径:src/layouts/Layout.astro

打字机脚本放在全局 Layout,而不是组件内部(原因见末尾踩坑记录)。在文件末尾添加:

<!-- AI 摘要打字机:脚本放在全局 Layout,保证 Swup 翻页(非整页刷新)也能初始化 -->
<script>
// 模块级 timer,使翻页时能真正取消上一次未完成的打字动画
let aiSummaryTimer: ReturnType<typeof setTimeout> | undefined;
function initAiSummary() {
const container = document.getElementById("ai-summary");
if (!container) return;
const text = container.dataset.summary;
if (!text) return;
const target = document.getElementById("ai-summary-content");
if (!target) return;
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// 取消上一次可能还在进行的打字,避免翻页后两次动画叠加
if (aiSummaryTimer) clearTimeout(aiSummaryTimer);
const full = text;
let i = 0;
function typeNext() {
i++;
if (i <= full.length) {
target.innerHTML =
escapeHtml(full.slice(0, i)) +
'<span class="ai-summary-cursor"></span>';
// 标点处稍作停顿,更接近自然书写节奏
const ch = full[i - 1];
const delay = /[,。!?、;,.!?]/.test(ch) ? 90 : 28;
aiSummaryTimer = setTimeout(typeNext, delay);
} else {
target.innerHTML = escapeHtml(full);
container?.classList.add("fade-in");
}
}
typeNext();
}
window.addEventListener("load", initAiSummary);
document.addEventListener("astro:page-load", initAiSummary);
document.addEventListener("swup:contentReplaced", () => {
requestAnimationFrame(initAiSummary);
});
</script>

使用流程#

以后每写一篇新文章:

Terminal window
# 1. 生成 / 更新摘要缓存(增量,未变化的文章会跳过)
pnpm ai-summaries
# 2. 提交文章和缓存
git add src/content/blog/ src/constants/ai-summaries.json
# 3. 推送,构建时直接读缓存,不调 API
git push

踩坑记录#

实现过程中有两个不明显的坑,单独记一下。

entry.id 会保留 frontmatter slug 的前导斜杠#

frontmatter 写 slug: /AI文章摘要,Astro glob loader 的 entry.id 实际是 /AISummary(带斜杠),而输出目录是干净的 AISummary。如果缓存键用了不带斜杠的形式,aiSummaries[entry.id] 就永远查不到,摘要不显示且没有任何报错。

解决:生成键和查找时都做 replace(/^\/+/, "").replace(/\/+$/, "") 归一化,两边对齐。

组件内 script 在 Swup 翻页时不执行#

Astro 组件级 <script> 只会打包进「渲染了该组件的页面」。AI 摘要组件只在文章页出现,首页没有它,所以首页不加载这段脚本。而 Swup 翻页只替换容器内容、不执行新页面里的 script 标签,于是从首页点进文章时打字机从未初始化,表现为光标一直闪、刷新后才正常。

解决:把打字机脚本移到始终渲染的 Layout.astro 全局脚本,并监听 astro:page-loadswup:contentReplaced,覆盖首次加载和翻页两种场景。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

AI 文章摘要
https://astro.cchaoka.cn/blog/AI文章摘要/
作者
像风
发布于
2026-06-11
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
像风
Hello, I'm 像风.
📢 欢迎来访者
👋🏻 Hi,我是像风,欢迎您!
分类
标签
站点统计
文章
12
分类
2
标签
3
总字数
5,056
运行时长
0
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.14.3
文章许可
CC BY-NC-SA 4.0
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
✨ 今日一言
"做你自己,别人已经有人做了。"
—— 奥斯卡·王尔德
统计
✨️ 复制成功,转载请标注本文地址