import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { readFileSync } from "fs"; import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; import OpenAI from "openai"; import "dotenv/config"; import { withRetry } from "./lib/retry.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=10&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); const prisma = new PrismaClient({ adapter }); const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1", }); const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json"); const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat"; const FORUM_SLUGS = [ "money", "ecommerce", "ai-tech", "career", "cross-border", "real-estate", "content", "offline-biz", "education", "fire-life", ]; const FORUM_NAMES = { money: "赚钱与副业", ecommerce: "电商运营", "ai-tech": "AI科技", career: "职场创业", "cross-border": "跨境电商", "real-estate": "房产投资", content: "自媒体内容", "offline-biz": "实体生意", education: "教育成长", "fire-life": "FIRE与生活", }; const SUB_CATEGORY_MAP = { money: ["money-projects", "money-pitfalls", "money-models"], ecommerce: ["ec-platform", "ec-livestream", "ec-product"], "ai-tech": ["ai-tools-forum", "ai-startup", "ai-frontier"], career: ["career-grow", "career-startup", "career-35"], "cross-border": ["cb-platform", "cb-logistics", "cb-local"], "real-estate": ["re-buy", "re-invest", "re-market"], content: ["ct-video", "ct-writing", "ct-money"], "offline-biz": ["ob-shop", "ob-food", "ob-factory"], education: ["edu-skills", "edu-mindset", "edu-books"], "fire-life": ["fl-fire", "fl-invest", "fl-lifestyle"], }; function ts() { return `[${new Date().toISOString()}]`; } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function slugify(str) { const clean = str.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-|-$/g, "").substring(0, 80); return `${clean}-${Date.now().toString(36)}`; } function randomDate(daysAgo) { const d = new Date(); d.setDate(d.getDate() - Math.floor(Math.random() * daysAgo)); d.setHours(Math.floor(Math.random() * 12) + 8, Math.floor(Math.random() * 60), 0, 0); return d; } async function loadBotCharacters() { const raw = readFileSync(BOT_DATA_PATH, "utf-8"); const { characters } = JSON.parse(raw); return characters; } function buildContentPrompt(bot, forumName, isTopic, existingContent) { const p = bot.personality; if (isTopic) { return `你正在模拟一个中文行业论坛的帖子。现在是2026年,这个论坛叫"追光AI行业论坛",板块是"${forumName}"。 [角色] 你是【${bot.displayName}】,${p.identity} 擅长:${p.expertise.join("、")} 立场:${p.stance} 风格:${p.speakingStyle} [任务] 以${bot.displayName}的身份发起一个新话题。这是"预生成历史内容",用来让新论坛看起来有一定基础。话题可以基于${bot.displayName}的"经历"来写,让他看起来已经在这个论坛活跃了一段时间。 要求: 1. 标题10-20字,像真人在论坛发的帖子 2. 内容200-500字,口语化,有具体细节 3. 不要用"首先/其次/最后""综上所述"等格式 4. 可以有情绪,有立场 只输出JSON:{"title":"标题","content":"内容"}`; } return `你正在模拟一个中文行业论坛的回复。板块是"${forumName}"。 [角色] 你是【${bot.displayName}】,${p.identity} 立场:${p.stance} 风格:${p.speakingStyle} [上下文] 楼主发言:${existingContent} [任务] 以${bot.displayName}的身份回复。可以赞同、质疑、补充案例或提问。 要求: 1. 150-400字 2. 口语化自然 3. 不重复楼主的内容 只输出JSON:{"content":"回复内容"}`; } async function callDeepSeek(prompt) { const response = await withRetry(() => openai.chat.completions.create({ model: MODEL, messages: [{ role: "user", content: prompt }], temperature: 0.9, max_tokens: 1024, }) ); const text = response.choices[0].message.content.trim(); const jsonMatch = text.match(/\{[\s\S]*\}/); if (!jsonMatch) { throw new Error(`Could not parse JSON: ${text.substring(0, 200)}`); } return JSON.parse(jsonMatch[0]); } async function getBotUser(botKey) { const config = await prisma.botConfig.findFirst({ where: { user: { email: `bot_${botKey}@zhuiguang.ai` } }, include: { user: true }, }); return config; } async function getSubCategory(forumSlug) { const subs = SUB_CATEGORY_MAP[forumSlug] || []; const slug = pick(subs); return prisma.forumCategory.findUnique({ where: { slug } }); } async function main() { console.log(`${ts()} Pre-generating historical forum content...`); const allBots = await loadBotCharacters(); console.log(`${ts()} Loaded ${allBots.length} bot definitions.`); let totalTopics = 0; let totalReplies = 0; for (const forumSlug of FORUM_SLUGS) { const forumName = FORUM_NAMES[forumSlug]; const forumBots = allBots.filter(c => c.primaryForums && c.primaryForums.includes(forumSlug)); if (forumBots.length === 0) continue; const subCat = await getSubCategory(forumSlug); if (!subCat) { console.log(`${ts()} No sub-category found for ${forumSlug}, skipping.`); continue; } const topicCount = Math.floor(Math.random() * 2) + 2; console.log(`${ts()} ${forumName}: generating ${topicCount} topics...`); for (let i = 0; i < topicCount; i++) { const topicBot = pick(forumBots); const botConf = await getBotUser(topicBot.key); if (!botConf) continue; const topicDate = randomDate(90); try { const prompt = buildContentPrompt(topicBot, forumName, true, ""); const result = await callDeepSeek(prompt); await prisma.forumTopic.create({ data: { categoryId: subCat.id, userId: botConf.userId, title: result.title, slug: slugify(result.title), content: result.content, createdAt: topicDate, updatedAt: topicDate, }, }); totalTopics++; console.log(`${ts()} + Topic: "${result.title}" by ${topicBot.displayName}`); const replyCount = Math.floor(Math.random() * 2) + 1; const replierCandidates = forumBots.filter(b => b.key !== topicBot.key); const selectedRepliers = replierCandidates.sort(() => Math.random() - 0.5).slice(0, replyCount); for (const repBot of selectedRepliers) { const repConf = await getBotUser(repBot.key); if (!repConf) continue; const replyDate = new Date(topicDate.getTime() + Math.floor(Math.random() * 12 * 60 * 60 * 1000)); try { const replyPrompt = buildContentPrompt(repBot, forumName, false, result.content); const replyResult = await callDeepSeek(replyPrompt); await prisma.forumPost.create({ data: { topicId: (await prisma.forumTopic.findFirst({ where: { title: result.title, userId: botConf.userId }, orderBy: { createdAt: "desc" }, })).id, userId: repConf.userId, content: replyResult.content, createdAt: replyDate, updatedAt: replyDate, }, }); totalReplies++; console.log(`${ts()} + Reply by ${repBot.displayName}`); } catch (err) { console.error(`${ts()} Reply error:`, err.message); } } } catch (err) { console.error(`${ts()} Topic error:`, err.message); } } } console.log(`${ts()} Done! Created ${totalTopics} topics and ${totalReplies} replies.`); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });