update-blog.js 5.8 KB
#!/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);
});