前段时间看到 @LiuShen 发表了一篇博客文章,我不禁感慨:继对友链朋友圈改进后,柳神终于盯上 AI 摘要的改进了!在 Liushen 文章中提到了 @konoXIN 通过 Vercel 函数调用大模型的方法,对此我也受到启发,打算动手尝试自己的 AI 摘要解决方案。

目前,我了解到的静态博客 AI 摘要大概有以下几种:

方案 作者 前端 后端 参考链接
TianliGPT @Tianli 引入 js 文件/插件 第三方后端管理 博客文章
插件 hexo-ai-summary @LiuShen 插件 + 修改主题源码 无(本地提前生成好摘要) 博客文章
Vercel 代理方案 @konoXIN 引入 js 文件 Vercel 接口转发 博客文章

TianliGPT 方案是本站最初选择的方案,有专门的后端管理,内容合规、模型选择啥的基本不用操心。现在的 TianliGPT 在多款主题下也开发有多款插件,前端部署不是问题。要想省心省力,选择 TianliGPT 就行。

hexo-ai-summary 是大佬 Liushen 提供的解决方案。这款方案通过提前生成博客摘要嵌入到每篇文章中,读者访问文章就能访问摘要,没有额外请求时延且不用担心另外的系统故障,此外可以灵活替换使用的大语言模型。此方案是速度与灵活性双重兼顾方案,要想文章摘要以最快的速度获取,选择 hexo-ai-summary 准没错。(我还没用过这个插件不知道我说得对不对,欢迎指正~)

konoXIN 也实现了自己的 AI 摘要方案,用户访问文章时携带文章内容发送至 Vercel,Vercel 充当代理作用调用 LLM 接口,并将最终的摘要数据返回给用户。这种方案朴素、直观且有效,给想动 (zhe) 手 (teng) 的人更大的发挥空间。

本文主要参考的是 konoXIN 的文章,并在原方案的基础上作出了改进。但总的来说不会有前面的方案那么完美,只能说是个人试验的小作品。当然也希望各位读者分享你们的方案与建议,期待和各位大佬探讨交流 ~

方案概述

相较于 konoXIN 的方案,本方案改进点如下:

  • 样式调整
    • 样式文件中,像素 px 统一使用 rem 单位
    • Hexo-Butterfly 主题夜间模式适配
    • 多一个「关于」按钮
  • 逻辑改进
    • 重构前后端代码,增加代码可读性
    • 优化前端请求方式,减少不必要的参数携带
    • 在原方案本地缓存的基础上,Vercel 代理端再加一层 Redis 缓存摘要结果
    • 优化提示词模板

本方案缺点:

  • 模块依赖相较于其他方案复杂,存在一定的耦合性
  • 模型的替换还需要额外的代码重构
  • 仅适合个人学习参考使用
  • API 插件提供的信息过多,存在冗余或安全风险

交互时序图(Sequence Diagram)如下:

sequenceDiagram
    actor U as 用户
    participant F as 前端
    participant V as Vercel
    participant R as Redis
    participant L as 大模型
    participant B as 博客 API 服务
    activate U
    U ->> F : 文章链接
    activate F
    F ->> F : 查询本地缓存
    activate F
    deactivate F
    alt 本地无缓存
        F ->> V : 文章链接
        activate V
        V ->> R : 查询缓存
        activate R
        R -->> V : 查询结果
        deactivate R
        alt 服务端无缓存            
            V ->> B : 访问API接口获取全文
            activate B
            B -->> V : 文章数据
            deactivate B
            
            V ->> V : 数据清洗
            activate V
            deactivate V
            V ->> L : 提示词与清洗后的数据
            activate L
            L -->> V : 文章摘要
            deactivate L
            V ->> R : 缓存
            activate R
            deactivate R   
        end
        V -->> F : 文章摘要
        deactivate V
        F ->> F : 本地缓存
        activate F
        deactivate F
    end
    F -->> U: 文章摘要
    deactivate F
    deactivate U

缓存方案概述:用户访问文章时,先找本地浏览器缓存,本地缓存不命中则发送请求至 Vercel。Vercel 接受请求后,先从 Redis 找缓存,Redis 不命中才向大模型服务获取文章摘要。所有缓存的时间均为 1 周。

前端:文件导入

本文所有代码文件均附于文末

按照自己主题的导入方式引入以下文件:

  • ai-summary.js
  • ai-summary.css

hexo-butterfly 主题可在 _config.butterfly.yml 中配置。

后端:Vercel 提供的函数服务

本文所有代码文件均附于文末

我们使用 Vercel 函数服务代理文章摘要请求。官方文档:Vercel Functions.

建一个 GitHub 仓,里面编写代理逻辑以及 Redis 调用逻辑,然后通过 Vercel 导入这个项目。我的仓库目录如下:

image.png

文件说明:

  • spark-lite.js:编写代理逻辑。放在 /api/ai-summary 下。访问时可通过:https://your-custom-domain/api/ai-summary/spark-lite 进行访问。
  • redis.js:用于使用 Redis 服务。

