feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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) => `
|
||||
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
${t.logoUrl ? `<img src="${t.logoUrl}" alt="${t.name}" width="48" height="48" style="border-radius:10px;object-fit:contain;border:1px solid #e5e7eb;">` : ""}
|
||||
<div>
|
||||
<a href="https://www.zhuig.com/tools/${t.slug}" style="font-size:16px;font-weight:600;color:#7c3aed;text-decoration:none;">${t.name}</a>
|
||||
<span style="margin-left:8px;font-size:12px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${t.category}</span>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:#6b7280;">${t.description}</p>
|
||||
<span style="font-size:12px;color:#9ca3af;">👁 ${t.viewCount} 浏览 · ⭐ ${t.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const skillsHtml = n.sections.hotSkills
|
||||
.map(
|
||||
(s) => `
|
||||
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:12px;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
${s.logoUrl ? `<img src="${s.logoUrl}" alt="${s.name}" width="48" height="48" style="border-radius:10px;object-fit:contain;border:1px solid #e5e7eb;">` : ""}
|
||||
<div>
|
||||
<a href="https://www.zhuig.com/skills/${s.slug}" style="font-size:16px;font-weight:600;color:#7c3aed;text-decoration:none;">${s.name}</a>
|
||||
<span style="margin-left:8px;font-size:12px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${s.category}</span>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:#6b7280;">${s.description}</p>
|
||||
<span style="font-size:12px;color:#9ca3af;">⭐ ${s.githubStars} · 📈 ${s.starsChange} 本周</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const topicsHtml = n.sections.hotTopics
|
||||
.map(
|
||||
(t) => `
|
||||
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:12px;">
|
||||
<a href="https://www.zhuig.com/forum/${t.slug}" style="font-size:16px;font-weight:600;color:#7c3aed;text-decoration:none;">${t.title}</a>
|
||||
<span style="margin-left:8px;font-size:12px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${t.category}</span>
|
||||
<div style="margin-top:4px;font-size:12px;color:#9ca3af;">
|
||||
💬 ${t.replyCount} 回复 · 👍 ${t.likeCount} 赞 · 👁 ${t.viewCount} 浏览
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>追光AI 周刊 #${n.issue} - ${n.date}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;margin:0 auto;background:#fff;">
|
||||
<tr>
|
||||
<td style="padding:40px 32px 24px;text-align:center;background:linear-gradient(135deg,#7c3aed,#a855f7);">
|
||||
<h1 style="color:#fff;margin:0;font-size:28px;">🔦 追光AI 周刊</h1>
|
||||
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px;">第 ${n.issue} 期 · ${n.weekStart} ~ ${n.weekEnd}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p style="color:#4b5563;font-size:15px;line-height:1.6;margin:0;">${n.summary}</p>
|
||||
</td>
|
||||
</tr>
|
||||
${toolsHtml ? `
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<h2 style="color:#1f2937;font-size:20px;margin:0 0 16px;">🔥 本周热门工具</h2>
|
||||
${toolsHtml}
|
||||
</td>
|
||||
</tr>` : ""}
|
||||
${skillsHtml ? `
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<h2 style="color:#1f2937;font-size:20px;margin:0 0 16px;">📈 技能上升榜</h2>
|
||||
${skillsHtml}
|
||||
</td>
|
||||
</tr>` : ""}
|
||||
${topicsHtml ? `
|
||||
<tr>
|
||||
<td style="padding:0 32px 24px;">
|
||||
<h2 style="color:#1f2937;font-size:20px;margin:0 0 16px;">💡 社区热帖</h2>
|
||||
${topicsHtml}
|
||||
</td>
|
||||
</tr>` : ""}
|
||||
<tr>
|
||||
<td style="padding:24px 32px;text-align:center;border-top:1px solid #e5e7eb;">
|
||||
<p style="color:#9ca3af;font-size:12px;margin:0;">
|
||||
<a href="https://www.zhuig.com/newsletter" style="color:#7c3aed;text-decoration:none;">查看往期周刊</a> ·
|
||||
<a href="https://www.zhuig.com" style="color:#7c3aed;text-decoration:none;">追光AI</a>
|
||||
</p>
|
||||
<p style="color:#d1d5db;font-size:11px;margin:4px 0 0;">由追光AI自动生成于 ${n.generatedAt}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ [Newsletter] 生成失败:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user