250 lines
8.1 KiB
JavaScript
250 lines
8.1 KiB
JavaScript
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();
|
||
}); |