如代码有更新,git push 到代码仓后,Vercel 会自动重新部署。Vercel 自定义域名可自行配置。

注意:使用 Vercel 函数服务,相关 JavaScript 文件必须放置在名为 api 的文件夹中才能生效。

讯飞星火 Spark Lite

讯飞星火 侧申请自己的大模型应用,并申请 Spark Lite 的无限 Token。记下 APPID、API_SECRET、API_KEY、API_PASSWORD 关键信息。

image.png

打开 Vercel,创建对应的环境变量供后续使用:

image.png

使用 Vercel 中的 Redis 集成服务

Redis 服务可以自己另外部署,且还能有更高的灵活性。本文介绍的是 Vercel 中的 Redis 集成组件。

在 Vercel 中,可以使用 Redis 集成组件:

image.png

选择 Redis:

image.png

Vercel 免费用户(Hobby 计划)可以建一个 30MB 的,假设每篇摘要 200 字左右,这些空间可以应付上万篇文章摘要的需求,对于个人博客开发者来说完全够用。

将 Redis 关联到 Vercel 对应的项目中,这样项目中就会多出一条环境变量——Redis 服务的地址。

image.png

如果详看环境变量信息,除了在 Vercel 网站上看之外,还可以直接下载到代码仓中:

1
2
npm i -g vercel # 如果没安装 vercel CLI
vercel env pull .env.development.local # 拉取环境变量信息

使用 Redis 服务是记得安装好相应的包:npm install redis。并将生成的 package.json 以及 package-lock.json 一并加入到代码仓的版本管理中。

安装 API 插件

插件是在 Hexo 插件列表找的,基本符合我的要求。安装这个插件并根据自己的情况配置即可:wherewhere/hexo-generator-apis: Generate restful json data for Hexo plugins

通过 /api/posts/{path}.json 请求可以得到文章元信息,里面有文章全文数据。Hexo 博客部署后,你可以尝试一下调用这个 API。

代码文件

ai-summary.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
// 定义样式常量 - 便于集中管理和修改
const LEFT_STYLE = "color: #fadfa3; background: #030307; padding:5px 0;";
const RIGHT_STYLE = "background: #fadfa3; padding:5px 0;";

// 构建消息模板 - 使用多行模板字符串保持视觉结构
const MESSAGE_TEMPLATE = `
%c Spark Lite 文章摘要AI生成 %c https://uuanqin.top/
`;

const PROXY_API_URL = "https://ai-summary.uuanqin.top/api/ai-summary/spark-lite"; // 这里填的是 Vercel 的地址
const LINK_AI_ABOUT = "https://blog.uuanqin.top/p/75efe9f3/"

// 输出格式化控制台消息
console.log(
MESSAGE_TEMPLATE,
LEFT_STYLE,
RIGHT_STYLE
);

// --- 其他配置 (根据需要调整) ---
const sparkLite_postSelector = "#article-container"; // 文章内容容器的选择器,例如 #article-container, .post-content
const sparkLite_wordLimit = 1000; // 提交给 API 的最大字数限制
const sparkLite_typingAnimate = true; // 是否启用打字机效果
// 指定博客文章URL类型,只在这样的界面上生成ai摘要
// 通配符写法
const sparkLite_postURLs = [
// "https://*.uuanqin.top/p/*",
// "http://localhost:*/p/*"
];
// 正则写法
const sparkLite_postURLs_regex = [
/^https:\/\/.*\.uuanqin\.top\/p\/[0-9a-fA-F]+\/$/,
/^http:\/\/localhost:4000\/p\/[0-9a-fA-F]+\/$/
];

const MILLISECONDS_OF_A_WEEK = 7 * 24 * 60 * 60 * 1000;

const sparkLite_localCacheTime = MILLISECONDS_OF_A_WEEK;

const initDB = () => {
return new Promise((resolve, reject) => {
const request = indexedDB.open('SparkLiteDB', 1);

request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('summaries')) {
const store = db.createObjectStore('summaries', {keyPath: 'url'});
store.createIndex('timestamp', 'timestamp', {unique: false});
}
};

request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
};

let sparkLiteIsRunning = false; // 重命名

