763 lines
26 KiB
JavaScript
763 lines
26 KiB
JavaScript
/**
|
|
* 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 `
|
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background-color:#ffffff;">
|
|
<tr>
|
|
<td style="padding:16px;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
<tr>
|
|
<td width="48" style="vertical-align:top;padding-right:12px;">
|
|
<img src="${logoSrc}" width="48" height="48" alt="${tool.name}" style="border-radius:8px;display:block;object-fit:cover;background:#f3f4f6;" />
|
|
</td>
|
|
<td style="vertical-align:top;">
|
|
<p style="margin:0 0 4px 0;font-size:16px;font-weight:600;color:#111827;">
|
|
<a href="${detailUrl}" style="color:#111827;text-decoration:none;">${tool.name}</a>
|
|
</p>
|
|
<p style="margin:0 0 6px 0;font-size:13px;color:#6b7280;">${tool.category?.name || ""}</p>
|
|
<p style="margin:0 0 10px 0;font-size:14px;color:#374151;line-height:1.5;">${desc}</p>
|
|
<a href="${detailUrl}" style="display:inline-block;padding:6px 16px;font-size:13px;color:#ffffff;background-color:#3b82f6;border-radius:6px;text-decoration:none;">查看详情</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>`;
|
|
}
|
|
|
|
/**
|
|
* 生成完整 HTML 邮件
|
|
*/
|
|
function renderEmailHtml({ userName, weekNumber, dateStr, interestCategories, interestTools, trendingTools, newTools, unsubscribeUrl, baseUrl }) {
|
|
const displayName = userName || "AI 探索者";
|
|
const sortedCategories = (interestCategories || []).slice(0, 5);
|
|
|
|
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 周报 | 为你精选的 AI 工具</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans SC',sans-serif;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f3f4f6;">
|
|
<tr>
|
|
<td align="center" style="padding:24px 16px;">
|
|
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
|
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="background:linear-gradient(135deg,#3b82f6,#8b5cf6);padding:32px 24px;text-align:center;">
|
|
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.8);">追光AI · 第 ${weekNumber} 周</p>
|
|
<h1 style="margin:8px 0 0 0;font-size:24px;color:#ffffff;font-weight:700;">为你精选的 AI 工具</h1>
|
|
<p style="margin:8px 0 0 0;font-size:14px;color:rgba(255,255,255,0.9);">${dateStr}</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Greeting -->
|
|
<tr>
|
|
<td style="padding:24px;">
|
|
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
|
|
${displayName},你好!
|
|
</p>
|
|
<p style="margin:8px 0 0 0;font-size:14px;color:#6b7280;line-height:1.6;">
|
|
根据你的收藏和浏览历史,我们为你精心挑选了以下 AI 工具。以下是本周的个性化推荐:
|
|
</p>
|
|
|
|
<!-- Interest Summary -->
|
|
${sortedCategories.length > 0 ? `
|
|
<p style="margin:16px 0 0 0;font-size:13px;color:#9ca3af;">
|
|
你的兴趣偏好:${sortedCategories.map((c) => c.categoryName).join("、")}
|
|
</p>
|
|
` : ""}
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Section 1: 基于你的兴趣推荐 -->
|
|
<tr>
|
|
<td style="padding:0 24px 8px 24px;">
|
|
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #3b82f6;padding-left:12px;">
|
|
基于你的兴趣推荐
|
|
</h2>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 24px 16px 24px;">
|
|
${interestTools.length > 0
|
|
? interestTools.map((t) => renderToolCard(t, baseUrl)).join("")
|
|
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无匹配的推荐工具</p>`}
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Section 2: 本周热门工具 -->
|
|
<tr>
|
|
<td style="padding:0 24px 8px 24px;">
|
|
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #f59e0b;padding-left:12px;">
|
|
本周热门工具
|
|
</h2>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 24px 16px 24px;">
|
|
${trendingTools.length > 0
|
|
? trendingTools.map((t) => renderToolCard(t, baseUrl)).join("")
|
|
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无热门工具</p>`}
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Section 3: 你可能错过的新工具 -->
|
|
<tr>
|
|
<td style="padding:0 24px 8px 24px;">
|
|
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #10b981;padding-left:12px;">
|
|
你可能错过的新工具
|
|
</h2>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 24px 16px 24px;">
|
|
${newTools.length > 0
|
|
? newTools.map((t) => renderToolCard(t, baseUrl)).join("")
|
|
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无新工具</p>`}
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- CTA -->
|
|
<tr>
|
|
<td style="padding:16px 24px;text-align:center;">
|
|
<a href="${baseUrl}/tools" style="display:inline-block;padding:12px 32px;font-size:15px;color:#ffffff;background-color:#3b82f6;border-radius:8px;text-decoration:none;font-weight:600;">
|
|
探索更多 AI 工具
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Divider -->
|
|
<tr>
|
|
<td style="padding:0 24px;">
|
|
<div style="border-top:1px solid #e5e7eb;"></div>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="padding:20px 24px 32px 24px;text-align:center;">
|
|
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.8;">
|
|
此邮件由追光AI自动生成<br>
|
|
如有疑问,请访问 <a href="${baseUrl}" style="color:#3b82f6;">${baseUrl.replace("https://", "")}</a>
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* 生成纯文本邮件内容
|
|
*/
|
|
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();
|
|
});
|