/** * newsletter-generate.mjs * 每周一生成追光AI Newsletter * * 功能: * 1. 获取近14天热门工具(按浏览数排序) * 2. 获取近7天GitHub星值增长最多的技能 * 3. 获取近7天最活跃的社区话题 * 4. 生成 JSON + HTML 两个版本的输出 * 5. 输出到 public/newsletter/ * * 调度: 每周一上午 10:00 (crontab: 0 10 * * 1) */ import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import "dotenv/config"; // ─── Prisma 初始化 ──────────────────────────────────────────── const rawUrl = process.env.DATABASE_URL; if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); } const base = rawUrl.replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); const prisma = new PrismaClient({ adapter }); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // ─── 工具函数 ───────────────────────────────────────────────── function getWeekStart() { const now = new Date(); const day = now.getDay(); const diff = now.getDate() - day + (day === 0 ? -6 : 1); const monday = new Date(now.getFullYear(), now.getMonth(), diff); monday.setHours(0, 0, 0, 0); return monday; } function formatDate(date) { const d = new Date(date); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function formatNumber(n) { if (n >= 10000) return `${(n / 10000).toFixed(1)}万`; if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; return String(n); } async function main() { const startTime = new Date(); const now = new Date(); const weekStart = getWeekStart(); const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); console.log(`📰 [Newsletter] 开始生成 ${formatDate(startTime)} 的 Newsletter...`); // ── 1. 热门工具 ───────────────────────────────────────────── const hotTools = await prisma.tool.findMany({ where: { status: "published", createdAt: { gte: twoWeeksAgo }, }, select: { id: true, name: true, slug: true, description: true, logoUrl: true, viewCount: true, avgRating: true, category: { select: { name: true, slug: true } }, }, orderBy: { viewCount: "desc" }, take: 5, }); // ── 2. 热门技能(本周星值增长)────────────────────────────── const hotSkills = await prisma.skill.findMany({ where: { status: "published", starsChangeWeek: { gt: 0 }, }, select: { id: true, name: true, slug: true, description: true, logoUrl: true, githubStars: true, starsChangeWeek: true, sourceUrl: true, sourceType: true, category: { select: { name: true, slug: true } }, }, orderBy: { starsChangeWeek: "desc" }, take: 3, }); // ── 3. 热门话题 ───────────────────────────────────────────── const hotTopics = await prisma.forumTopic.findMany({ where: { isHidden: false, createdAt: { gte: weekStart }, }, select: { id: true, title: true, slug: true, replyCount: true, likeCount: true, viewCount: true, category: { select: { name: true, slug: true } }, }, orderBy: { replyCount: "desc" }, take: 3, }); // ── 4. 本周期数 ──────────────────────────────────────────── const issueNumber = Math.floor( (now.getTime() - new Date("2025-01-01").getTime()) / (7 * 24 * 60 * 60 * 1000) ); // ── 5. 构建 Newsletter 数据 ───────────────────────────────── const newsletter = { issue: issueNumber, date: formatDate(now), weekStart: formatDate(weekStart), weekEnd: formatDate(now), summary: `本周推荐 ${hotTools.length} 款热门AI工具、${hotSkills.length} 个上升技能、${hotTopics.length} 个社区热帖`, sections: { hotTools: hotTools.map((t) => ({ name: t.name, slug: t.slug, description: t.description?.substring(0, 120) || "", logoUrl: t.logoUrl, viewCount: formatNumber(t.viewCount), rating: t.avgRating?.toFixed(1) || "0.0", category: t.category?.name || "未分类", })), hotSkills: hotSkills.map((s) => ({ name: s.name, slug: s.slug, description: s.description?.substring(0, 120) || "", logoUrl: s.logoUrl, githubStars: formatNumber(s.githubStars), starsChange: `+${s.starsChangeWeek}`, sourceUrl: s.sourceUrl, sourceType: s.sourceType, category: s.category?.name || "未分类", })), hotTopics: hotTopics.map((t) => ({ title: t.title, slug: t.slug, replyCount: t.replyCount, likeCount: t.likeCount, viewCount: formatNumber(t.viewCount), category: t.category?.name || "综合", })), }, generatedAt: now.toISOString(), }; // ── 6. 写入文件 ───────────────────────────────────────────── const outDir = path.join(__dirname, "..", "public", "newsletter"); fs.mkdirSync(outDir, { recursive: true }); // JSON 输出 const jsonPath = path.join(outDir, "latest.json"); fs.writeFileSync(jsonPath, JSON.stringify(newsletter, null, 2), "utf-8"); console.log(` ✅ JSON 已写入: ${jsonPath}`); // HTML 输出 const html = buildHtml(newsletter); const htmlPath = path.join(outDir, "latest.html"); fs.writeFileSync(htmlPath, html, "utf-8"); console.log(` ✅ HTML 已写入: ${htmlPath}`); // 归档 JSON(按日期) const archiveDir = path.join(outDir, "archive"); fs.mkdirSync(archiveDir, { recursive: true }); const archivePath = path.join(archiveDir, `${formatDate(now)}.json`); fs.writeFileSync(archivePath, JSON.stringify(newsletter, null, 2), "utf-8"); const elapsed = Date.now() - startTime.getTime(); console.log(`✅ [Newsletter] 第 ${issueNumber} 期生成完成,耗时 ${elapsed}ms`); await prisma.$disconnect(); } function buildHtml(n) { const toolsHtml = n.sections.hotTools .map( (t) => `
${t.logoUrl ? `${t.name}` : ""}
${t.name} ${t.category}

${t.description}

👁 ${t.viewCount} 浏览 · ⭐ ${t.rating}
` ) .join(""); const skillsHtml = n.sections.hotSkills .map( (s) => `
${s.logoUrl ? `${s.name}` : ""}
${s.name} ${s.category}

${s.description}

⭐ ${s.githubStars} · 📈 ${s.starsChange} 本周
` ) .join(""); const topicsHtml = n.sections.hotTopics .map( (t) => `
${t.title} ${t.category}
💬 ${t.replyCount} 回复 · 👍 ${t.likeCount} 赞 · 👁 ${t.viewCount} 浏览
` ) .join(""); return ` 追光AI 周刊 #${n.issue} - ${n.date} ${toolsHtml ? ` ` : ""} ${skillsHtml ? ` ` : ""} ${topicsHtml ? ` ` : ""}

🔦 追光AI 周刊

第 ${n.issue} 期 · ${n.weekStart} ~ ${n.weekEnd}

${n.summary}

🔥 本周热门工具

${toolsHtml}

📈 技能上升榜

${skillsHtml}

💡 社区热帖

${topicsHtml}

查看往期周刊 · 追光AI

由追光AI自动生成于 ${n.generatedAt}

`; } main().catch((err) => { console.error("❌ [Newsletter] 生成失败:", err); process.exit(1); });