// --- insertAIDiv 函数 ---
function insertAIDiv(selector) {
// 首先移除现有的 "post-SparkLite" 类元素(如果有的话)
removeExistingAIDiv(); // 需要同步修改 removeExistingAIDiv 函数选择器

// 获取目标元素
const targetElement = document.querySelector(selector);

// 如果没有找到目标元素,不执行任何操作
if (!targetElement) {
return;
}

// 创建要插入的HTML元素
const aiDiv = document.createElement('div');
aiDiv.className = 'post-SparkLite'; // 修改类名

const aiTitleDiv = document.createElement('div');
aiTitleDiv.className = 'sparkLite-title'; // 修改类名
aiDiv.appendChild(aiTitleDiv);

const aiIcon = document.createElement('i');
aiIcon.className = 'sparkLite-title-icon'; // 修改类名
aiTitleDiv.appendChild(aiIcon);

// 插入 SVG 图标 (保持不变或替换)
aiIcon.innerHTML = `<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='48px' height='48px' viewBox='0 0 48 48'>
<title>机器人</title>
<g id='机器人' stroke='none' stroke-width='1' fill='none' fill-rule='evenodd'>
<path d='M34.717885,5.03561087 C36.12744,5.27055371 37.079755,6.60373651 36.84481,8.0132786 L35.7944,14.3153359 L38.375,14.3153359 C43.138415,14.3153359 47,18.1768855 47,22.9402569 L47,34.4401516 C47,39.203523 43.138415,43.0650727 38.375,43.0650727 L9.625,43.0650727 C4.861585,43.0650727 1,39.203523 1,34.4401516 L1,22.9402569 C1,18.1768855 4.861585,14.3153359 9.625,14.3153359 L12.2056,14.3153359 L11.15519,8.0132786 C10.920245,6.60373651 11.87256,5.27055371 13.282115,5.03561087 C14.69167,4.80066802 16.024865,5.7529743 16.25981,7.16251639 L17.40981,14.0624532 C17.423955,14.1470924 17.43373,14.2315017 17.43948,14.3153359 L30.56052,14.3153359 C30.56627,14.2313867 30.576045,14.1470924 30.59019,14.0624532 L31.74019,7.16251639 C31.975135,5.7529743 33.30833,4.80066802 34.717885,5.03561087 Z M38.375,19.4902885 L9.625,19.4902885 C7.719565,19.4902885 6.175,21.0348394 6.175,22.9402569 L6.175,34.4401516 C6.175,36.3455692 7.719565,37.89012 9.625,37.89012 L38.375,37.89012 C40.280435,37.89012 41.825,36.3455692 41.825,34.4401516 L41.825,22.9402569 C41.825,21.0348394 40.280435,19.4902885 38.375,19.4902885 Z M14.8575,23.802749 C16.28649,23.802749 17.445,24.9612484 17.445,26.3902253 L17.445,28.6902043 C17.445,30.1191812 16.28649,31.2776806 14.8575,31.2776806 C13.42851,31.2776806 12.27,30.1191812 12.27,28.6902043 L12.27,26.3902253 C12.27,24.9612484 13.42851,23.802749 14.8575,23.802749 Z M33.1425,23.802749 C34.57149,23.802749 35.73,24.9612484 35.73,26.3902253 L35.73,28.6902043 C35.73,30.1191812 34.57149,31.2776806 33.1425,31.2776806 C31.71351,31.2776806 30.555,30.1191812 30.555,28.6902043 L30.555,26.3902253 C30.555,24.9612484 31.71351,23.802749 33.1425,23.802749 Z' id='形状结合' fill='#444444' fill-rule='nonzero'></path>
</g>
</svg>`;

const aiTitleTextDiv = document.createElement('div');
aiTitleTextDiv.className = 'sparkLite-title-text'; // 修改类名
aiTitleTextDiv.textContent = 'AI 摘要';
aiTitleDiv.appendChild(aiTitleTextDiv);

const aiAboutLink = document.createElement('a');
aiAboutLink.href = LINK_AI_ABOUT;
aiAboutLink.target = '_blank'; // 可选:在新标签页打开
aiAboutLink.className = 'sparkLite-about'; // 修改类名
aiAboutLink.style.color = 'var(--ai-summary-lighttext)'; // 内联样式防止覆写
aiAboutLink.id = 'sparkLite-about'; // 修改 ID
aiAboutLink.textContent = '关于'; // 修改显示文本
aiTitleDiv.appendChild(aiAboutLink);

const aiTagDiv = document.createElement('div');
aiTagDiv.className = 'sparkLite-tag'; // 修改类名
aiTagDiv.id = 'sparkLite-tag'; // 修改 ID
aiTagDiv.textContent = 'Spark Lite'; // 修改显示文本
aiTitleDiv.appendChild(aiTagDiv);


const aiExplanationDiv = document.createElement('div');
aiExplanationDiv.className = 'sparkLite-explanation'; // 修改类名
aiExplanationDiv.innerHTML = '生成中...' + '<span class="blinking-cursor"></span>';
aiDiv.appendChild(aiExplanationDiv);

// 将创建的元素插入到目标元素的顶部
targetElement.insertBefore(aiDiv, targetElement.firstChild);
}

// --- removeExistingAIDiv 函数 ---
function removeExistingAIDiv() {
// 查找具有 "post-SparkLite" 类的元素
const existingAIDiv = document.querySelector(".post-SparkLite"); // 修改选择器

// 如果找到了这个元素,就从其父元素中删除它
if (existingAIDiv) {
existingAIDiv.parentElement.removeChild(existingAIDiv);
}
}


