Files
Ad-closeNN f283dc2d40 fix(summary): 修复 AI 摘要脚本删除 ::: 标记和换行符不一致
- bodyContent 不再写回文件,避免 ::: admonition 被 strip
- 写文件时检测原文换行符(CRLF/LF),避免混用
- 换用 deepseek-v4-flash-free 免费模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-05-16 23:32:58 +08:00

459 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = "deepseek-v4-flash-free"
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 bodyOriginal = frontmatterMatch
? fileContent.slice(frontmatterMatch[0].length).trimStart()
: fileContent;
const bodyForAI = stripAdmonitionMarkers(bodyOriginal)
const eol = fileContent.includes("\r\n") ? "\r\n" : "\n"
console.log("\n生成 AI 摘要中...\n")
const apiKey = "public"
const apiUrl = "opencode.ai/zen"
const prompt = `请为以下博客文章生成一个不超过100字的中文摘要。
输出要求:
- 只输出摘要正文,不要解释,不要加标题。
- 摘要必须以“本文介绍了”开头。
- 使用一句话概括文章主题、关键内容和用途。
- 不要输出 Markdown 语法、admonition 标记或提示框类型,例如 :::warning、:::caution、[!warning]、警告、注意。
文章内容:
${bodyForAI}`
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 = `---${eol}${newFrontmatter}${eol}---${eol}${bodyOriginal}`
} else {
newContent = `---${eol}${formatFrontmatterField("aiSummary", summary)}${eol}${formatFrontmatterField("aiSummaryModel", summaryModel)}${eol}---${eol}${bodyOriginal}`
}
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)
})