hexo-ai-summary-liushen
Version:
Hexo 插件:使用 AI(如 ChatGPT 或腾讯混元)自动为 Markdown 博文生成摘要,并将摘要写入 front-matter,可控制并发请求。
219 lines (187 loc) • 8.66 kB
JavaScript
const strip = require('./strip')
const ai = require('./ai')
const hexoFs = require('hexo-fs')
const nodeFs = require('fs').promises
const path = require('path')
const fm = require('hexo-front-matter')
const pLimit = require('p-limit')
const PLUGIN_PREFIX = '[Hexo-AI-Summary-LiuShen]'
// 日志等级枚举
const LOG_LEVELS = {
SILENT: 0, // 只输出错误和成功
NORMAL: 1, // 输出错误和摘要预览
VERBOSE: 2 // 输出调试信息
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key)
}
function getPositiveNumber(value, fallback) {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
}
function getNonNegativeNumber(value, fallback) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
}
function getLoggerLevel(value) {
return value === LOG_LEVELS.SILENT || value === LOG_LEVELS.NORMAL || value === LOG_LEVELS.VERBOSE
? value
: LOG_LEVELS.NORMAL
}
function getSummaryPreview(summary) {
return `${summary.slice(0, 10)}...`
}
async function writeFrontMatterSafely(filePath, frontMatter, mdContent) {
const dir = path.dirname(filePath)
const base = path.basename(filePath)
const suffix = `${process.pid}-${Date.now()}`
const tempPath = path.join(dir, `${base}.aisummary.${suffix}.tmp`)
const backupPath = path.join(dir, `${base}.aisummary.${suffix}.bak`)
const nextContent = `---\n${fm.stringify(frontMatter)}\n${mdContent}`
await hexoFs.writeFile(tempPath, nextContent)
let originalMoved = false
try {
await nodeFs.rename(filePath, backupPath)
originalMoved = true
await nodeFs.rename(tempPath, filePath)
} catch (error) {
if (originalMoved) {
try {
await nodeFs.rename(backupPath, filePath)
} catch (restoreError) {
throw new Error(`写入文章文件失败,且恢复原文件失败:${restoreError.message};原始错误:${error.message}`)
}
}
throw new Error(`写入文章文件失败,原文件未被覆盖:${error.message}`)
} finally {
await nodeFs.unlink(tempPath).catch(() => {})
await nodeFs.unlink(backupPath).catch(() => {})
}
}
function normalizeConfig(rawConfig = {}) {
const warnings = []
const hasMaxInputToken = hasOwn(rawConfig, 'max_input_token')
const hasDeprecatedMaxToken = hasOwn(rawConfig, 'max_token')
let maxInputToken = 1000
if (typeof rawConfig.max_input_token === 'number' && Number.isFinite(rawConfig.max_input_token) && rawConfig.max_input_token > 0) {
maxInputToken = rawConfig.max_input_token
} else if (typeof rawConfig.max_token === 'number' && Number.isFinite(rawConfig.max_token) && rawConfig.max_token > 0) {
maxInputToken = rawConfig.max_token
}
if (hasDeprecatedMaxToken) {
if (hasMaxInputToken) {
warnings.push(`${PLUGIN_PREFIX} 配置项 max_token 已废弃,请尽快升级为 max_input_token;当前将优先使用 max_input_token。`)
} else {
warnings.push(`${PLUGIN_PREFIX} 配置项 max_token 已废弃,请尽快升级为 max_input_token;当前版本仍兼容旧键。`)
}
}
if (rawConfig.thinking === true) {
warnings.push(`${PLUGIN_PREFIX} 已开启思考链,可能造成摘要失败,非必要不要开启。`)
}
return {
config: {
api: rawConfig.api,
token: rawConfig.token,
model: rawConfig.model,
prompt: typeof rawConfig.prompt === 'string' && rawConfig.prompt.trim()
? rawConfig.prompt
: '请为这些内容生成一个简短的摘要:',
ignoreRules: Array.isArray(rawConfig.ignoreRules) ? rawConfig.ignoreRules : [],
max_input_token: maxInputToken,
max_output_token: getPositiveNumber(rawConfig.max_output_token, 2000),
thinking: rawConfig.thinking === true,
summary_field: typeof rawConfig.summary_field === 'string' && rawConfig.summary_field.trim()
? rawConfig.summary_field
: 'summary',
logger: getLoggerLevel(rawConfig.logger),
concurrency: getPositiveNumber(rawConfig.concurrency, 2),
sleep_time: getNonNegativeNumber(rawConfig.sleep_time, 0),
enable: rawConfig.enable === true,
cover_all: rawConfig.cover_all === true
},
warnings
}
}
const rawConfig = hexo.config.aisummary || {}
const { config, warnings } = normalizeConfig(rawConfig)
warnings.forEach(message => console.warn(message))
if (!config.api) {
console.error(`${PLUGIN_PREFIX} 请在配置文件中设置 api`)
return
}
const limit = pLimit(config.concurrency)
const fieldName = config.summary_field
const defaultPrompt = config.prompt
const sleepTime = config.sleep_time
const logLevel = config.logger
if (logLevel >= LOG_LEVELS.VERBOSE) {
console.info(`${PLUGIN_PREFIX} 插件启动:field=${fieldName},concurrency=${config.concurrency},max_input_token=${config.max_input_token},max_output_token=${config.max_output_token},thinking=${config.thinking}`)
}
hexo.extend.filter.register('before_post_render', async function (data) {
if (data.layout != 'post' || typeof data.source !== 'string' || !data.source.startsWith('_posts/')) {
if (logLevel >= LOG_LEVELS.VERBOSE) {
console.info(`${PLUGIN_PREFIX} 跳过 ${data.title},不是文章页面`)
}
return data
}
return await limit(async () => {
if (!config.enable || data.is_summary === false) { // 感谢MCXiaoChen
if (logLevel >= LOG_LEVELS.VERBOSE) {
console.info(`${PLUGIN_PREFIX} 文章 ${data.title} 被标记为不进行摘要,跳过`)
}
return data
}
if (data[fieldName] && data[fieldName].length > 0 && config.cover_all !== true) {
if (logLevel >= LOG_LEVELS.VERBOSE) {
console.info(`${PLUGIN_PREFIX} 文章 ${data.title} 已经有摘要,跳过`)
}
return data
}
const content = strip(data.content, data.title, config)
const filePath = path.join(this.source_dir, data.source)
if (logLevel >= LOG_LEVELS.VERBOSE) {
console.info(`${PLUGIN_PREFIX} 开始生成摘要:title=${data.title},source=${data.source},model=${config.model || 'gpt-3.5-turbo'},thinking=${config.thinking}`)
}
try {
const frontMatter = fm.parse(await hexoFs.readFile(filePath))
const mdContent = typeof frontMatter._content === 'string'
? frontMatter._content.replace(/^\n+|\n+$/g, '')
: ''
delete frontMatter._content
const aiContent = await ai(
config.token,
config.api,
config.model,
content,
defaultPrompt,
config.max_output_token,
config.thinking,
logLevel
)
if (!aiContent || aiContent.length < 10 || /[\n#$%]/.test(aiContent)) {
console.error(`${PLUGIN_PREFIX} 生成摘要失败:${data.title}\nAI 返回的摘要内容不符合要求(长度不足或包含非法字符)`)
if (logLevel >= LOG_LEVELS.VERBOSE) {
console.info(`${PLUGIN_PREFIX} 异常摘要内容:${aiContent}`)
}
return data
}
frontMatter[fieldName] = aiContent
await writeFrontMatterSafely(filePath, frontMatter, mdContent)
data[fieldName] = aiContent
if (logLevel === LOG_LEVELS.SILENT) {
console.info(`${PLUGIN_PREFIX} 摘要 ${data.title} 完成`)
} else if (logLevel === LOG_LEVELS.NORMAL) {
console.info(`${PLUGIN_PREFIX} ${data.title} 摘要:${getSummaryPreview(aiContent)}`)
} else {
console.info(`${PLUGIN_PREFIX} 摘要 ${data.title} 完成:${aiContent}`)
}
} catch (err) {
console.error(`${PLUGIN_PREFIX} 生成摘要失败:${data.title}\n${err.message}`)
}
if (sleepTime > 0) {
if (logLevel >= LOG_LEVELS.VERBOSE) {
console.info(`${PLUGIN_PREFIX} 处理完毕一篇文章,休眠 ${sleepTime} 毫秒...`)
}
await new Promise(resolve => setTimeout(resolve, sleepTime))
}
return data
})
})