// --- 主要逻辑对象 ---
const sparkLite = { // 重命名对象
// --- fetchSparkLiteSummary 函数 (核心修改) ---
fetchSparkLiteSummary: async function () {
// const title = document.title;
const url = window.location.href;

// 先尝试从IndexedDB读取
try {
const db = await initDB();
const tx = db.transaction('summaries', 'readonly');
const store = tx.objectStore('summaries');
const request = store.get(url);

const cachedData = await new Promise((resolve) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => resolve(null);
});

if (cachedData?.summary) {
// 检查缓存是否过期(7天有效期)

const isExpired = Date.now() - cachedData.timestamp > sparkLite_localCacheTime;
if (!isExpired) {
return cachedData.summary;
}
}
} catch (e) {
console.log('【AI 摘要前端】读取 IndexedDB 缓存失败', e);
}

const requestDataToProxy = {post_url: url};// {content: content, title: title};
const timeout = 30000;

try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

const response = await fetch(PROXY_API_URL, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(requestDataToProxy),
signal: controller.signal
});

clearTimeout(timeoutId);
const data = await response.json();

if (response.ok) {
// 成功获取摘要后存入IndexedDB
try {
const db = await initDB();
const tx = db.transaction('summaries', 'readwrite');
tx.objectStore('summaries').put({
url: url,
summary: data.summary,
timestamp: Date.now()
});
} catch (e) {
console.log('【AI 摘要前端】IndexedDB 写入失败', e);
}
return data.summary;
} else {
console.error(`【AI 摘要前端】代理或 API 错误: ${data.error || response.statusText}`);
return `【AI 摘要前端】获取摘要失败: ${data.error || `HTTP 状态码: ${response.status}`}`;
}
} catch (error) {
if (error.name === 'AbortError') {
console.error('【AI 摘要前端】Spark Lite 请求超时 (通过代理)');
return '【AI 摘要前端】获取文章摘要超时,请稍后刷新重试。';
} else {
console.error('【AI 摘要前端】Spark Lite 请求失败 (通过代理):', error);
if (error instanceof SyntaxError) {
return '【AI 摘要前端】获取文章摘要失败:代理服务器响应格式错误。';
}
return '【AI 摘要前端】获取文章摘要失败,请检查网络连接或代理服务器状态。';
}
}
},

// --- aiShowAnimation 函数 ---
// 可以修改 console.error 和 element.innerHTML 中的 "TianliGPT" 为 "Spark Lite"
aiShowAnimation: function (text) {
const element = document.querySelector(".sparkLite-explanation"); // 修改选择器
if (!element) {
return;
}

if (sparkLiteIsRunning) { // 修改变量名
return;
}

// 检查用户是否已定义 sparkLite_typingAnimate
if (typeof sparkLite_typingAnimate !== "undefined" && !sparkLite_typingAnimate) { // 修改变量名
element.innerHTML = text;
return;
}

sparkLiteIsRunning = true; // 修改变量名
const typingDelay = 25;
const waitingTime = 1000;
const punctuationDelayMultiplier = 6;

element.style.display = "block";
element.innerHTML = `生成中...<span class='blinking-cursor'></span>`;

let animationRunning = true;
let currentIndex = 0;
let initialAnimation = true;
let lastUpdateTime = performance.now();

const animate = () => {
if (currentIndex < text.length && animationRunning) {
const currentTime = performance.now();
const timeDiff = currentTime - lastUpdateTime;

const letter = text.slice(currentIndex, currentIndex + 1);
const isPunctuation = /[,。!、?,.!?]/.test(letter);
const delay = isPunctuation ? typingDelay * punctuationDelayMultiplier : typingDelay;

if (timeDiff >= delay) {
element.innerText = text.slice(0, currentIndex + 1);
lastUpdateTime = currentTime;
currentIndex++;

if (currentIndex < text.length) {
element.innerHTML =
text.slice(0, currentIndex) +
'<span class="blinking-cursor"></span>';
} else {
element.innerHTML = text;
element.style.display = "block";
sparkLiteIsRunning = false; // 修改变量名
observer.disconnect();// 暂停监听
}
}
requestAnimationFrame(animate);
}
}

// 使用IntersectionObserver对象优化ai离开视口后暂停的业务逻辑,提高性能
const observer = new IntersectionObserver((entries) => {
animationRunning = entries[0].isIntersecting; // 标志变量更新
if (animationRunning && initialAnimation) {
setTimeout(() => {
requestAnimationFrame(animate);
}, 200);
}
}, {threshold: 0});
let post_ai = document.querySelector('.post-SparkLite'); // 修改选择器
observer.observe(post_ai);//启动新监听
},
};

// --- runSparkLite 函数 (保持不变) ---
function runSparkLite() { // 重命名函数
// 确保在运行前移除可能存在的旧div,防止重复添加
removeExistingAIDiv();
// 插入新的占位符
insertAIDiv(sparkLite_postSelector);
// const content = sparkLite.getTitleAndContent(); // 调用重命名后的对象和方法
// if (content) {
// // console.log('Spark Lite 本次提交的内容为:' + content); // 修改日志文本
// } else {
// // 如果没有获取到内容,可能需要移除占位符或显示错误
// const aiExplanationDiv = document.querySelector(".sparkLite-explanation");
// if (aiExplanationDiv) {
// aiExplanationDiv.textContent = '未能获取到文章内容,无法生成摘要。';
// }
// return; // 提前退出,不进行 fetch
// }
sparkLite.fetchSparkLiteSummary().then(summary => { // 调用重命名后的方法
sparkLite.aiShowAnimation(summary); // 调用重命名后的方法
});
}

