feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库

This commit is contained in:
ZhuiGuangAI Dev
2026-10-02 18:52:49 +08:00
parent b44601bb66
commit 8205ae709c
1185 changed files with 49841 additions and 12802 deletions
+305
View File
@@ -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);
});