Files
zhuiguang-ai/scripts/generate-bot-content.mjs
T

250 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});