// --- checkURLAndRun 函数 (稍微调整,主要负责URL检查) ---
function checkURLAndRun() {
// 检查 AI 是否已在运行,防止重复启动动画等
if (sparkLiteIsRunning) {
return false; // 返回 false 表示不应继续执行
}
// 检查 AI 容器是否已存在 (如果存在,理论上不应再次运行完整流程,除非是内容更新)
// 为简化逻辑,我们允许它继续,runSparkLite内部会处理移除和重新插入
// if (document.querySelector(".post-SparkLite")) {
// return false;
// }

// URL 检查逻辑
if (typeof sparkLite_postURLs === "undefined" && typeof sparkLite_postURLs_regex === "undefined") {
console.log("【AI 摘要前端】没有设置页面链接模板,所以我为每个页面都生成ai摘要.");
return true; // 返回 true 表示检查通过,可以运行
}

try {
const regExpEscape = (s) => {
return s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
};
const wildcardToRegExp = (s) => {
return new RegExp('^' + s.split(/\*+/).map(regExpEscape).join('.*') + '$');
};

const currentURL = window.location.href;

const customPattern = sparkLite_postURLs.map(wildcardToRegExp);
const urlPattern = [...customPattern, ...(sparkLite_postURLs_regex)];

// 测试某个 URL 是否匹配任意一个规则
const testURL = (url) => {
return urlPattern.some(re => re.test(url));
};

if (testURL(currentURL)) {
console.log("【AI 摘要前端】匹配到了页面URL,将在此页面生成摘要");
return true; // URL匹配,检查通过
} else {
console.log("【AI 摘要前端】因为不符合自定义的链接规则,我决定不执行摘要功能。");
removeExistingAIDiv(); // 如果URL不匹配了,移除可能存在的旧AI框
return false; // URL不匹配,检查不通过
}
} catch (error) {
console.error("【AI 摘要前端】我没有看懂你编写的自定义链接规则...", error);
return false; // 出错,检查不通过
}
}

// --- 新增:统一的初始化入口函数 ---
function initializeSparkLite() {
// 1. 检查文章容器是否存在
const targetElement = document.querySelector(sparkLite_postSelector);
if (!targetElement) {
// console.log("Spark Lite: Target post selector not found.");
removeExistingAIDiv(); // 确保目标容器不在时,AI框也被移除
return;
}

// 2. 执行URL和运行状态检查
if (checkURLAndRun()) {
// 3. 如果检查通过,执行核心逻辑
// console.log("Spark Lite: Initialization checks passed, running...");
runSparkLite();
} else {
// console.log("Spark Lite: Initialization checks failed (URL mismatch or already running).");
}
}


// --- Event Listeners (使用新的初始化函数) ---

// 确保在移除旧监听器(如果可能)后添加新的
// (在简单脚本场景下通常不需要移除,但这是良好实践)

// --- 增强路由变化监听 ---

// 保存原始的 pushState 和 replaceState 方法
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;

// 包装 pushState
history.pushState = function () {
// 调用原始方法
const result = originalPushState.apply(this, arguments);
// 创建并触发自定义事件,表明 URL 可能已更改
window.dispatchEvent(new Event('pushstate'));
// 触发我们的初始化函数
// 使用 setTimeout 确保在 DOM 更新后执行
setTimeout(initializeSparkLite, 100);
return result;
};

// 包装 replaceState
history.replaceState = function () {
// 调用原始方法
const result = originalReplaceState.apply(this, arguments);
// 创建并触发自定义事件,表明 URL 可能已更改
window.dispatchEvent(new Event('replacestate'));
// 触发我们的初始化函数
// 使用 setTimeout 确保在 DOM 更新后执行
setTimeout(initializeSparkLite, 100);
return result;
};

// 监听 popstate 事件 (浏览器前进/后退按钮)
window.addEventListener('popstate', () => {
// 触发我们的初始化函数
// 使用 setTimeout 确保在 DOM 更新后执行
setTimeout(initializeSparkLite, 100);
});

// --- (确保之前的事件监听器仍然存在) ---
// 初始加载
document.removeEventListener("DOMContentLoaded", initializeSparkLite); // 避免重复添加
document.addEventListener("DOMContentLoaded", initializeSparkLite);

ai-summary.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
/* AI 文章摘要样式框 */

