#!/usr/bin/env node /** * update-blog.js — 定时调用 Blog Writer Skill 生成更新日志 * * 用法: * node scripts/update-blog.js # 自动检测上次运行时间 * node scripts/update-blog.js --since 2026-03-15 # 指定起始日期 * node scripts/update-blog.js --dry-run # 预览模式,不写入文件 * * Cron 示例 (每天凌晨 2 点运行): * 0 2 * * * cd /path/to/lemonape && node scripts/update-blog.js >> .logs/blog-cron.log 2>&1 */ const { execSync, execFile } = require('child_process'); const fs = require('fs'); const path = require('path'); // ─── 配置 ─── const BLOG_DIR = path.resolve(__dirname, '../data/blog'); const LAST_RUN_FILE = path.join(BLOG_DIR, '.last-run'); const SKILL_PATH = path.resolve(__dirname, '../skills/blog_writer/SKILL.md'); const MIN_COMMITS = 2; // 少于此数量的 commits 不生成 Blog // ─── 参数解析 ─── const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); const sinceIdx = args.indexOf('--since'); let sinceDate = null; if (sinceIdx !== -1 && args[sinceIdx + 1]) { sinceDate = args[sinceIdx + 1]; } async function main() { console.log(`[blog-gen] 🚀 开始生成 Blog...`); console.log(`[blog-gen] Blog 目录: ${BLOG_DIR}`); console.log(`[blog-gen] Dry run: ${dryRun}`); // 确保目录存在 fs.mkdirSync(BLOG_DIR, { recursive: true }); // 确定起始时间 if (!sinceDate) { if (fs.existsSync(LAST_RUN_FILE)) { sinceDate = fs.readFileSync(LAST_RUN_FILE, 'utf-8').trim(); } else { // 首次运行,取 7 天前 const d = new Date(); d.setDate(d.getDate() - 7); sinceDate = d.toISOString().split('T')[0]; } } const today = new Date().toISOString().split('T')[0]; console.log(`[blog-gen] 时间范围: ${sinceDate} ~ ${today}`); // 获取 Git commits let commits; try { commits = execSync( `git log --since="${sinceDate}" --until="${today} 23:59:59" --oneline --no-merges`, { cwd: path.resolve(__dirname, '..'), encoding: 'utf-8' } ).trim(); } catch (e) { console.error('[blog-gen] ❌ git log 失败:', e.message); process.exit(1); } if (!commits) { console.log('[blog-gen] ℹ️ 没有新 commits,跳过生成。'); updateLastRun(today); return; } const commitLines = commits.split('\n').filter(Boolean); console.log(`[blog-gen] 找到 ${commitLines.length} 个 commits`); if (commitLines.length < MIN_COMMITS) { console.log(`[blog-gen] ℹ️ Commits 数量 (${commitLines.length}) 少于阈值 (${MIN_COMMITS}),跳过。`); return; } // 获取文件变更统计 let diffStat; try { diffStat = execSync( `git diff --stat --since="${sinceDate}" --until="${today} 23:59:59" HEAD`, { cwd: path.resolve(__dirname, '..'), encoding: 'utf-8' } ).trim(); } catch { diffStat = '(统计不可用)'; } // 构建 Skill 输入 const skillInput = [ '## 时间范围', `${sinceDate} ~ ${today}`, '', '## Git Commits', commits, '', '## 变更文件统计', diffStat, ].join('\n'); console.log('[blog-gen] 📋 Skill 输入:'); console.log(skillInput.substring(0, 500) + (skillInput.length > 500 ? '...' : '')); if (dryRun) { console.log('[blog-gen] 🏁 Dry run 模式,不调用 Skill。'); console.log('[blog-gen] 以上输入将被发送给 blog_writer Skill。'); return; } // 调用 Claude CLI + Blog Writer Skill console.log('[blog-gen] 🤖 调用 blog_writer Skill...'); const skillContent = fs.readFileSync(SKILL_PATH, 'utf-8'); const prompt = [ '请按照以下 Skill 规范生成一篇更新日志 Blog:', '', '--- Skill 规范 ---', skillContent, '', '--- 输入数据 ---', skillInput, '', '请直接输出完整的 markdown 文件内容(包含 frontmatter),不需要其他说明。', ].join('\n'); try { const blogContent = await callClaude(prompt); if (!blogContent || blogContent.length < 50) { console.error('[blog-gen] ❌ Skill 输出为空或太短,跳过。'); return; } // 生成文件名 const slug = generateSlug(blogContent, today); const filename = `${slug}.md`; const filepath = path.join(BLOG_DIR, filename); if (fs.existsSync(filepath)) { console.log(`[blog-gen] ⚠️ 文件 ${filename} 已存在,跳过。`); return; } fs.writeFileSync(filepath, blogContent, 'utf-8'); console.log(`[blog-gen] ✅ Blog 已生成: ${filename}`); console.log(`[blog-gen] 文件大小: ${blogContent.length} 字符`); updateLastRun(today); } catch (e) { console.error('[blog-gen] ❌ Skill 调用失败:', e.message); process.exit(1); } } /** * 调用 Claude CLI */ function callClaude(prompt) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Claude CLI 超时 (60s)')), 60000); execFile('claude', ['--print', '-p', prompt], { timeout: 60000, maxBuffer: 1024 * 1024 }, (err, stdout) => { clearTimeout(timeout); if (err) return reject(err); resolve((stdout || '').trim()); }); }); } /** * 从 Blog 内容中提取 slug */ function generateSlug(content, date) { // 尝试从 frontmatter 提取 title_en const match = content.match(/title_en:\s*["']?(.+?)["']?\s*$/m); if (match) { const slug = match[1] .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .substring(0, 50); return `${date}-${slug}`; } return `${date}-update`; } /** * 更新上次运行时间戳 */ function updateLastRun(date) { fs.writeFileSync(LAST_RUN_FILE, date, 'utf-8'); console.log(`[blog-gen] Last run 更新为: ${date}`); } main().catch(e => { console.error('[blog-gen] 💥 未预期错误:', e); process.exit(1); });