From 2608a54bca9d8360902a09b248e2a4e761e3267f Mon Sep 17 00:00:00 2001 From: Ad-closeNN <1709301095@qq.com> Date: Fri, 1 May 2026 00:40:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(aiSummary):=20AI=20=E6=96=87=E7=AB=A0?= =?UTF-8?q?=E6=80=BB=E7=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astro.config.mjs | 2 +- package.json | 3 + scripts/summary.js | 459 ++++++++++++++++++ src/content/posts/captcha.md | 2 + src/content/posts/chrome-manifestv2-block.md | 3 +- src/content/posts/cloudflare-discord-bot.md | 3 +- src/content/posts/codex-warp.md | 2 + src/content/posts/custom-frontmatter.md | 3 +- .../posts/directly-connect-telegram.md | 3 +- src/content/posts/domain-email.md | 3 +- src/content/posts/folo_verify.md | 3 +- src/content/posts/giscus.md | 3 +- src/content/posts/hCaptcha-recaptchacompat.md | 3 +- src/content/posts/kugou-music-download.md | 3 +- src/content/posts/new-domain.md | 3 +- src/content/posts/newtab_link.md | 3 +- .../posts/pcl-intelligence-homepage.md | 3 +- src/content/posts/umami.md | 3 +- src/pages/posts/[...slug].astro | 71 +++ 19 files changed, 564 insertions(+), 14 deletions(-) create mode 100644 scripts/summary.js diff --git a/astro.config.mjs b/astro.config.mjs index 7cba635..7e06107 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -42,7 +42,7 @@ export default defineConfig({ // when the Tailwind class `transition-all` is used containers: ["main", "#toc"], smoothScrolling: true, - cache: true, + cache: process.env.NODE_ENV !== "development", preload: true, accessibility: true, updateHead: true, diff --git a/package.json b/package.json index 13a0c7e..1b3397e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "astro": "astro", "type-check": "tsc --noEmit --isolatedDeclarations", "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", "lint": "biome check --write ./src", "preinstall": "npx only-allow pnpm" diff --git a/scripts/summary.js b/scripts/summary.js new file mode 100644 index 0000000..5a4c48a --- /dev/null +++ b/scripts/summary.js @@ -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) +}) \ No newline at end of file diff --git a/src/content/posts/captcha.md b/src/content/posts/captcha.md index 1f02443..87a8233 100644 --- a/src/content/posts/captcha.md +++ b/src/content/posts/captcha.md @@ -8,6 +8,8 @@ category: 杂项 draft: false showcover: false customcover: /public/pic/cloudflare-turnstile-verify-error.png +aiSummary: "本文介绍了多种验证码实现与集成要点,涵盖 Google reCaptcha、Cloudflare Turnstile、hCaptcha 及 Arkose Labs 的使用场景、 key 配置与前端嵌入方式,帮助开发者快速了解并对比不同方案的适用性与实现流程。" +aiSummaryModel: "gpt-5-nano" --- diff --git a/src/content/posts/chrome-manifestv2-block.md b/src/content/posts/chrome-manifestv2-block.md index b19141f..2ee9e2e 100644 --- a/src/content/posts/chrome-manifestv2-block.md +++ b/src/content/posts/chrome-manifestv2-block.md @@ -8,8 +8,9 @@ showcover: false customcover: /public/pic/chrome-ext-force-custom-font.png category: 教程 draft: false +aiSummary: "本文介绍了 Chrome 已停止对 Manifest V2 的支持及其时间线,给出通过开启 flags、添加启动参数或切换浏览器(如 Firefox)等临时解决方案与注意事项。" +aiSummaryModel: "gpt-5-nano" --- - # 前言 :::tip[提示] diff --git a/src/content/posts/cloudflare-discord-bot.md b/src/content/posts/cloudflare-discord-bot.md index 27e9cbd..2944453 100644 --- a/src/content/posts/cloudflare-discord-bot.md +++ b/src/content/posts/cloudflare-discord-bot.md @@ -7,8 +7,9 @@ image: /public/pic/discord-bot-cln-bot-profile.png customcover: /public/pic/discord-bot-cln-bot-profile-wide.png showcover: false category: 教程 +aiSummary: "本文介绍了如何在 Cloudflare Workers 上实现 Discord 与 Telegram 机器人,包括创建机器人、获取必要信息、配置秘密、上传并部署 Worker、设置端点 URL 以及注册命令,强调安全保存密钥并可直接复制粘贴配置,实现快速部署与未来扩展。" +aiSummaryModel: "gpt-5-nano" --- - :::tip[提示] ~~赛博菩萨~~ Cloudflare Workers 不仅能运行 Discord 机器人,还能运行 Telegram 机器人。 ::: diff --git a/src/content/posts/codex-warp.md b/src/content/posts/codex-warp.md index 7d5a51f..8690152 100644 --- a/src/content/posts/codex-warp.md +++ b/src/content/posts/codex-warp.md @@ -8,6 +8,8 @@ customcover: /public/pic/codex-warp-banner.png tags: ["Codex", "Warp", "注册机", "AI"] category: '教程' outdated: true +aiSummary: "本文介绍了通过注册机注册 OpenAI 免费模型的风险、被封禁现状,以及利用 Cloudflare Warp 提高注册成功率的做法与具体使用方法,以及相关注意事项与测试IP示例。" +aiSummaryModel: "gpt-5-nano" --- # 前言 diff --git a/src/content/posts/custom-frontmatter.md b/src/content/posts/custom-frontmatter.md index 7db6f64..804d562 100644 --- a/src/content/posts/custom-frontmatter.md +++ b/src/content/posts/custom-frontmatter.md @@ -7,8 +7,9 @@ image: "/public/pic/custom-frontmatter-cover.svg" category: 教程 showcover: false 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),那么这篇文章或许适合。 diff --git a/src/content/posts/directly-connect-telegram.md b/src/content/posts/directly-connect-telegram.md index e372831..96b959a 100644 --- a/src/content/posts/directly-connect-telegram.md +++ b/src/content/posts/directly-connect-telegram.md @@ -6,8 +6,9 @@ description: 2025/8/8 早上,Telegram 竟可在国内被访问,不过现在 image: /public/pic/telegram-devices-panel-china.jpg category: 记录 draft: false +aiSummary: "本文介绍了 Telegram DC5 部分地区直连的尝试与 GFW 窗口变化,以及 Cloudflare Warp 的国内直连可能性与相关定位误差。" +aiSummaryModel: "gpt-5-nano" --- - # Telegram 能直连了? ### 目前已被墙 [t.me/zaihuapd/34943](https://t.me/zaihuapd/34943) diff --git a/src/content/posts/domain-email.md b/src/content/posts/domain-email.md index e1a0d64..7091456 100644 --- a/src/content/posts/domain-email.md +++ b/src/content/posts/domain-email.md @@ -6,8 +6,9 @@ description: 通过阿里云免费企业邮箱获得一个类似于 admin@github image: /public/pic/aliyun-email-mxcheck.png category: 教程 draft: false +aiSummary: "本文介绍了如何利用阿里云免费企业邮箱,用自有域名收发邮件的流程与注意事项,以及从域名解析到管理员设置、MX/DKIM验证等前置配置的要点和实际使用路径。" +aiSummaryModel: "gpt-5-nano" --- - # 前言 自从你上网冲浪接触邮箱以来,你或多或少都会见过这种邮箱: - noreply@github.com diff --git a/src/content/posts/folo_verify.md b/src/content/posts/folo_verify.md index 75ec3c3..d4e144a 100644 --- a/src/content/posts/folo_verify.md +++ b/src/content/posts/folo_verify.md @@ -6,8 +6,9 @@ description: Folo(Follow)是一个 RSS 订阅源合集软件,用它可以 image: /public/pic/folo-rss-verify-panel.png category: 教程 draft: false +aiSummary: "本文介绍了通过纯文本、描述和 RSS 标签三种方式,将订阅源与 Folo 账户绑定并完成认证的具体步骤与示例。" +aiSummaryModel: "gpt-5-nano" --- - # 前言 :::tip[提示] Folo 也就是之前的 Follow,只不过改名成了 Folo。 diff --git a/src/content/posts/giscus.md b/src/content/posts/giscus.md index 670a70c..a23807e 100644 --- a/src/content/posts/giscus.md +++ b/src/content/posts/giscus.md @@ -7,8 +7,9 @@ image: /public/pic/giscus-preview.png customcover: /public/pic/giscus-no-content.png showcover: false category: 教程 +aiSummary: "本文介绍了如何在博客��接入 Giscus 评论区,包含在博客仓库或专用仓库存放、获取并粘贴 JS 代码、将评论区嵌入特定页面与页面模板中的具体实现,以及 origin 配置与简单的反垃圾思路。" +aiSummaryModel: "gpt-5-nano" --- - # 前言 如果你的静态博客没有评论区,又不想自己搭建一个评论系统,那么 Giscus 就是一个不错的选择。 接下来就手把手教你添加这个[插](https://giscus.app/client.js)件并配置它。 diff --git a/src/content/posts/hCaptcha-recaptchacompat.md b/src/content/posts/hCaptcha-recaptchacompat.md index 90f43c5..db94569 100644 --- a/src/content/posts/hCaptcha-recaptchacompat.md +++ b/src/content/posts/hCaptcha-recaptchacompat.md @@ -5,8 +5,9 @@ tags: ["网站", "验证"] description: 一次出3个 hCaptcha?瞧瞧你干的好事! image: /public/pic/hCaptcha-localhost-errkey.png category: 记录 +aiSummary: "本文介绍了在同时使用 Google reCaptcha 与 hCaptcha 时,hCaptcha 会尝试兼容导致界面混乱的情况,并给出通过将脚本 URL 加上 recaptchacompat=off 关闭兼容化的简单解决方法。" +aiSummaryModel: "gpt-5-nano" --- - # 强兼 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-recaptchacompat-origin](/public/pic/hcaptcha-recaptchacompat-origin.png) diff --git a/src/content/posts/kugou-music-download.md b/src/content/posts/kugou-music-download.md index 8ebeefb..9e7bedd 100644 --- a/src/content/posts/kugou-music-download.md +++ b/src/content/posts/kugou-music-download.md @@ -7,8 +7,9 @@ customcover: /public/pic/kugou-music-nodejs-api-self-hosting-menu.png showcover: false tags: ["酷狗音乐", "狠活"] category: '教程' +aiSummary: "本文介绍了通过开源酷狗API解析音乐URL的后端搭建、登录领取概念版VIP及通过FileHash下载高音质音乐的关键步骤与注意事项。" +aiSummaryModel: "gpt-5-nano" --- - # 前言 :::caution[警告] 1. 本教程仅供学习交流,下载的音乐文件请于24小时内删除。 diff --git a/src/content/posts/new-domain.md b/src/content/posts/new-domain.md index 30ccdf6..0ac56a9 100644 --- a/src/content/posts/new-domain.md +++ b/src/content/posts/new-domain.md @@ -7,8 +7,9 @@ description: 获得了一个新域名 *这使我充满了决心 image: /public/pic/domain-inlist-adclosenn.top.png category: 记录 draft: false +aiSummary: "本文介绍了作者通过购买 .top 域名并搭建博客的过程,比较不同域名注册商和支付体验,分享域名被 XYZ 服务器暂停、解封的经历以及教训与使用场景。" +aiSummaryModel: "gpt-5-nano" --- - # .dev 域名 :::note[感谢] 感谢 [MC_Kero blog](https://blog.mckero.com) 的站长 [MC_Kero](https://github.com/MCKero6423) 提供的 [GitHub Student Developer Pack](https://education.github.com/pack) 免费**一年域名**福利!~~都给我去 Follow 他~~ diff --git a/src/content/posts/newtab_link.md b/src/content/posts/newtab_link.md index 87aca9e..08ea0ba 100644 --- a/src/content/posts/newtab_link.md +++ b/src/content/posts/newtab_link.md @@ -7,8 +7,9 @@ image: /public/pic/newtab-link-npm-plugin-info-1.png customcover: /public/pic/newtab-link-npm-plugin-info-2.png showcover: false category: 教程 +aiSummary: "本文介绍了如何使用 rehype-external-links 插件,在 Astro 项目中将文章内外部链接统一设置为在新标签页打开,包含安装与在配置中的具体实现要点与示例。" +aiSummaryModel: "gpt-5-nano" --- - # 前言 看标题可能不明白在说什么。但是如果我放出这两个链接让你点,你应该知道是什么意思。 diff --git a/src/content/posts/pcl-intelligence-homepage.md b/src/content/posts/pcl-intelligence-homepage.md index 6588639..a735b76 100644 --- a/src/content/posts/pcl-intelligence-homepage.md +++ b/src/content/posts/pcl-intelligence-homepage.md @@ -6,8 +6,9 @@ description: 在 PCL 启动器上面询问大模型?当然可以~ image: /public/pic/pcl-gemini-mixed.png category: 教程 draft: false +aiSummary: "本文介绍了多 API Key 模式的原理、适用场景及如何在 Google Gemini API 的速率限制下通过多钥匙并用来提升请求量。" +aiSummaryModel: "gpt-5-nano" --- - diff --git a/src/content/posts/umami.md b/src/content/posts/umami.md index 0265dad..4660062 100644 --- a/src/content/posts/umami.md +++ b/src/content/posts/umami.md @@ -6,8 +6,9 @@ description: Umami 是一个精美的网站统计分析工具,我们可以自 image: /public/pic/umami-screenshot.png category: 教程 draft: false +aiSummary: "本文介绍了自托管 Umami 的整体思路、所需条件、以 Neon/Netlify 为例的具体部署步骤,以及登录与注意事项,帮助搭建独立的网站统计工具。" +aiSummaryModel: "gpt-5-nano" --- - # 自行托管 Umami ## 什么是 Umami diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index dd12001..0609639 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -34,6 +34,61 @@ const lastUpdatedAt = entry.data.updated ?? entry.data.published; const lastUpdatedLabel = formatDateToYYYYMMDD(lastUpdatedAt); const lastUpdatedTimestamp = lastUpdatedAt.getTime(); +const postSources = import.meta.glob("../../content/posts/**/*.{md,mdx}", { + query: "?raw", + import: "default", + eager: true, +}) as Record; +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 = { "@context": "https://schema.org", "@type": "BlogPosting", @@ -177,6 +232,22 @@ const isOutdated = entry.data.outdated; {!entry.data.image &&
} + {aiSummaryMeta.summary && ( +
+
+
+ +
+
+
+ AI 摘要{aiSummaryMeta.model && · {aiSummaryMeta.model}} +
+
{aiSummaryMeta.summary}
+
+
+
+ )} + {showcover && entry.data.image &&