feat(aiSummary): AI 文章总结

This commit is contained in:
Ad-closeNN
2026-05-01 00:40:53 +08:00
parent 8c45610503
commit 2608a54bca
19 changed files with 564 additions and 14 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ export default defineConfig({
// when the Tailwind class `transition-all` is used // when the Tailwind class `transition-all` is used
containers: ["main", "#toc"], containers: ["main", "#toc"],
smoothScrolling: true, smoothScrolling: true,
cache: true, cache: process.env.NODE_ENV !== "development",
preload: true, preload: true,
accessibility: true, accessibility: true,
updateHead: true, updateHead: true,
+3
View File
@@ -11,6 +11,9 @@
"astro": "astro", "astro": "astro",
"type-check": "tsc --noEmit --isolatedDeclarations", "type-check": "tsc --noEmit --isolatedDeclarations",
"new-post": "node scripts/new-post.js", "new-post": "node scripts/new-post.js",
"summary": "node scripts/summary.js",
"summary:all": "node scripts/summary.js --all",
"summary:force": "node scripts/summary.js --all --force",
"format": "biome format --write ./src", "format": "biome format --write ./src",
"lint": "biome check --write ./src", "lint": "biome check --write ./src",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
+459
View File
@@ -0,0 +1,459 @@
/* This is a script to generate AI summary for a post */
import fs from "fs"
import path from "path"
import https from "https"
import readline from "readline"
const targetDir = "./src/content/posts/"
const summaryModel = "gpt-5-nano"
const batchDelayMs = 1500
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
function parseAiSummaryValue(value) {
const trimmed = value.trim()
if (!trimmed) return null
if (trimmed.startsWith('"')) {
try {
return JSON.parse(trimmed)
} catch {
return trimmed
}
}
return trimmed
}
function extractAiSummary(frontmatter) {
const summaryMatch = frontmatter.match(/^aiSummary:[ \t]*(.*)$/m)
if (!summaryMatch) return null
const inlineValue = summaryMatch[1].trim()
if (inlineValue !== ">" && inlineValue !== "|") {
return parseAiSummaryValue(inlineValue)
}
const afterSummary = frontmatter.slice(summaryMatch.index + summaryMatch[0].length)
const lines = afterSummary.split(/\r?\n/)
const blockLines = []
for (const line of lines) {
if (!line.startsWith(" ") && line.trim()) break
blockLines.push(line.replace(/^ {1,2}/, ""))
}
return blockLines.join("\n").trim() || null
}
function formatFrontmatterField(key, value) {
return `${key}: ${JSON.stringify(value.replace(/\r?\n/g, " "))}`
}
function upsertFrontmatterField(frontmatter, key, value) {
const fieldPattern = new RegExp(`^${key}:[ \\t]*(?:.*(?:\\r?\\n[ \\t].*)*)`, "m")
const formattedField = formatFrontmatterField(key, value)
return fieldPattern.test(frontmatter)
? frontmatter.replace(fieldPattern, formattedField)
: `${frontmatter.trimEnd()}\n${formattedField}`
}
function stripAdmonitionMarkers(text) {
return text
.replace(/^:::(note|tip|important|caution|warning)(?:\[[^\]]*\])?\s*$/gim, "")
.replace(/^:::\s*$/gm, "")
.replace(/^>\s*\[!(note|tip|important|caution|warning)\]\s*$/gim, "")
.replace(/^\s*\[!(note|tip|important|caution|warning)\]\s*/gim, "")
}
function cleanGeneratedSummary(summary) {
return stripAdmonitionMarkers(summary)
.replace(/^(note|tip|important|caution|warning|警告|注意|提示)[:\s-]+/i, "")
.replace(/\s+/g, " ")
.trim()
}
function getPostFiles() {
const files = fs.readdirSync(targetDir)
return files.filter(f => f.endsWith(".md") || f.endsWith(".mdx"))
}
function getCurrentAiSummary(fileName) {
const fullPath = path.join(targetDir, fileName)
const content = fs.readFileSync(fullPath, "utf-8")
const match = content.match(frontmatterRegex)
if (match) {
const frontmatter = match[1]
const summary = extractAiSummary(frontmatter)
if (summary) {
return summary
}
}
return null
}
function selectFile(files) {
return new Promise((resolve) => {
const rl = createInterface()
const summaries = new Map(files.map(file => [file, getCurrentAiSummary(file)]))
const wasRaw = process.stdin.isRaw
let selectedIndex = 0
let closed = false
readline.emitKeypressEvents(process.stdin, rl)
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
const cleanup = () => {
if (closed) return
closed = true
process.stdin.removeListener("keypress", onKey)
if (process.stdin.isTTY) {
process.stdin.setRawMode(Boolean(wasRaw))
}
process.stdout.write("\x1b[?25h")
rl.close()
}
const draw = () => {
const rows = process.stdout.rows || 24
const listHeight = Math.max(5, rows - 8)
const start = Math.min(
Math.max(0, selectedIndex - Math.floor(listHeight / 2)),
Math.max(0, files.length - listHeight),
)
const visibleFiles = files.slice(start, start + listHeight)
let output = "\x1b[?25l\x1b[2J\x1b[H"
output += "选择文章(↑/↓ 或 j/k 移动,Enter 确认,q/Esc 取消)\n\n"
visibleFiles.forEach((file, offset) => {
const index = start + offset
const currentSummary = summaries.get(file)
const prefix = index === selectedIndex ? "" : " "
const hasSummary = currentSummary ? " 已有摘要" : ""
output += `${prefix} ${file}${hasSummary}\n`
})
const currentFile = files[selectedIndex]
const currentSummary = summaries.get(currentFile)
output += `\n${selectedIndex + 1}/${files.length} ${currentFile}\n`
if (currentSummary) {
output += `当前摘要:${currentSummary}\n`
}
process.stdout.write(output)
}
const cancel = () => {
cleanup()
process.stdout.write("已取消\n")
process.exit(0)
}
const onKey = (_str, key = {}) => {
if (key.ctrl && key.name === "c") {
cancel()
}
if (key.name === "up" || key.name === "k") {
selectedIndex = Math.max(0, selectedIndex - 1)
draw()
return
}
if (key.name === "down" || key.name === "j") {
selectedIndex = Math.min(files.length - 1, selectedIndex + 1)
draw()
return
}
if (key.name === "return") {
const selectedFile = files[selectedIndex]
cleanup()
resolve(selectedFile)
return
}
if (key.name === "escape" || key.name === "q") {
cancel()
}
}
process.stdin.on("keypress", onKey)
draw()
})
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function getRetryAfterMs(value) {
if (!value) return null
const seconds = Number(value)
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000)
}
const timestamp = Date.parse(value)
if (Number.isFinite(timestamp)) {
return Math.max(0, timestamp - Date.now())
}
return null
}
function getRetryDelayMs(error, attempt) {
const retryAfterMs = getRetryAfterMs(error.retryAfter)
if (retryAfterMs !== null) {
return Math.min(retryAfterMs, 120000)
}
const exponentialDelay = 3000 * 2 ** (attempt - 1)
const jitter = Math.floor(Math.random() * 1000)
return Math.min(exponentialDelay + jitter, 120000)
}
function shouldRetry(error) {
return !error.statusCode || error.statusCode === 429 || error.statusCode >= 500
}
function requestSummary(options, requestBody) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let data = ""
res.on("data", (chunk) => {
data += chunk
})
res.on("end", () => {
if (res.statusCode !== 200) {
const error = new Error(`HTTP ${res.statusCode}`)
error.statusCode = res.statusCode
error.retryAfter = res.headers["retry-after"]
error.responseBody = data.slice(0, 500)
reject(error)
return
}
try {
const response = JSON.parse(data)
const output = response.output
if (!output || !Array.isArray(output)) {
reject(new Error("Failed to generate summary"))
return
}
const messageOutput = output.find(o => o.type === "message")
if (!messageOutput || !messageOutput.content) {
reject(new Error("No message output found"))
return
}
const textBlock = messageOutput.content.find((block) => block.type === "output_text")
if (!textBlock) {
reject(new Error("No output_text block found"))
return
}
resolve(textBlock.text.trim())
} catch (error) {
reject(error)
}
})
})
req.on("error", (error) => {
reject(error)
})
req.write(requestBody)
req.end()
})
}
async function generateSummary(fileName) {
const fullPath = path.join(targetDir, fileName)
const fileContent = fs.readFileSync(fullPath, "utf-8")
const frontmatterMatch = fileContent.match(frontmatterRegex)
const bodyContent = stripAdmonitionMarkers(
frontmatterMatch
? fileContent.slice(frontmatterMatch[0].length).trimStart()
: fileContent,
)
console.log("\n生成 AI 摘要中...\n")
const apiKey = "public"
const apiUrl = "opencode.ai/zen"
const prompt = `请为以下博客文章生成一个不超过100字的中文摘要。
输出要求:
- 只输出摘要正文,不要解释,不要加标题。
- 摘要必须以“本文介绍了”开头。
- 使用一句话概括文章主题、关键内容和用途。
- 不要输出 Markdown 语法、admonition 标记或提示框类型,例如 :::warning、:::caution、[!warning]、警告、注意。
文章内容:
${bodyContent}`
const requestBody = JSON.stringify({
model: summaryModel,
input: [
{
role: "user",
content: [{ type: "input_text", text: prompt }]
}
],
max_output_tokens: 500,
stream: false,
reasoning: { effort: "minimal" }
})
const url = new URL(`https://${apiUrl}/v1/responses`)
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"anthropic-version": "2023-06-01"
}
}
const maxAttempts = 6
let summary = ""
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
summary = cleanGeneratedSummary(await requestSummary(options, requestBody))
break
} catch (error) {
if (!shouldRetry(error) || attempt === maxAttempts) {
if (error.responseBody) {
console.error("Error response:", error.responseBody)
}
throw error
}
const delayMs = getRetryDelayMs(error, attempt)
console.warn(`请求失败:${error.message}${Math.ceil(delayMs / 1000)} 秒后重试(${attempt}/${maxAttempts - 1}`)
await sleep(delayMs)
}
}
let newContent
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1]
const newFrontmatter = upsertFrontmatterField(
upsertFrontmatterField(frontmatter, "aiSummary", summary),
"aiSummaryModel",
summaryModel,
)
newContent = `---\n${newFrontmatter}\n---\n${bodyContent}`
} else {
newContent = `---\n${formatFrontmatterField("aiSummary", summary)}\n${formatFrontmatterField("aiSummaryModel", summaryModel)}\n---\n${bodyContent}`
}
fs.writeFileSync(fullPath, newContent)
return summary
}
function createInterface() {
return readline.createInterface({
input: process.stdin,
output: process.stdout
})
}
async function generateMissingSummaries(files, { force = false } = {}) {
const pendingFiles = force ? files : files.filter(file => !getCurrentAiSummary(file))
if (pendingFiles.length === 0) {
console.log("所有文章都已有 AI 摘要")
return
}
if (force) {
console.log(`将强制为 ${pendingFiles.length} 篇文章重新生成 AI 摘要。\n`)
} else {
console.log(`将为 ${pendingFiles.length} 篇文章生成 AI 摘要,跳过 ${files.length - pendingFiles.length} 篇已有摘要的文章。\n`)
}
const failedFiles = []
for (const [index, file] of pendingFiles.entries()) {
console.log(`[${index + 1}/${pendingFiles.length}] ${file}`)
try {
const summary = await generateSummary(file)
console.log(`完成:${summary}\n`)
if (index < pendingFiles.length - 1) {
await sleep(batchDelayMs)
}
} catch (err) {
failedFiles.push({ file, message: err.message })
console.error(`失败:${file} - ${err.message}\n`)
}
}
if (failedFiles.length > 0) {
console.error("以下文章生成失败:")
failedFiles.forEach(({ file, message }) => {
console.error(`- ${file}: ${message}`)
})
process.exitCode = 1
}
}
async function main() {
const files = getPostFiles()
const args = process.argv.slice(2)
const force = args.includes("--force")
const all = args.includes("--all")
const positionalArgs = args.filter(arg => !arg.startsWith("--"))
if (files.length === 0) {
console.log("没有找到任何文章文件")
process.exit(1)
}
if (all) {
await generateMissingSummaries(files, { force })
} else if (positionalArgs.length > 0) {
const fileName = positionalArgs[0]
let targetFile = fileName
const fileExtensionRegex = /\.md(x)?$/i
if (!fileExtensionRegex.test(targetFile)) {
targetFile += ".md"
}
if (!files.includes(targetFile)) {
console.error(`Error: File ${targetFile} does not exist`)
process.exit(1)
}
try {
const summary = await generateSummary(targetFile)
console.log(`AI 摘要已生成: \n${summary}`)
} catch (err) {
console.error(`生成失败: ${err.message}`)
process.exit(1)
}
} else {
const selectedFile = await selectFile(files)
console.log(`已选择: ${selectedFile}\n`)
try {
const summary = await generateSummary(selectedFile)
console.log(`AI 摘要已生成: ${summary}`)
} catch (err) {
console.error(`生成失败: ${err.message}`)
process.exit(1)
}
}
}
main().catch((err) => {
console.error("Error:", err.message)
process.exit(1)
})
+2
View File
@@ -8,6 +8,8 @@ category: 杂项
draft: false draft: false
showcover: false showcover: false
customcover: /public/pic/cloudflare-turnstile-verify-error.png customcover: /public/pic/cloudflare-turnstile-verify-error.png
aiSummary: "本文介绍了多种验证码实现与集成要点,涵盖 Google reCaptcha、Cloudflare Turnstile、hCaptcha 及 Arkose Labs 的使用场景、 key 配置与前端嵌入方式,帮助开发者快速了解并对比不同方案的适用性与实现流程。"
aiSummaryModel: "gpt-5-nano"
--- ---
<head> <head>
<!-- Google reCaptcha v2--> <!-- Google reCaptcha v2-->
+2 -1
View File
@@ -8,8 +8,9 @@ showcover: false
customcover: /public/pic/chrome-ext-force-custom-font.png customcover: /public/pic/chrome-ext-force-custom-font.png
category: 教程 category: 教程
draft: false draft: false
aiSummary: "本文介绍了 Chrome 已停止对 Manifest V2 的支持及其时间线,给出通过开启 flags、添加启动参数或切换浏览器(如 Firefox)等临时解决方案与注意事项。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 前言 # 前言
:::tip[提示] :::tip[提示]
+2 -1
View File
@@ -7,8 +7,9 @@ image: /public/pic/discord-bot-cln-bot-profile.png
customcover: /public/pic/discord-bot-cln-bot-profile-wide.png customcover: /public/pic/discord-bot-cln-bot-profile-wide.png
showcover: false showcover: false
category: 教程 category: 教程
aiSummary: "本文介绍了如何在 Cloudflare Workers 上实现 Discord 与 Telegram 机器人,包括创建机器人、获取必要信息、配置秘密、上传并部署 Worker、设置端点 URL 以及注册命令,强调安全保存密钥并可直接复制粘贴配置,实现快速部署与未来扩展。"
aiSummaryModel: "gpt-5-nano"
--- ---
:::tip[提示] :::tip[提示]
~~赛博菩萨~~ Cloudflare Workers 不仅能运行 Discord 机器人,还能运行 Telegram 机器人。 ~~赛博菩萨~~ Cloudflare Workers 不仅能运行 Discord 机器人,还能运行 Telegram 机器人。
::: :::
+2
View File
@@ -8,6 +8,8 @@ customcover: /public/pic/codex-warp-banner.png
tags: ["Codex", "Warp", "注册机", "AI"] tags: ["Codex", "Warp", "注册机", "AI"]
category: '教程' category: '教程'
outdated: true outdated: true
aiSummary: "本文介绍了通过注册机注册 OpenAI 免费模型的风险、被封禁现状,以及利用 Cloudflare Warp 提高注册成功率的做法与具体使用方法,以及相关注意事项与测试IP示例。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 前言 # 前言
+2 -1
View File
@@ -7,8 +7,9 @@ image: "/public/pic/custom-frontmatter-cover.svg"
category: 教程 category: 教程
showcover: false showcover: false
customcover: /public/pic/custom-frontmatter-customcover.svg customcover: /public/pic/custom-frontmatter-customcover.svg
aiSummary: "本文介绍了在 Astro/Fuwari 博客中,通过在 Markdown 顶部自定义前置参数(如 draft、image、customcover、showcover 等)来灵活控制文章头图的显示与替换,并给出在前端模板中按条件渲染头图的实现方法、示例代码及配置要点,帮助提升博客主页与文章内页头图的可控性与个性化展示。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 前言 # 前言
如果你用的博客是 [Astro](https://astro.build) 或以它为架构的 [Fuwari](https://github.com/saicaca/fuwari),那么这篇文章或许适合。 如果你用的博客是 [Astro](https://astro.build) 或以它为架构的 [Fuwari](https://github.com/saicaca/fuwari),那么这篇文章或许适合。
@@ -6,8 +6,9 @@ description: 2025/8/8 早上,Telegram 竟可在国内被访问,不过现在
image: /public/pic/telegram-devices-panel-china.jpg image: /public/pic/telegram-devices-panel-china.jpg
category: 记录 category: 记录
draft: false draft: false
aiSummary: "本文介绍了 Telegram DC5 部分地区直连的尝试与 GFW 窗口变化,以及 Cloudflare Warp 的国内直连可能性与相关定位误差。"
aiSummaryModel: "gpt-5-nano"
--- ---
# Telegram 能直连了? # Telegram 能直连了?
### 目前已被墙 ### 目前已被墙
[t.me/zaihuapd/34943](https://t.me/zaihuapd/34943) [t.me/zaihuapd/34943](https://t.me/zaihuapd/34943)
+2 -1
View File
@@ -6,8 +6,9 @@ description: 通过阿里云免费企业邮箱获得一个类似于 admin@github
image: /public/pic/aliyun-email-mxcheck.png image: /public/pic/aliyun-email-mxcheck.png
category: 教程 category: 教程
draft: false draft: false
aiSummary: "本文介绍了如何利用阿里云免费企业邮箱,用自有域名收发邮件的流程与注意事项,以及从域名解析到管理员设置、MX/DKIM验证等前置配置的要点和实际使用路径。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 前言 # 前言
自从你上网冲浪接触邮箱以来,你或多或少都会见过这种邮箱: 自从你上网冲浪接触邮箱以来,你或多或少都会见过这种邮箱:
- noreply@github.com - noreply@github.com
+2 -1
View File
@@ -6,8 +6,9 @@ description: FoloFollow)是一个 RSS 订阅源合集软件,用它可以
image: /public/pic/folo-rss-verify-panel.png image: /public/pic/folo-rss-verify-panel.png
category: 教程 category: 教程
draft: false draft: false
aiSummary: "本文介绍了通过纯文本、描述和 RSS 标签三种方式,将订阅源与 Folo 账户绑定并完成认证的具体步骤与示例。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 前言 # 前言
:::tip[提示] :::tip[提示]
Folo 也就是之前的 Follow,只不过改名成了 Folo。 Folo 也就是之前的 Follow,只不过改名成了 Folo。
+2 -1
View File
@@ -7,8 +7,9 @@ image: /public/pic/giscus-preview.png
customcover: /public/pic/giscus-no-content.png customcover: /public/pic/giscus-no-content.png
showcover: false showcover: false
category: 教程 category: 教程
aiSummary: "本文介绍了如何在博客接入 Giscus 评论区,包含在博客仓库或专用仓库存放、获取并粘贴 JS 代码、将评论区嵌入特定页面与页面模板中的具体实现,以及 origin 配置与简单的反垃圾思路。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 前言 # 前言
如果你的静态博客没有评论区,又不想自己搭建一个评论系统,那么 Giscus 就是一个不错的选择。 如果你的静态博客没有评论区,又不想自己搭建一个评论系统,那么 Giscus 就是一个不错的选择。
接下来就手把手教你添加这个[](https://giscus.app/client.js)件并配置它。 接下来就手把手教你添加这个[](https://giscus.app/client.js)件并配置它。
@@ -5,8 +5,9 @@ tags: ["网站", "验证"]
description: 一次出3个 hCaptcha?瞧瞧你干的好事! description: 一次出3个 hCaptcha?瞧瞧你干的好事!
image: /public/pic/hCaptcha-localhost-errkey.png image: /public/pic/hCaptcha-localhost-errkey.png
category: 记录 category: 记录
aiSummary: "本文介绍了在同时使用 Google reCaptcha 与 hCaptcha 时,hCaptcha 会尝试兼容导致界面混乱的情况,并给出通过将脚本 URL 加上 recaptchacompat=off 关闭兼容化的简单解决方法。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 强兼 Google reCaptcha 失败? # 强兼 Google reCaptcha 失败?
如你所见,这是 hCaptcha 无法验证的样子。当然,如果你在一个页面同时放上 [Google reCaptcha](https://developers.google.com/recaptcha?hl=zh-cn)(我的是 v2)和 [hCaptcha](https://www.hcaptcha.com),那么聪明的 hCaptcha 会 [开始兼容它](https://docs.hcaptcha.com/configuration) 。 如你所见,这是 hCaptcha 无法验证的样子。当然,如果你在一个页面同时放上 [Google reCaptcha](https://developers.google.com/recaptcha?hl=zh-cn)(我的是 v2)和 [hCaptcha](https://www.hcaptcha.com),那么聪明的 hCaptcha 会 [开始兼容它](https://docs.hcaptcha.com/configuration) 。
![hcaptcha-recaptchacompat-origin](/public/pic/hcaptcha-recaptchacompat-origin.png) ![hcaptcha-recaptchacompat-origin](/public/pic/hcaptcha-recaptchacompat-origin.png)
+2 -1
View File
@@ -7,8 +7,9 @@ customcover: /public/pic/kugou-music-nodejs-api-self-hosting-menu.png
showcover: false showcover: false
tags: ["酷狗音乐", "狠活"] tags: ["酷狗音乐", "狠活"]
category: '教程' category: '教程'
aiSummary: "本文介绍了通过开源酷狗API解析音乐URL的后端搭建、登录领取概念版VIP及通过FileHash下载高音质音乐的关键步骤与注意事项。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 前言 # 前言
:::caution[警告] :::caution[警告]
1. 本教程仅供学习交流,下载的音乐文件请于24小时内删除。 1. 本教程仅供学习交流,下载的音乐文件请于24小时内删除。
+2 -1
View File
@@ -7,8 +7,9 @@ description: 获得了一个新域名 *这使我充满了决心
image: /public/pic/domain-inlist-adclosenn.top.png image: /public/pic/domain-inlist-adclosenn.top.png
category: 记录 category: 记录
draft: false draft: false
aiSummary: "本文介绍了作者通过购买 .top 域名并搭建博客的过程,比较不同域名注册商和支付体验,分享域名被 XYZ 服务器暂停、解封的经历以及教训与使用场景。"
aiSummaryModel: "gpt-5-nano"
--- ---
# .dev 域名 # .dev 域名
:::note[感谢] :::note[感谢]
感谢 [MC_Kero blog](https://blog.mckero.com) 的站长 [MC_Kero](https://github.com/MCKero6423) 提供的 [GitHub Student Developer Pack](https://education.github.com/pack) 免费**一年域名**福利!~~都给我去 Follow 他~~ 感谢 [MC_Kero blog](https://blog.mckero.com) 的站长 [MC_Kero](https://github.com/MCKero6423) 提供的 [GitHub Student Developer Pack](https://education.github.com/pack) 免费**一年域名**福利!~~都给我去 Follow 他~~
+2 -1
View File
@@ -7,8 +7,9 @@ image: /public/pic/newtab-link-npm-plugin-info-1.png
customcover: /public/pic/newtab-link-npm-plugin-info-2.png customcover: /public/pic/newtab-link-npm-plugin-info-2.png
showcover: false showcover: false
category: 教程 category: 教程
aiSummary: "本文介绍了如何使用 rehype-external-links 插件,在 Astro 项目中将文章内外部链接统一设置为在新标签页打开,包含安装与在配置中的具体实现要点与示例。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 前言 # 前言
看标题可能不明白在说什么。但是如果我放出这两个链接让你点,你应该知道是什么意思。 看标题可能不明白在说什么。但是如果我放出这两个链接让你点,你应该知道是什么意思。
@@ -6,8 +6,9 @@ description: 在 PCL 启动器上面询问大模型?当然可以~
image: /public/pic/pcl-gemini-mixed.png image: /public/pic/pcl-gemini-mixed.png
category: 教程 category: 教程
draft: false draft: false
aiSummary: "本文介绍了多 API Key 模式的原理、适用场景及如何在 Google Gemini API 的速率限制下通过多钥匙并用来提升请求量。"
aiSummaryModel: "gpt-5-nano"
--- ---
<!-- 小破站防盗链?Referrer 为 localhost 的时候 403 Forbidden --> <!-- 小破站防盗链?Referrer 为 localhost 的时候 403 Forbidden -->
<meta name="referrer" content="no-referrer"> <meta name="referrer" content="no-referrer">
+2 -1
View File
@@ -6,8 +6,9 @@ description: Umami 是一个精美的网站统计分析工具,我们可以自
image: /public/pic/umami-screenshot.png image: /public/pic/umami-screenshot.png
category: 教程 category: 教程
draft: false draft: false
aiSummary: "本文介绍了自托管 Umami 的整体思路、所需条件、以 Neon/Netlify 为例的具体部署步骤,以及登录与注意事项,帮助搭建独立的网站统计工具。"
aiSummaryModel: "gpt-5-nano"
--- ---
# 自行托管 Umami # 自行托管 Umami
## 什么是 Umami ## 什么是 Umami
+71
View File
@@ -34,6 +34,61 @@ const lastUpdatedAt = entry.data.updated ?? entry.data.published;
const lastUpdatedLabel = formatDateToYYYYMMDD(lastUpdatedAt); const lastUpdatedLabel = formatDateToYYYYMMDD(lastUpdatedAt);
const lastUpdatedTimestamp = lastUpdatedAt.getTime(); const lastUpdatedTimestamp = lastUpdatedAt.getTime();
const postSources = import.meta.glob("../../content/posts/**/*.{md,mdx}", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
const normalizedPostSources = Object.fromEntries(
Object.entries(postSources).map(([key, source]) => [key.toLowerCase(), source]),
);
function parseAiSummaryValue(value: string) {
const trimmed = value.trim();
if (!trimmed) return "";
if (trimmed.startsWith('"')) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
function extractFrontmatterValue(frontmatter: string, key: string) {
const valueMatch = frontmatter.match(new RegExp(`^${key}:[ \\t]*(.*)$`, "m"));
if (!valueMatch) return "";
const inlineValue = valueMatch[1].trim();
if (inlineValue !== ">" && inlineValue !== "|") {
return parseAiSummaryValue(inlineValue);
}
const afterValue = frontmatter.slice(valueMatch.index! + valueMatch[0].length);
const lines = afterValue.split(/\r?\n/);
const blockLines = [];
for (const line of lines) {
if (!line.startsWith(" ") && line.trim()) break;
blockLines.push(line.replace(/^ {1,2}/, ""));
}
return blockLines.join("\n").trim();
}
function getAiSummaryMeta(entryId: string) {
const source =
normalizedPostSources[`../../content/posts/${entryId}`.toLowerCase()] ??
normalizedPostSources[`../../content/posts/${entryId}.md`.toLowerCase()] ??
normalizedPostSources[`../../content/posts/${entryId}.mdx`.toLowerCase()];
const frontmatter = source?.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1];
return {
summary: frontmatter ? extractFrontmatterValue(frontmatter, "aiSummary") : "",
model: frontmatter ? extractFrontmatterValue(frontmatter, "aiSummaryModel") : "",
};
}
const aiSummaryMeta = getAiSummaryMeta(entry.id);
const jsonLd = { const jsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BlogPosting", "@type": "BlogPosting",
@@ -177,6 +232,22 @@ const isOutdated = entry.data.outdated;
{!entry.data.image && <div class="border-[var(--line-divider)] border-dashed border-b-[1px] mb-5"></div>} {!entry.data.image && <div class="border-[var(--line-divider)] border-dashed border-b-[1px] mb-5"></div>}
</div> </div>
{aiSummaryMeta.summary && (
<div class="mb-4 rounded-xl p-4 bg-gradient-to-r from-violet-500/10 to-indigo-500/10 dark:from-violet-500/15 dark:to-indigo-500/15 border border-violet-500/20 onload-animation">
<div class="flex items-start gap-3">
<div class="shrink-0 w-8 h-8 rounded-lg bg-violet-500/20 dark:bg-violet-500/30 flex items-center justify-center">
<Icon name="material-symbols:info-outline-rounded" class="text-violet-600 dark:text-violet-400 text-lg"></Icon>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-violet-700 dark:text-violet-300 mb-1 select-none pointer-events-none">
AI 摘要{aiSummaryMeta.model && <span class="text-violet-600/70 dark:text-violet-300/70"> · {aiSummaryMeta.model}</span>}
</div>
<div class="text-sm text-black/70 dark:text-white/70 leading-relaxed">{aiSummaryMeta.summary}</div>
</div>
</div>
</div>
)}
<!-- always show cover as long as it has one --> <!-- always show cover as long as it has one -->
<!-- 使用自制 showcover 控制器控制头图的出现 --> <!-- 使用自制 showcover 控制器控制头图的出现 -->
{showcover && entry.data.image && {showcover && entry.data.image &&