:root {
/* 主色调 */
--ai-summary-main: #5e72e4; /* 柔和的蓝色 */
--ai-summary-secondbg: #f8f9fa; /* 非常浅的灰色背景 */
--ai-summary-card-bg: #ffffff; /* 白色卡片背景 */

/* 文字颜色 */
--ai-summary-fontcolor: #2d3748; /* 深灰色文字 */
--ai-summary-lighttext: #718096; /* 中等灰色文字 */
--ai-summary-white: #ffffff; /* 纯白色 */

/* 边框样式 */
--ai-summary-style-border-always: 0.0625rem solid #e2e8f0; /* 浅灰色边框 (1px→0.0625rem) */

/* 悬停状态 */
--ai-summary-hover: #4c51bf; /* 深一点的蓝色用于悬停 */
}

[data-theme="dark"] {
/* 主色调调整(降低饱和度,提高辨识度) */
--ai-summary-main: #7f9cf5; /* 夜间模式下更亮的蓝色,保持可读性 */
--ai-summary-secondbg: #1a202c; /* 深灰蓝背景(接近黑但不刺眼) */
--ai-summary-card-bg: #2d3748; /* 暗色卡片背景(对比度适中) */

/* 文字颜色调整(减少亮度,避免白光刺眼) */
--ai-summary-fontcolor: #e2e8f0; /* 浅灰白文字(确保可读性) */
--ai-summary-lighttext: #a0aec0; /* 中等灰文字(次要内容) */
--ai-summary-white: #ffffff; /* 纯白色保留(用于强调内容) */

/* 边框样式调整(暗色环境下更柔和) */
--ai-summary-style-border-always: 0.0625rem solid #4a5568; /* 深灰色边框 (1px→0.0625rem) */

/* 悬停状态调整(夜间模式下更明显) */
--ai-summary-hover: #667eea; /* 亮蓝色悬停效果 */
}

.post-SparkLite {
background: var(--ai-summary-secondbg);
border-radius: 0.75rem; /* 12px→0.75rem */
padding: 0.75rem; /* 12px→0.75rem */
line-height: 1.3;
border: var(--ai-summary-style-border-always);
margin: 1rem 0; /* 16px→1rem */
}

@media screen and (max-width: 768px) {
.post-SparkLite {
margin-top: 1.375rem; /* 22px→1.375rem */
}
}

.sparkLite-title {
display: flex;
color: var(--ai-summary-lighttext);
border-radius: 0.5rem; /* 8px→0.5rem */
align-items: center;
padding: 0 0 0 0.35rem; /* 0 12px→0 0.75rem */
cursor: default;
user-select: none;
}

.sparkLite-title-text {
font-weight: bold;
margin-left: 0.5rem; /* 8px→0.5rem */
line-height: 1;
}

.sparkLite-explanation {
margin-top: 0.75rem; /* 12px→0.75rem */
padding: 0.5rem 0.75rem; /* 8px 12px→0.5rem 0.75rem */
background: var(--ai-summary-card-bg);
border-radius: 0.5rem; /* 8px→0.5rem */
border: var(--ai-summary-style-border-always);
/*font-size: var(--global-font-size);*/
line-height: 1.4;
display: flex;
}

.sparkLite-tag {
font-size: 0.70rem; /* 12px→0.75rem */
background-color: var(--ai-summary-lighttext);
color: var(--ai-summary-card-bg);
font-weight: bold;
border-radius: 0.25rem; /* 4px→0.25rem */
margin-left: 0.75rem;
line-height: 1;
padding: 0.25rem; /* 4px→0.25rem */
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.3s;
}

.sparkLite-about {
font-size: 0.70rem;
margin-left: auto;
line-height: 1;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.3s;
}

.sparkLite-title-icon {
width: 1.25rem; /* 20px→1.25rem */
height: 1.25rem; /* 20px→1.25rem */
}

.sparkLite-title-icon svg {
width: 1.25rem; /* 20px→1.25rem */
height: 1.25rem; /* 20px→1.25rem */
fill: var(--ai-summary-main);
}

.sparkLite-title-icon svg path {
fill: var(--ai-summary-main);
}

spark-lite.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
/*
讯飞星火 API 文档:https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html
*/

import getRedisClient from '../../utils/redis';

// --- 从环境变量读取敏感信息 ---
const APPID = process.env.SPARK_APPID; // 可能仍需要
const API_SECRET = process.env.SPARK_API_SECRET; // 用于新鉴权
const API_KEY = process.env.SPARK_API_KEY; // 用于新鉴权
const API_PASSWORD = process.env.SPARK_API_PASSWORD; // 用于新鉴权

// --- Spark API 地址 (兼容 OpenAI 格式) ---
const SPARK_API_URL = "https://spark-api-open.xf-yun.com/v1/chat/completions";
// --- 确认 Lite 版或其他模型在新 API 中的标识符 ---
const MODEL_NAME = "lite"; // 请根据官方文档确认正确的模型名称

