/** * weekly-recommend-email.mjs * 每周一个性化推荐邮件 * * 功能: * 1. 分析每个用户的兴趣(收藏 + 浏览历史 + 社区活动) * 2. 基于兴趣匹配推荐 AI 工具 * 3. 生成个性化 HTML 邮件 * 4. 输出到 data/emails/weekly-recommend-{date}.json * * 调度: 每周一上午 10:30 (crontab: 30 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"; import { withRetry } from "./lib/retry.mjs"; import { cacheGet, cacheSet, disconnectRedis } from "./lib/redis-cache.mjs"; // ─── 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 getWeekNumber(d) { const startOfYear = new Date(d.getFullYear(), 0, 1); const diff = d - startOfYear; const oneWeek = 7 * 24 * 60 * 60 * 1000; return Math.ceil((diff / oneWeek + startOfYear.getDay() + 1) / 7); } function truncate(str, maxLen) { if (!str) return ""; return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str; } function getBaseUrl() { return process.env.NEXT_PUBLIC_BASE_URL || "https://zhuiguang.ai"; } // ─── 兴趣分析 ───────────────────────────────────────────────── /** * 分析单个用户的兴趣偏好,返回按分数排序的类别列表。 * * 数据来源: * - Favorite 表(权重 3)→ 明确喜欢 * - BrowsingHistory 表(权重 1)→ 浏览过 * - ForumTopic 表(通过论坛板块名称与工具类别名称的语义匹配,权重 1) * * 返回格式: [{ categoryId: number, categoryName: string, categorySlug: string, score: number }] */ async function analyzeUserInterests(userId) { const categoryScores = {}; function addScore(categoryId, categoryName, categorySlug, weight) { if (!categoryId) return; const key = categoryId; if (!categoryScores[key]) { categoryScores[key] = { categoryId, categoryName, categorySlug, score: 0, sources: new Set() }; } categoryScores[key].score += weight; } // 1. 收藏的工具 → 类别映射(权重 3) const favorites = await prisma.favorite.findMany({ where: { userId, toolId: { not: null } }, include: { tool: { select: { categoryId: true, category: { select: { id: true, name: true, slug: true } }, }, }, }, take: 200, }); for (const fav of favorites) { if (fav.tool?.category) { addScore(fav.tool.category.id, fav.tool.category.name, fav.tool.category.slug, 3); categoryScores[fav.tool.category.id].sources.add("favorite"); } } // 2. 浏览历史 → 类别映射(权重 1) const history = await prisma.browsingHistory.findMany({ where: { userId, toolId: { not: null } }, include: { tool: { select: { categoryId: true, category: { select: { id: true, name: true, slug: true } }, }, }, }, take: 500, }); for (const h of history) { if (h.tool?.category) { addScore(h.tool.category.id, h.tool.category.name, h.tool.category.slug, 1); categoryScores[h.tool.category.id].sources.add("browsing"); } } // 3. 社区发帖 → 尝试通过论坛板块名称匹配工具类别 // ForumCategory 名称与 Category 名称可能有重叠,做模糊匹配 const forumTopics = await prisma.forumTopic.findMany({ where: { userId }, include: { category: { select: { id: true, name: true, slug: true } }, }, take: 200, }); // 收集所有工具类别,用于名称匹配 const allToolCategories = await prisma.category.findMany({ where: { parentId: null }, select: { id: true, name: true, slug: true }, }); const forumCategoryNames = new Set(); for (const ft of forumTopics) { if (ft.category) { forumCategoryNames.add(ft.category.name.toLowerCase()); } } // 简单的关键词匹配:论坛板块名包含工具类别名,或反之 for (const tc of allToolCategories) { const tcLower = tc.name.toLowerCase(); for (const fcn of forumCategoryNames) { if (fcn.includes(tcLower) || tcLower.includes(fcn)) { addScore(tc.id, tc.name, tc.slug, 2); categoryScores[tc.id].sources.add("forum"); break; } } } // 按分数降序排列 const sorted = Object.values(categoryScores) .sort((a, b) => b.score - a.score) .map(({ categoryId, categoryName, categorySlug, score, sources }) => ({ categoryId, categoryName, categorySlug, score: Math.round(score), sources: [...sources], })); return sorted; } // ─── 推荐引擎 ───────────────────────────────────────────────── /** * Section 1: "基于你的兴趣推荐" * 从用户感兴趣的前 3 个类别中,挑选他们尚未收藏/浏览过的工具(最多 3 个) */ async function recommendFromInterests(userId, topCategories, excludeToolIds, limit = 3) { if (topCategories.length === 0) return []; const categoryIds = topCategories.slice(0, 3).map((c) => c.categoryId); const tools = await prisma.tool.findMany({ where: { categoryId: { in: categoryIds }, status: "published", id: { notIn: excludeToolIds }, }, include: { category: { select: { name: true, slug: true } }, }, orderBy: { viewCount: "desc" }, take: limit * 3, // fetch more to deduplicate across categories }); // Deduplicate and pick distinct tools const seen = new Set(); const result = []; for (const t of tools) { if (seen.has(t.id)) continue; seen.add(t.id); result.push(t); if (result.length >= limit) break; } return result; } /** * Section 2: "本周热门工具" * 过去 30 天内发布的最热门工具(按 viewCount),排除用户已看过的 */ async function recommendTrendingTools(excludeToolIds, limit = 3) { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const tools = await prisma.tool.findMany({ where: { status: "published", createdAt: { gte: thirtyDaysAgo }, id: { notIn: excludeToolIds }, }, include: { category: { select: { name: true, slug: true } }, }, orderBy: { viewCount: "desc" }, take: limit, }); return tools; } /** * Section 3: "你可能错过的新工具" * 用户感兴趣类别中最近 30 天新发布的工具(最多 2 个) */ async function recommendNewTools(userId, topCategories, excludeToolIds, limit = 2) { if (topCategories.length === 0) return []; const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const categoryIds = topCategories.slice(0, 3).map((c) => c.categoryId); const tools = await prisma.tool.findMany({ where: { categoryId: { in: categoryIds }, status: "published", createdAt: { gte: thirtyDaysAgo }, id: { notIn: excludeToolIds }, }, include: { category: { select: { name: true, slug: true } }, }, orderBy: { createdAt: "desc" }, take: limit, }); return tools; } // ─── 获取用户已交互的工具 ID 集合 ──────────────────────────── async function getUserInteractedToolIds(userId) { const [favs, history] = await Promise.all([ prisma.favorite.findMany({ where: { userId, toolId: { not: null } }, select: { toolId: true }, }), prisma.browsingHistory.findMany({ where: { userId, toolId: { not: null } }, select: { toolId: true }, }), ]); return new Set([ ...favs.map((f) => f.toolId).filter(Boolean), ...history.map((h) => h.toolId).filter(Boolean), ]); } // ─── HTML 邮件模板 ──────────────────────────────────────────── /** * 生成工具卡片的 HTML 片段 */ function renderToolCard(tool, baseUrl) { const logoSrc = tool.logoUrl || `${baseUrl}/default-tool-logo.png`; const detailUrl = `${baseUrl}/tools/${tool.slug}`; const desc = truncate(tool.description, 80); return `
${tool.name}

${tool.name}

${tool.category?.name || ""}

${desc}

查看详情
`; } /** * 生成完整 HTML 邮件 */ function renderEmailHtml({ userName, weekNumber, dateStr, interestCategories, interestTools, trendingTools, newTools, unsubscribeUrl, baseUrl }) { const displayName = userName || "AI 探索者"; const sortedCategories = (interestCategories || []).slice(0, 5); return ` 追光AI 周报 | 为你精选的 AI 工具

追光AI · 第 ${weekNumber} 周

为你精选的 AI 工具

${dateStr}

${displayName},你好!

根据你的收藏和浏览历史,我们为你精心挑选了以下 AI 工具。以下是本周的个性化推荐:

${sortedCategories.length > 0 ? `

你的兴趣偏好:${sortedCategories.map((c) => c.categoryName).join("、")}

` : ""}

基于你的兴趣推荐

${interestTools.length > 0 ? interestTools.map((t) => renderToolCard(t, baseUrl)).join("") : `

暂无匹配的推荐工具

`}

本周热门工具

${trendingTools.length > 0 ? trendingTools.map((t) => renderToolCard(t, baseUrl)).join("") : `

暂无热门工具

`}

你可能错过的新工具

${newTools.length > 0 ? newTools.map((t) => renderToolCard(t, baseUrl)).join("") : `

暂无新工具

`}
探索更多 AI 工具

此邮件由追光AI自动生成
如有疑问,请访问 ${baseUrl.replace("https://", "")}

`; } /** * 生成纯文本邮件内容 */ function renderTextContent({ userName, interestTools, trendingTools, newTools, baseUrl }) { const displayName = userName || "AI 探索者"; let text = [ `追光AI 周报 | 为你精选的 AI 工具`, ``, `${displayName},你好!`, ``, `根据你的收藏和浏览历史,我们为你精心挑选了以下 AI 工具:`, ``, ]; if (interestTools.length > 0) { text.push(`【基于你的兴趣推荐】`); interestTools.forEach((t, i) => { text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`); text.push(` 查看: ${baseUrl}/tools/${t.slug}`); }); text.push(``); } if (trendingTools.length > 0) { text.push(`【本周热门工具】`); trendingTools.forEach((t, i) => { text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`); text.push(` 查看: ${baseUrl}/tools/${t.slug}`); }); text.push(``); } if (newTools.length > 0) { text.push(`【你可能错过的新工具】`); newTools.forEach((t, i) => { text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`); text.push(` 查看: ${baseUrl}/tools/${t.slug}`); }); text.push(``); } text.push(`探索更多 AI 工具: ${baseUrl}/tools`); text.push(``); text.push(`此邮件由追光AI自动生成`); return text.join("\n"); } // ─── 主流程 ─────────────────────────────────────────────────── async function generateRecommendationsForUser(user) { const userId = user.id; // 1. 分析兴趣 let interestCategories = []; try { interestCategories = await analyzeUserInterests(userId); } catch (err) { console.warn(` ⚠ 用户 ${user.email} 兴趣分析失败: ${err.message}`); interestCategories = []; } // 2. 获取已交互的工具 ID(用于排除) const excludeIds = await getUserInteractedToolIds(userId); const excludeIdArray = [...excludeIds].filter(Boolean); // 3. Section 1: 基于兴趣推荐 let interestTools = []; if (interestCategories.length > 0) { interestTools = await recommendFromInterests(userId, interestCategories, excludeIdArray, 3); } // 如果兴趣推荐不够 3 个,用热门工具补充 if (interestTools.length < 3) { const existingIds = new Set([...excludeIdArray, ...interestTools.map((t) => t.id)]); const fillTools = await prisma.tool.findMany({ where: { status: "published", id: { notIn: [...existingIds] }, }, include: { category: { select: { name: true, slug: true } } }, orderBy: { viewCount: "desc" }, take: 3 - interestTools.length, }); interestTools = [...interestTools, ...fillTools]; } // 4. Section 2: 本周热门 const trendingIds = new Set([...excludeIdArray, ...interestTools.map((t) => t.id)]); const trendingTools = await recommendTrendingTools([...trendingIds], 3); // 5. Section 3: 新工具 const newIds = new Set([...trendingIds, ...trendingTools.map((t) => t.id)]); const newTools = await recommendNewTools(userId, interestCategories, [...newIds], 2); return { interestCategories: interestCategories.slice(0, 5), interestTools, trendingTools, newTools, }; } async function main() { if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL"); const startTime = new Date(); const now = new Date(); const weekNumber = getWeekNumber(now); const dateStr = now.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric", timeZone: "Asia/Shanghai", }); const dateISO = now.toISOString().split("T")[0]; const baseUrl = getBaseUrl(); const unsubscribeUrl = `${baseUrl}/settings/notifications`; console.log(`📧 开始生成第 ${weekNumber} 周个性化推荐邮件 (${dateStr})...`); console.log(); // 1. 查询目标用户:非 Bot、有邮箱、有活动(收藏/浏览/社区帖子) console.log("👥 第一步:查询目标用户..."); const usersWithActivity = await prisma.user.findMany({ where: { isBot: false, email: { not: null, not: "" }, OR: [ { favorites: { some: {} } }, { browsingHistory: { some: {} } }, { forumTopics: { some: {} } }, ], }, select: { id: true, email: true, name: true, }, }); console.log(` 找到 ${usersWithActivity.length} 位活跃用户`); console.log(); if (usersWithActivity.length === 0) { console.log("⚠️ 没有符合条件的用户,跳过推荐生成"); await prisma.taskLog.create({ data: { taskKey: "weekly-recommend-email", taskName: "每周推荐邮件", status: "completed", startedAt: startTime, finishedAt: new Date(), duration: new Date().getTime() - startTime.getTime(), result: { userCount: 0, emailCount: 0 }, }, }); return; } // 2. 为每个用户生成推荐 console.log("🔍 第二步:为每个用户生成个性化推荐..."); const emailEntries = []; let successCount = 0; let skipCount = 0; for (let i = 0; i < usersWithActivity.length; i++) { const user = usersWithActivity[i]; const progress = `[${i + 1}/${usersWithActivity.length}]`; console.log(` ${progress} 处理用户 ${user.email}...`); try { const recs = await generateRecommendationsForUser(user); // 跳过没有任何推荐内容的用户 if ( recs.interestTools.length === 0 && recs.trendingTools.length === 0 && recs.newTools.length === 0 ) { console.log(` ⚠ 无可用推荐,跳过`); skipCount++; continue; } const htmlContent = renderEmailHtml({ userName: user.name, weekNumber, dateStr, interestCategories: recs.interestCategories, interestTools: recs.interestTools, trendingTools: recs.trendingTools, newTools: recs.newTools, unsubscribeUrl, baseUrl, }); const textContent = renderTextContent({ userName: user.name, interestTools: recs.interestTools, trendingTools: recs.trendingTools, newTools: recs.newTools, baseUrl, }); emailEntries.push({ userId: user.id, email: user.email, name: user.name, subject: `追光AI 周报 | 为你精选的 AI 工具`, htmlContent, textContent, interestCategories: recs.interestCategories.map((c) => ({ name: c.categoryName, score: c.score, sources: c.sources, })), sections: { interest: recs.interestTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })), trending: recs.trendingTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })), new: recs.newTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })), }, }); const sectionCounts = [ recs.interestTools.length > 0 ? `兴趣${recs.interestTools.length}` : "", recs.trendingTools.length > 0 ? `热门${recs.trendingTools.length}` : "", recs.newTools.length > 0 ? `新工具${recs.newTools.length}` : "", ].filter(Boolean).join("+"); console.log(` ✅ 推荐: ${sectionCounts} | 兴趣: ${recs.interestCategories.slice(0, 3).map((c) => c.categoryName).join(", ") || "无"}`); successCount++; } catch (err) { console.error(` ❌ 处理失败: ${err.message}`); skipCount++; } } console.log(); console.log(` ✅ 成功生成 ${successCount} 封推荐邮件 (跳过 ${skipCount} 人)`); // 3. 输出到 JSON 文件 console.log(); console.log("💾 第三步:写入 JSON 文件..."); const outputDir = path.resolve(__dirname, "..", "data", "emails"); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } const outputFile = path.join(outputDir, `weekly-recommend-${dateISO}.json`); const outputData = { generatedAt: startTime.toISOString(), weekNumber, dateStr, dateISO, totalUsers: usersWithActivity.length, successCount, skipCount, entries: emailEntries, }; fs.writeFileSync(outputFile, JSON.stringify(outputData, null, 2), "utf-8"); console.log(` 文件: ${outputFile}`); console.log(` 大小: ${(fs.statSync(outputFile).size / 1024).toFixed(1)} KB`); // 4. 记录 TaskLog const endTime = new Date(); const duration = endTime.getTime() - startTime.getTime(); await prisma.taskLog.create({ data: { taskKey: "weekly-recommend-email", taskName: "每周推荐邮件", status: "completed", startedAt: startTime, finishedAt: endTime, duration, result: { userCount: usersWithActivity.length, emailCount: successCount, skipCount, outputFile: `data/emails/weekly-recommend-${dateISO}.json`, weekNumber, }, }, }); console.log(); console.log(`🎉 每周推荐邮件生成完成!`); console.log(` 周数: 第 ${weekNumber} 周`); console.log(` 用户数: ${usersWithActivity.length}`); console.log(` 成功: ${successCount}`); console.log(` 跳过: ${skipCount}`); console.log(` 耗时: ${(duration / 1000).toFixed(1)}s`); return { userCount: usersWithActivity.length, successCount, skipCount }; } // ─── Entry ───────────────────────────────────────────────────── main() .then(() => { console.log("Done"); process.exit(0); }) .catch(async (error) => { console.error("❌ 每周推荐邮件生成失败:", error.message); try { await prisma.taskLog.create({ data: { taskKey: "weekly-recommend-email", taskName: "每周推荐邮件", status: "failed", startedAt: new Date(), finishedAt: new Date(), duration: 0, error: error.message, }, }); } catch {} process.exit(1); }) .finally(async () => { await prisma.$disconnect(); await disconnectRedis(); });