最近浏览技术博客时,发现像CSDN都会有 **AI 摘要 ** 功能,觉得还算是一个实用的功能,所以作为一个热衷于折腾 Hexo 的博主,我也决定动手给自己的博客加上这个功能。
本以为写个脚本调用一下 API 是件很简单的事,结果实际操作中踩了不少坑:API Key 泄露风险、YAML 格式解析报错、长文章摘要不准确…
这篇文章将详细复盘我从“原始版本”到“终极通用版本”的开发全过程,并附上完整的代码和配置教程。
准备工作
在开始写代码之前,我们需要准备好环境和工具。
1. 申请 API
考虑到成本和中文理解能力,我选择了 **DeepSeek (深度求索)**。它的 API 完全兼容 OpenAI 格式,价格极低(写这篇博客时大约是 GPT-4 的百分之一),且拥有 64K 的超大上下文窗口,非常适合用来读长篇博客。
- 去 DeepSeek 开放平台 申请 API Key。
2. 安装依赖
我们需要在 Hexo 根目录下安装几个 Node.js 库:
# 1. OpenAI SDK (用于调用大模型)
# 2. dotenv (用于安全读取环境变量)
# 3. gray-matter (用于解析和生成 Markdown 的 Front-matter)
npm install openai dotenv gray-matter
具体实现
1.创建自动化脚本
在博客根目录的 scripts 文件夹下(如果没有就新建一个 scripts 文件夹),新建一个文件 ai_summary.js。
复制以下代码进去:
/**
* Hexo AI Summary Generator
* 使用方法: hexo ai
*/
const { OpenAI } = require("openai");
const fs = require("fs");
const path = require("path");
const matter = require("gray-matter"); // Hexo 自带了 gray-matter,无需额外安装
// ================= 配置区域 =================
const API_KEY = "sk-xxxxxxxxxxxxxxxxxxxxxxxx"; // 🔴 替换成你的 API Key
const API_BASE_URL = "https://api.deepseek.com"; // 如果用 OpenAI 则不用改,用 DeepSeek 请填这个
const MODEL_NAME = "deepseek-chat"; // 模型名称
// ===========================================
const client = new OpenAI({
apiKey: API_KEY,
baseURL: API_BASE_URL,
});
hexo.extend.console.register("ai", "自动生成 AI 摘要", async function (args) {
const posts = hexo.locals.get("posts").data;
const log = hexo.log;
log.info("🤖 正在启动 AI 摘要生成助手...");
for (let i = 0; i < posts.length; i++) {
const post = posts[i];
const fullPath = post.full_source;
// 1. 读取文件内容
const fileContent = fs.readFileSync(fullPath, "utf8");
const parsed = matter(fileContent);
// 2. 检查是否已经存在 ai_summary
if (parsed.data.ai_summary) {
// log.info(`[跳过] 已存在摘要: ${post.title}`);
continue;
}
log.info(`[处理中] 正在为文章生成摘要: ${post.title}`);
// 3. 提取纯文本用于发送给 AI (去掉 Markdown 符号,截取前 1000 字以节省 Token)
const cleanContent = post.content.replace(/<[^>]+>/g, "").slice(0, 1000);
try {
// 4. 调用 API
const completion = await client.chat.completions.create({
messages: [
{
role: "system",
content: "你是一个博客摘要助手。请用一段通俗易懂的中文总结以下文章的核心内容,字数控制在 80-120 字之间,不要使用markdown格式,不要有多余的废话。",
},
{ role: "user", content: cleanContent },
],
model: MODEL_NAME,
});
const summary = completion.choices[0].message.content.trim();
// 5. 将摘要写入 Front-matter
// 我们使用字符串替换的方式,避免重组导致格式混乱
const newContent = fileContent.replace(
/^---\n([\s\S]+?)\n---/,
`---\n$1\nai_summary: ${summary}\n---`
);
fs.writeFileSync(fullPath, newContent, "utf8");
log.info(`✅ [成功] 摘要已写入: ${post.title}`);
} catch (error) {
log.error(`❌ [失败] ${post.title}: ${error.message}`);
}
}
log.info("🎉 所有文章处理完毕!请检查 md 文件。");
});
2.运行生成
在终端运行:
hexo ai
会看到脚本开始逐个处理没有摘要的文章,并自动修改 source/_posts/ 下的 .md 文件,在头部增加 ai_summary: 你的摘要内容。
3.展示在页面上
你需要修改你的 主题文件 来把这个摘要显示出来。 找到你的主题文件夹下的文章模板,通常在 themes/你的主题/layout/_partial/post/ 目录下,文件名可能是 intro.ejs 或 article.ejs。
以 Hexo-Matery 或常见主题为例,通常修改 themes/主题名/layout/_partial/post-detail.ejs 文件。
在 <%- page.content %> 之前插入以下代码:
<% if (page.ai_summary) { %>
<style>
.ai-summary-card {
background: #f6f8fa;
border-left: 4px solid #3eaf7c;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
font-size: 15px;
color: #555;
line-height: 1.6;
}
.ai-title {
font-weight: bold;
color: #2c3e50;
margin-bottom: 8px;
display: flex;
align-items: center;
}
</style>
<div class="ai-summary-card">
<div class="ai-title">🤖 AI 摘要</div>
<div><%= page.ai_summary %></div>
</div>
<% } %>
完成!
以后写完新文章:
hexo ai(生成摘要)hexo s(本地预览)hexo g -d(部署上线)
改进
V1.0:原始版本
最初的想法很简单:读取文件 -> 调用 API -> 把摘要替换进文件头。
原始代码片段:
// scripts/ai_summary.js (V1)
const API_KEY = "sk-xxxxxxxx"; // <--- 致命错误:直接硬编码 Key
// ...
const cleanContent = post.content.replace(/<[^>]+>/g, "").slice(0, 1000); // <--- 问题:只截取了前1000字
// ...
// 简单的正则替换
const newContent = fileContent.replace(/^---\n([\s\S]+?)\n---/, `---\n$1\nai_summary: ${summary}\n---`);
问题:
- 安全隐患:如果我把这个文件 push 到 GitHub,API Key 就直接泄露了。
- 内容截断:
slice(0, 1000)导致对于长篇技术文章,AI 只能读到前言,生成的摘要完全抓不住重点。 - 格式脆弱:简单的正则替换容易破坏原本复杂的 Front-matter 结构。
V2.0:安全性与稳定性的修复
针对 V1 的问题,我进行了第一次重构。
1. 解决 Key 泄露 (配置环境变量)
在博客根目录新建 .env 文件:
AI_API_KEY=sk-your-actual-api-key-here
⚠️ 极其重要: 修改 .gitignore 文件,添加一行:
.env
这样 Git 就会忽略这个文件,确保 Key 永远保存在本地。
2. 解决 YAML 解析崩溃
在运行脚本时,我遇到了 YAMLException: can not read a block mapping entry 报错。原因是部分老文章的 Front-matter 里使用了 Tab 键 缩进,而标准的 YAML 只允许空格。
✅ 解决方案:
使用 gray-matter 库的 stringify 方法。它不仅能解析,还能把对象重新组装成标准的 YAML 格式字符串,自动修复缩进问题。
V3.0:上下文扩容与大纲提取
在查看AI生成的摘要时,我发现 AI 的总结很局限,基本只是根据文章的前一小部分文字进行总结,而不是完整的文章,导致总结出来的内容很局限;另外,像LeetCode刷题记录那篇文章,因为题目数量众多,上下文很长,所以很难将完整的文章喂给大模型。
✅ 优化策略:
- 扩大窗口:DeepSeek 支持 64K Context,我将截取限制从 1k 提升到 50k 字符。
- 提取大纲:用正则抓取所有的
## 标题, 利用正则match(/^#{1,4} .+/gm)提取文章的层级目录(Outline),在 Prompt 中先告诉 AI 这篇文章的结构。 - 过滤代码:刷题文章代码量巨大且消耗 Token,使用
replace将代码块 ```…``` 替换为占位符[代码块],让 AI 专注阅读解题思路。
V4.0:增量更新与全量更新
增量更新指的是当我们新写了一篇文章的时候只需要生成这一篇文章的摘要,而不需要将之前生成过的文章摘要再重新生成一遍,这个功能主要是通过检查是否已经存在ai_summary去实现。
// 2. 检查是否已经存在 ai_summary
if (parsed.data.ai_summary) {
// log.warn(`⚠️ [跳过] ...`);
continue; // <--- 关键在这里!
}
这段代码的意思是:
- 脚本启动后,会扫描所有文章。在处理每一篇文章前,它会先检查 Markdown 文件的头部(Front-matter)里有没有
ai_summary这个字段。 - 如果已经有了: 直接执行
continue,跳过当前循环,不调用 API,不扣费,直接看下一篇。 - 如果没有(比如你刚写的新文章): 才会继续往下走,调用 DeepSeek API 生成摘要并写入。
但是在一些场景下还是需要全量更新,例如生成效果不好希望重新生成,所以我又加了一个全量更新的功能。
支持 -f 参数:如果不加 -f,它就是增量更新(省钱);加了 -f,它会无视是否存在摘要,强制把所有文章重新“读”一遍并生成新的摘要。
终版
结合了以上所有经验,这是最终的脚本。把它保存为 scripts/ai_summary.js。
功能亮点:
- 支持增量更新 (默认) 和 全量强制更新 (hexo ai -f)
- 智能大纲:自动提取 Markdown 标题,辅助 AI 理解文章结构
- 超大上下文:支持 5万字符的长文读取
- 通用化:既适合 LeetCode 刷题集,也适合普通技术/生活文章
- 格式修复:自动标准化 YAML 头部的缩进问题
/**
* Hexo AI Summary Generator (Universal Pro)
* * 功能亮点:
* 1. 支持增量更新 (默认) 和 全量强制更新 (hexo ai -f)
* 2. 智能大纲:自动提取 Markdown 标题,辅助 AI 理解文章结构
* 3. 超大上下文:支持 5万字符的长文读取
* 4. 通用化:既适合 LeetCode 刷题集,也适合普通技术/生活文章
* 5. 格式修复:自动标准化 YAML 头部的缩进问题
*/
const { OpenAI } = require("openai");
const fs = require("fs");
const path = require("path");
const matter = require("gray-matter");
require('dotenv').config();
// ================= 配置区域 =================
const API_KEY = process.env.AI_API_KEY;
const API_BASE_URL = "https://api.deepseek.com";
const MODEL_NAME = "deepseek-chat";
// ===========================================
if (!API_KEY) {
console.error("❌ 错误: 未找到 API Key。请在根目录 .env 文件中配置 AI_API_KEY。");
process.exit(1);
}
const client = new OpenAI({
apiKey: API_KEY,
baseURL: API_BASE_URL,
});
hexo.extend.console.register("ai", "自动生成 AI 摘要", {
options: [
{ name: '-f, --force', desc: 'Force regenerate all summaries' } // 👈 全量更新开关在这里
]
}, async function (args) {
const log = this.log;
// 获取命令行参数,判断是否开启强制模式
const isForceMode = args.f || args.force;
log.info("📚 正在加载文章数据...");
await this.load();
const posts = this.locals.get("posts").toArray();
if (posts.length === 0) {
log.warn("⚠️ 没有找到任何文章。");
return;
}
log.info(`🤖 AI 摘要助手启动 | 模式: ${isForceMode ? '🔥 强制全量更新 (覆盖旧摘要)' : '⚡ 增量更新 (跳过已生成)'}`);
let count = 0;
let skipped = 0;
let failed = 0;
for (const post of posts) {
const fullPath = post.full_source;
let fileContent, parsed;
try {
fileContent = fs.readFileSync(fullPath, "utf8");
parsed = matter(fileContent);
} catch (e) {
log.warn(`⚠️ [解析失败] 跳过: ${post.title}`);
failed++;
continue;
}
// === 逻辑判断 ===
// 只有在 "非强制模式" 且 "摘要已存在" 时才跳过
if (!isForceMode && parsed.data.ai_summary) {
skipped++;
continue;
}
log.info(`[分析中] ${post.title}...`);
// === 1. 提取大纲 (标题) ===
const headings = parsed.content.match(/^#{1,4} .+/gm) || [];
const outline = headings.length > 0 ? headings.join("\n") : "(无显式标题结构)";
// === 2. 内容清洗 ===
let processingContent = parsed.content;
// 替换代码块为占位符,减少 Token 消耗,让 AI 专注逻辑
processingContent = processingContent.replace(/```[\s\S]*?```/g, "\n[此处为代码块]\n");
// 去除 HTML
processingContent = processingContent.replace(/<[^>]+>/g, "");
// 压缩空行
processingContent = processingContent.replace(/[\r\n]+/g, "\n").trim();
const cleanContent = processingContent.slice(0, 50000);
// === 3. 组合 Prompt (通用版) ===
const finalUserContent = `
文章标题: ${post.title}
文章大纲结构:
${outline}
文章正文内容:
${cleanContent}
`.trim();
try {
const completion = await client.chat.completions.create({
messages: [
{
role: "system",
content: "你是一个专业的博客摘要助手。请根据提供的【文章大纲】和【正文内容】,生成一段通俗流畅的中文摘要。\n\n要求:\n1. 如果文章是技术/刷题类,请概括核心算法或解决的问题。\n2. 如果文章是感悟/杂谈类,请总结作者的核心观点。\n3. 字数控制在 80-150 字。\n4. 直接输出纯文本,不要使用Markdown格式,不要包含代码。",
},
{ role: "user", content: finalUserContent },
],
model: MODEL_NAME,
});
const summary = completion.choices[0].message.content.trim();
// === 4. 回写文件 ===
parsed.data.ai_summary = summary;
const newFileContent = matter.stringify(parsed.content, parsed.data);
fs.writeFileSync(fullPath, newFileContent, "utf8");
log.info(`✅ [已更新] ${post.title}`);
count++;
} catch (error) {
log.error(`❌ [API 错误] ${post.title}: ${error.message}`);
failed++;
}
}
log.info(`🎉 任务结束! 更新: ${count} | 跳过: ${skipped} | 失败: ${failed}`);
});
总结
现在,我的博客可以很简单的实现AI摘要功能:
- 写完新文章:在终端运行
hexo ai,脚本会自动检测新文章,几秒钟生成摘要。 - 想要刷新旧文章:运行
hexo ai -f,全量重新生成。 - 部署:
hexo g -d。
这个折腾过程虽然曲折,但最终不仅实现了一个实用的功能,还顺手把博客里历史遗留的 YAML 格式问题全修好了。如果你也是 Hexo 用户,强烈推荐尝试!