const SECONDS_OF_WEEK = 60 * 60 * 24 * 7;
const SYSTEM_PROMPT = [
"你是一个严格遵循格式规则的摘要生成器。根据提供的文章内容,生成100-200字符的中文摘要,并严格遵守以下格式要求:",
"",
"## 核心规则(违反将导致任务失败):",
"1. 中英文/数字间必须添加空格!",
" - 正确示例:'使用 Transformer 模型'",
" - 正确示例:'在 2023 年发布的 GPT-4 模型'",
" - 错误示例:'使用Transformer模型'(缺少空格)",
" - 错误示例:'在2023年发布的GPT-4模型'(缺少空格)",
"",
"2. 语言和格式要求:",
" - 仅使用中文(专业术语保留英文)",
" - 绝对禁止 Markdown 符号:* _ \\ $ # 等",
" - 第三人称客观叙述",
" - 输出必须是完整句子",
"",
"3. 长度控制:",
" - 严格控制在 100-200 个中文字符(约5-10句)",
"",
"## 输出说明:",
"直接输出摘要文本,不要任何额外说明。生成后必须人工检查:",
"(1) 中英文间空格 (2) 无符号 (3) 纯文本",
"如发现违规,必须重新生成!"
].join('\n');

const cleanText = (htmlText, maxLength = 2000) => {
// 合并多个替换操作
let cleanedContent = htmlText
// 1. 解码Unicode转义序列
.replace(/\\u([\dA-F]{4})/gi, (_, code) =>
String.fromCharCode(parseInt(code, 16)))

// 2. 合并移除代码块、HTML标签、特定属性和callout块
.replace(
/<code[\s\S]*?<\/code>|<pre[\s\S]*?<\/pre>|<div class="callout"[\s\S]*?<\/div>|<img[^>]*alt="([^"]*)"[^>]*>|<[^>]+>|\b(data-[\w-]+|class|id|style|xmlns|viewBox|fill|stroke-width)="[^"]*"/gi,
(match, altText) => altText ? altText : ' '
)

// 3. 处理Markdown链接(支持嵌套格式)
.replace(/\[([^\]]+)\]\([^)]+\)/g, (_, text) =>
text.replace(/\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g, '$1$2$3$4'))

// 4. 压缩空白字符
.replace(/\s+/g, ' ')
.replace(/^ | $/g, '')
.trim();

// 截断优化
if (cleanedContent.length <= maxLength) return cleanedContent;

// 查找句子结束位置(优化版)
const lastSentenceIndex = cleanedContent
.substring(0, maxLength)
.search(/[.!?。!?](?=\s|$)/);

const truncIndex = lastSentenceIndex > 0 && lastSentenceIndex > maxLength - 150 ?
lastSentenceIndex + 1 :
Math.min(maxLength, cleanedContent.length);

const summary = cleanedContent.substring(0, truncIndex);
return summary + (truncIndex < cleanedContent.length ? '...' : '');
};

const extractUrlPath = (urlString) => {
try {
const url = new URL(urlString);
// 获取路径名(包含开头斜杠和可能的结尾斜杠)
let path = url.pathname;

// 移除开头和结尾的斜杠(如果存在)
path = path.replace(/^\/|\/$/g, '');

return path;
} catch (e) {
console.error("【服务端】无效的URL:", e);
return "";
}
}

// 常量定义
const HTTP_METHODS = {
POST: 'POST',
OPTIONS: 'OPTIONS'
};

const ERROR_MESSAGES = {
MISSING_ENV: '【服务端】内部错误:API凭证未配置',
MISSING_URL: '【服务端】请求体缺少 \'post_url\' 字段',
BLOG_API_FAILURE: '【服务端】获取文章元数据失败',
BLOG_NOT_FOUND: '【服务端】blog api 找不到文章的元数据',
SPARK_CONNECTION_FAILED: '【服务端】代理服务器未能连接到 Spark API',
SPARK_RESPONSE_ERROR: '【服务端】未能从 Spark 获取有效摘要内容',
INVALID_METHOD: (method) => `【服务端】Method ${method} Not Allowed`
};

const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
};

// 主处理函数
module.exports = async (req, res) => {
setCorsHeaders(res);

if (req.method === HTTP_METHODS.OPTIONS) {
return handleOptionsRequest(res);
}

if (req.method === HTTP_METHODS.POST) {
return handlePostRequest(req, res);
}

return handleInvalidMethod(req, res);
};

// 设置CORS头
function setCorsHeaders(res) {
Object.entries(CORS_HEADERS).forEach(([key, value]) => {
res.setHeader(key, value);
});
}

// 处理OPTIONS请求
function handleOptionsRequest(res) {
return res.status(200).end();
}

// 处理无效方法
function handleInvalidMethod(req, res) {
res.setHeader('Allow', [HTTP_METHODS.POST, HTTP_METHODS.OPTIONS]);
return res.status(405).end(ERROR_MESSAGES.INVALID_METHOD(req.method));
}

// 处理POST请求
async function handlePostRequest(req, res) {
// 验证环境变量
if (!validateEnvironmentVariables()) {
console.error("Server Error: Spark environment variables not configured.");
return res.status(500).json({ error: ERROR_MESSAGES.MISSING_ENV });
}

try {
const { post_url } = req.body;
if (!post_url) {
return res.status(400).json({ error: ERROR_MESSAGES.MISSING_URL });
}

const post_path = extractUrlPath(post_url);
const cachedSummary = await checkRedisCache(post_path);
if (cachedSummary) {
return res.status(200).json({ summary: cachedSummary });
}

const { title, content } = await fetchBlogContent(post_path);
const summary = await generateSparkSummary(title, content);

await cacheResult(post_path, summary);
return res.status(200).json({ summary });

} catch (error) {
return handleError(error, res);
}
}

// 验证环境变量
function validateEnvironmentVariables() {
return API_KEY && API_SECRET && APPID;
}

// 检查Redis缓存
async function checkRedisCache(post_path) {
const redisClient = await getRedisClient();
const value = await redisClient.get(post_path); // 直接获取值

if (value !== null) {
console.log("【服务端】Redis Hit!");
return value;
}

console.log("【服务端】Redis Miss!");
return null;
}

// 获取博客内容
async function fetchBlogContent(post_path) {
const response = await fetch(`https://blog.uuanqin.top/api/posts/${post_path}.json`, {
method: 'GET',
});

if (!response || response.status === 404) {
console.error("【服务端】进入blog api - 404");
throw new Error(ERROR_MESSAGES.BLOG_NOT_FOUND);
}

const blogData = await response.json();
return {
title: blogData.data.title,
content: cleanText(blogData.data.content)
};
}

// 生成Spark摘要
async function generateSparkSummary(title, content) {
const requestData = {
model: MODEL_NAME,
messages: [
{
role: "system",
content: SYSTEM_PROMPT
},
{
role: "user",
content: `【文章标题】${title || '无标题'}【文章内容】${content}`,
}
],
temperature: 0.5,
max_tokens: 200
};

const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_PASSWORD}`
};

const response = await fetch(SPARK_API_URL, {
method: 'POST',
headers,
body: JSON.stringify(requestData)
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || ERROR_MESSAGES.SPARK_CONNECTION_FAILED);
}

const responseData = await response.json();
const assistantMessage = responseData.choices?.[0]?.message;

if (assistantMessage?.role === 'assistant' && assistantMessage.content) {
return assistantMessage.content.trim();
}

throw new Error(ERROR_MESSAGES.SPARK_RESPONSE_ERROR);
}

// 缓存结果
async function cacheResult(post_path, summary) {
const redisClient = await getRedisClient();
await redisClient.setEx(post_path, SECONDS_OF_WEEK, summary);
}

// 错误处理
function handleError(error, res) {
console.error("【服务端】错误:", error);

if (error.message in ERROR_MESSAGES) {
return res.status(400).json({ error: error.message });
}

if (error instanceof SyntaxError) {
console.error("Failed to parse Spark API response as JSON.");
return res.status(500).json({ error: '【服务端】代理服务器错误:无法解析 Spark API 响应' });
}

return res.status(500).json({
error: '【服务端】代理服务器内部错误'+error.message,
details: error.message
});
}

// --- 如果需要生成 Token,可能需要类似这样的辅助函数 (具体实现需查文档) ---
// function generateSparkToken(apiKey, apiSecret) {
// // ... 根据讯飞文档实现 Token 生成逻辑 ...
// return "generated_token_string";
// }

redis.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// utils/redis.js
import { createClient } from 'redis';

let redisClient;
let isConnecting = false;

export default async function getRedisClient() {
if (redisClient && redisClient.isReady) {
return redisClient;
}

if (isConnecting) {
// 等待现有连接完成
return new Promise((resolve) => {
const check = () => {
if (redisClient && redisClient.isReady) {
resolve(redisClient);
} else {
setTimeout(check, 50);
}
};
check();
});
}

isConnecting = true;

try {
redisClient = createClient({
url: process.env.REDIS_URL,
socket: {
// 针对 Vercel 环境的优化设置
reconnectStrategy: (retries) => Math.min(retries * 100, 3000),
keepAlive: 30000 // 保持连接活跃
}
});

redisClient.on('error', (err) => console.error('【服务端】Redis Client Error', err));
redisClient.on('end', () => console.log('【服务端】Redis connection closed'));

await redisClient.connect();
console.log('【服务端】Redis connected successfully');

return redisClient;
} catch (err) {
console.error('【服务端】Redis connection failed', err);
throw err;
} finally {
isConnecting = false;
}
}

后记

当 TianliGPT 的博客 AI 摘要诞生时,我深感震撼,这么酷的项目我自己也要实现一个。那段时间,这个小小的愿景驱动着我不断,苦学本领和技术。睡觉前,脑海中还会闪过无数种实现方案…但是由于这些年事情太多,这个计划一直被耽搁。直到最近看到 Liushen、KonoXIN 的作品,我才把这个两年前的 Flag 捡起来。

这些年,也逛了不少精美的博客,饱览许多高品质的文章。能和博友们一起成长,是一件幸福的事情!

本文参考