feat: 首次推送到Gitea - 完整项目代码 + 安全加固 + 知识库

This commit is contained in:
ZhuiGuangAI Dev
2026-10-02 18:52:49 +08:00
parent b44601bb66
commit 8205ae709c
1185 changed files with 49841 additions and 12802 deletions
+480
View File
@@ -0,0 +1,480 @@
#!/usr/bin/env node
/**
* 行业情报日更引擎 (industry-daily)
* ==================================
* 社区新形态:每个行业 = 一个「XX情报官」机器人,每天一篇《每日情报》
* - 内容 = 该行业的新模式 / 新打法 / 新策略(可操作、观点锐利、来源可追溯、结构固定、逻辑自洽)
* - 价值度闸门:素材不足 / 新闻单一 / 价值分低 → 当日不发(宁缺毋滥)
* - 问答:只回自己帖子下的真人提问;不相关的问题回复并标注边界
*
* 用法:
* node scripts/industry-daily.mjs daily --all
* node scripts/industry-daily.mjs daily --industry=cb-ecommerce
* node scripts/industry-daily.mjs respond --all
*/
import "dotenv/config";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import OpenAI from "openai";
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const NEWS_DIR = path.join(__dirname, "..", "data", "industry-news");
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(`${base}${sep}connection_limit=5&pool_timeout=30`) });
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com" });
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const ts = () => new Date().toISOString();
// ============================ 价值度闸门阈值 ============================
const GATE = {
FRESH_DAYS: 5, // 素材新鲜窗口(天);用足 10 天素材库,质量仍由 AI 价值分把关
MIN_FRESH: 6, // 窗口内最少素材条数
MIN_SOURCES: 2, // 最少来源数
MIN_THEMES: 3, // 最少主题簇数
MAX_THEME_SHARE: 0.5, // 单一主题最大占比
MIN_SCORE: 65, // AI 价值分下限
};
const SELF_CHECK = { EACH_MIN: 6, AVG_MIN: 7.0 };
// ============================ 行业情报官配置 ============================
// key 对应素材库文件名与社区板块 slug;bot 为单机器人情报官
const INDUSTRIES = [
{
key: "cb-ecommerce", title: "跨境电商", short: "出海", board: "cb-ecommerce",
bot: { key: "cb_intel", name: "跨境情报官·阿海", email: "bot_cb_intel@zhuiguang.ai" },
lens: "平台规则、流量结构、物流与合规的变化,以及卖家可立即采用的动作",
},
{
key: "fi-insurance", title: "保险理财", short: "保险", board: "fi-insurance",
bot: { key: "fi_ins_intel", name: "保险情报官·安叔", email: "bot_fi_ins_intel@zhuiguang.ai" },
lens: "监管与产品结构变化、银行与保险资金动向,以及家庭资产配置的应对",
},
{
key: "fi-stock", title: "股票基金", short: "市场", board: "fi-stock",
bot: { key: "fi_stock_intel", name: "市场情报官·K线", email: "bot_fi_stock_intel@zhuiguang.ai" },
lens: "资金与估值信号、板块轮动、公募私募动向,以及仓位与风险的应对",
},
{
key: "cb-brand", title: "品牌出海", short: "品牌", board: "cb-brand",
bot: { key: "brand_intel", name: "品牌情报官·阿牌", email: "bot_brand_intel@zhuiguang.ai" },
lens: "渠道与内容分发变化、品牌增长与本地化打法,以及可复制的增长动作",
},
{
key: "ai-tools-app", title: "AI应用", short: "AI", board: "ai-tools-app",
bot: { key: "ai_intel", name: "AI情报官·普罗", email: "bot_ai_intel@zhuiguang.ai" },
lens: "模型能力与定价变化、AI 产品商业化与交付边界,以及可落地的应用机会",
},
{
key: "ct-short-video", title: "内容创作", short: "内容", board: "ct-short-video",
bot: { key: "ct_intel", name: "内容情报官·爆点", email: "bot_ct_intel@zhuiguang.ai" },
lens: "平台算法与流量分配、内容形态与变现方式的变化,以及创作者可执行的调整",
},
];
// 情报官人设:优先读角色库(data/bot-characters.json),读不到则退回内置描述
let CHAR_MAP = null;
function loadChars() {
if (CHAR_MAP) return CHAR_MAP;
CHAR_MAP = {};
try {
const raw = fs.readFileSync(path.join(__dirname, "..", "data", "bot-characters.json"), "utf8");
for (const c of JSON.parse(raw).characters || []) CHAR_MAP[c.key] = c;
} catch (e) {
console.warn(`${ts()} ⚠️ 角色库读取失败,使用内置人设: ${e.message}`);
}
return CHAR_MAP;
}
function PERSONA(def) {
const c = loadChars()[def.bot.key];
const p = c?.personality;
if (!p) {
return [
`你是「${def.bot.name}」,${def.title}行业的情报官。`,
`你的观察视角:${def.lens}。`,
"风格:冷静、克制、不寒暄、不营销;不编造任何数字与新闻。",
].join("\n");
}
return [
`你是「${c.displayName}」,${p.identity}。`,
`观察视角:${def.lens}。`,
`立场:${p.stance}`,
`表达风格:${p.speakingStyle}`,
`口头禅(可自然使用):${p.catchphrase}`,
`禁止出现的表达:${(p.forbiddenPatterns || []).join("、")}`,
].join("\n");
}
// ============================ 基础工具 ============================
function readStore(key) {
const file = path.join(NEWS_DIR, `${key}.json`);
if (!fs.existsSync(file)) return [];
try {
const arr = JSON.parse(fs.readFileSync(file, "utf8"));
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
async function callAI(system, user, maxTokens = 16384) {
// DeepSeek V4 reasoning token 计入 max_tokens,预算不足会返回空内容
const r = await openai.chat.completions.create({
model: MODEL,
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
temperature: 0.7,
max_tokens: maxTokens,
});
return r.choices[0]?.message?.content || "";
}
async function callAIJson(system, user, maxTokens = 8192, tries = 3) {
for (let i = 0; i < tries; i++) {
const text = await callAI(system, user, maxTokens);
const m = text.match(/\{[\s\S]*\}/);
if (!m) { console.warn(`${ts()} ⚠️ JSON 缺失,重试 ${i + 1}/${tries}`); continue; }
try {
return JSON.parse(m[0]);
} catch {
try { return JSON.parse(m[0].replace(/,\s*}/g, "}").replace(/,\s*]/g, "]")); } catch { console.warn(`${ts()} ⚠️ JSON 解析失败,重试`); }
}
}
throw new Error("AI 结构化输出连续失败");
}
async function ensureBot(def) {
const b = def.bot;
let u = await prisma.user.findUnique({ where: { email: b.email } });
if (!u) {
u = await prisma.user.create({
data: { email: b.email, name: b.name, oauthProvider: "bot", oauthId: `bot_${b.key}`, role: "user", isBot: true },
});
console.log(`${ts()} + 创建情报官账号: ${b.name}`);
}
try {
const exists = await prisma.botConfig.findFirst({ where: { userId: u.id } });
if (!exists) {
await prisma.botConfig.create({
data: { userId: u.id, displayName: b.name, personality: { identity: `${def.title}行业情报官`, expertise: [def.title, "行业情报", "商业分析"], stance: "只讲事实与判断", speakingStyle: "冷静克制" }, primaryForums: [def.board], activeHours: [] },
});
}
} catch (e) { console.warn(`${ts()} ⚠️ BotConfig: ${e.message}`); }
return u;
}
// ============================ 价值度闸门 ============================
function pickFresh(def) {
const cutoff = new Date(Date.now() - GATE.FRESH_DAYS * 86400000).toISOString().slice(0, 10);
const all = readStore(def.key);
const fresh = all.filter((n) => n.date >= cutoff && n.title);
const sources = [...new Set(fresh.map((n) => n.source).filter(Boolean))];
return { fresh, sources };
}
async function evaluateValue(def, fresh, sources) {
if (fresh.length < GATE.MIN_FRESH) return { pass: false, reason: `素材不足(近${GATE.FRESH_DAYS}天 ${fresh.length} 条 < ${GATE.MIN_FRESH})` };
if (sources.length < GATE.MIN_SOURCES) return { pass: false, reason: `来源单一(仅 ${sources.length} 个来源 < ${GATE.MIN_SOURCES})` };
const list = fresh.slice(0, 24).map((n, i) => `[${i + 1}] ${n.title}(${n.source || "未知来源"})`).join("\n");
const judge = await callAIJson(
`你是内容价值评审。判断这批行业素材是否足以支撑一篇"有深度、有逻辑、可操作"的行业情报。只输出 JSON:{"score":0-100,"worth":true/false,"themes":[{"name":"主题名","count":条目数}],"reasons":"一句话理由"}。评分标准:主题是否多元、是否含可提炼的新模式/新打法信号、是否只是同质转载。`,
`行业:${def.title}\n素材(近${GATE.FRESH_DAYS}天):\n${list}`,
16384
);
const themes = Array.isArray(judge?.themes) ? judge.themes.filter((t) => t && t.name) : [];
const total = themes.reduce((s, t) => s + (Number(t.count) || 0), 0) || 1;
const maxShare = themes.length ? Math.max(...themes.map((t) => (Number(t.count) || 0) / total)) : 1;
const score = Number(judge?.score) || 0;
if (!judge?.worth) return { pass: false, reason: `AI 判定价值不足(score ${score}): ${judge?.reasons || ""}` };
if (themes.length < GATE.MIN_THEMES) return { pass: false, reason: `新闻单一(主题簇 ${themes.length} < ${GATE.MIN_THEMES}): ${themes.map((t) => t.name).join("/")}` };
if (maxShare > GATE.MAX_THEME_SHARE) return { pass: false, reason: `单主题占比 ${(maxShare * 100).toFixed(0)}% > ${GATE.MAX_THEME_SHARE * 100}%` };
if (score < GATE.MIN_SCORE) return { pass: false, reason: `价值分 ${score} < ${GATE.MIN_SCORE}` };
return { pass: true, score, themes, reasons: judge?.reasons || "" };
}
// ============================ 内容生成与自检 ============================
function materialBlock(fresh) {
return fresh.slice(0, 20).map((n, i) => `[${i + 1}] ${n.title}${n.url ? `\n 链接:${n.url}` : ""}(来源:${n.source || "未知"})`).join("\n");
}
const STYLE_RULES = `写作硬性要求(缺一不可):
1) 可操作解读:每条都要写清"对我们意味着什么 + 具体可以做什么",禁止只复述新闻;
2) 观点锐利:敢下判断(如"这个模式在国内跑不通,因为…"),不骑墙、不含糊;
3) 来源可追溯:所有数字、案例必须来自给定素材,宁缺不编;引用素材时标注其标题关键词;
4) 结构固定:严格按模板三段式输出;
5) 逻辑自洽:每条走完"现象 → 机制(为什么成立)→ 推论 → 动作"的因果链,禁止跳跃堆砌。`;
const TEMPLATE = `【{industry}·每日情报】{date}
🆕 新模式
1. (现象)…(机制)…(对从业者的含义)…
2. …
⚔️ 新打法
1. (谁在这么做)…(具体动作,可直接抄/试)…
2. …
🎯 新策略
1. (判断)…(观察指标,后续怎么验证)…
2. …
🔗 来源
- 素材标题关键词(来源媒体)`;
async function generateIntel(def, fresh, feedback) {
const sys = [PERSONA(def), STYLE_RULES, "只输出正文(从 🆕 开始,到来源结束),不要额外解释。"].join("\n");
const user = [
`请基于以下真实素材,产出今日《${def.title}·每日情报》。`,
`今天日期:${new Date().toISOString().slice(0, 10)}`,
`素材:\n${materialBlock(fresh)}`,
`模板(严格遵循):\n${TEMPLATE.replace("{industry}", def.title).replace("{date}", new Date().toISOString().slice(0, 10))}`,
"若某类目素材只能支撑 1 条,就只写 1 条;不要凑数。",
feedback ? `\n【上一版质检问题,请针对性重写并全部修复】:\n- ${feedback}` : "",
].join("\n\n");
return (await callAI(sys, user, 16384)).trim();
}
async function selfCheck(def, body, fresh) {
const judge = await callAIJson(
`你是严格的内容质检员。对这篇行业情报按五维打分(0-10)并给问题清单。只输出 JSON:{"scores":{"actionable":0-10,"sharp":0-10,"sourced":0-10,"structure":0-10,"logic":0-10},"issues":["具体问题"]}。标准:actionable=是否给出可直接执行的动作;sharp=是否有明确判断;sourced=是否基于给定素材且未编造;structure=是否遵循固定三段式;logic=因果链是否完整。`,
`行业:${def.title}\n文章:\n${body}\n\n可对照的素材:\n${materialBlock(fresh).slice(0, 1500)}`,
16384
);
const s = judge?.scores || {};
const vals = ["actionable", "sharp", "sourced", "structure", "logic"].map((k) => Number(s[k]) || 0);
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
const pass = vals.every((v) => v >= SELF_CHECK.EACH_MIN) && avg >= SELF_CHECK.AVG_MIN;
return { pass, avg, scores: s, issues: Array.isArray(judge?.issues) ? judge.issues : [] };
}
// ============================ 情报索引(每板块置顶导航帖) ============================
function weekStartLabel(d) {
const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const shift = (x.getUTCDay() + 6) % 7; // 周一为一周起点
x.setUTCDate(x.getUTCDate() - shift);
return `${x.getUTCFullYear()}/${String(x.getUTCMonth() + 1).padStart(2, "0")}/${String(x.getUTCDate()).padStart(2, "0")} 当周`;
}
async function updateIndex(def, bot, board) {
const slug = `intel-index-${def.key}`;
const topics = await prisma.forumTopic.findMany({
where: { categoryId: board.id, userId: bot.id, slug: { startsWith: `intel-${def.key}-` } },
orderBy: { createdAt: "desc" },
take: 40,
select: { id: true, title: true, createdAt: true, thinktank: true },
});
const lines = [];
let curWeek = "";
for (const t of topics) {
const d = new Date(t.createdAt);
const wk = weekStartLabel(d);
if (wk !== curWeek) { lines.push(`\n**${wk}**`); curWeek = wk; }
const day = `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}`;
const score = t.thinktank?.valueScore ? ` \`价值分 ${t.thinktank.valueScore}\`` : "";
lines.push(`- **${day}** [${t.title}](/community/topic/${t.id})${score}`);
}
const content = [
`# 「${board.name}」情报索引`,
"",
"本页由情报官自动维护,收录近期每日情报(最新在上),有新情报发布时自动更新。",
"",
`共 ${topics.length} 篇 · 每篇均基于真实素材整理、标注来源,不编造数据与案例。`,
...(lines.length ? lines : ["", "(暂无情报)"]),
"",
"---",
`*想看某天的完整情报,直接点标题;有具体场景想问,回帖即可,情报官会答。*`,
].join("\n");
const exist = await prisma.forumTopic.findUnique({ where: { slug }, select: { id: true } });
if (exist) {
await prisma.forumTopic.update({ where: { id: exist.id }, data: { title: `「${board.name}」情报索引`, content, isPinned: true } });
console.log(`${ts()} 📑 [${def.key}] 情报索引已更新(收录 ${topics.length} 篇)`);
return exist.id;
}
const t = await prisma.forumTopic.create({
data: { categoryId: board.id, userId: bot.id, title: `「${board.name}」情报索引`, slug, content, isPinned: true, thinktank: { kind: "intel-index", industry: def.key } },
});
await prisma.forumCategory.update({ where: { id: board.id }, data: { topicCount: { increment: 1 } } });
console.log(`${ts()} 📑 [${def.key}] 情报索引已创建 topic#${t.id}`);
return t.id;
}
// ============================ 发布 ============================
async function publish(def, body, valueInfo) {
const board = await prisma.forumCategory.findUnique({ where: { slug: def.board } });
if (!board) throw new Error(`板块 ${def.board} 不存在`);
const bot = await ensureBot(def);
const date = new Date().toISOString().slice(0, 10);
const slug = `intel-${def.key}-${date.replace(/-/g, "")}`;
const existing = await prisma.forumTopic.findUnique({ where: { slug } });
if (existing) { console.log(`${ts()} ⏭ [${def.key}] 今日已发(topic#${existing.id})`); return existing; }
const content = `${body}\n\n---\n*由「${def.bot.name}」基于当日真实素材自动整理,供学习参考,不构成投资/经营建议。价值分 ${valueInfo.score}。*`;
const topic = await prisma.forumTopic.create({
data: {
categoryId: board.id,
userId: bot.id,
title: `【${def.short}·每日情报】${date}`,
slug,
content,
isPinned: false,
thinktank: { kind: "daily-intel", industry: def.key, valueScore: valueInfo.score, themes: valueInfo.themes?.map((t) => t.name) || [], lastQuestionAt: null },
},
});
await prisma.forumCategory.update({ where: { id: board.id }, data: { topicCount: { increment: 1 } } });
console.log(`${ts()} ✅ [${def.key}] 已发布每日情报 topic#${topic.id}(价值分 ${valueInfo.score})`);
await updateIndex(def, bot, board);
return topic;
}
async function runDaily(def) {
const { fresh, sources } = pickFresh(def);
console.log(`${ts()} 🔎 [${def.key}] 近${GATE.FRESH_DAYS}天素材 ${fresh.length} 条 / ${sources.length} 来源`);
const value = await evaluateValue(def, fresh, sources);
if (!value.pass) {
console.log(`${ts()} ⏭ [${def.key}] 跳过发布:${value.reason}`);
return { key: def.key, status: "skipped", reason: value.reason };
}
console.log(`${ts()} ✅ [${def.key}] 通过价值闸门(score ${value.score},主题:${value.themes.map((t) => t.name).join("/")})`);
let body = await generateIntel(def, fresh);
let check = await selfCheck(def, body, fresh);
if (!check.pass) {
console.warn(`${ts()} ⚠️ [${def.key}] 自检未过(均分 ${check.avg.toFixed(1)}),重写一次。问题:${check.issues.join(";").slice(0, 200)}`);
body = await generateIntel(def, fresh, check.issues.join("\n- "));
check = await selfCheck(def, body, fresh);
}
if (!check.pass) {
const reason = `自检二次未过(均分 ${check.avg.toFixed(1)}):${check.issues.join(";").slice(0, 200)}`;
console.log(`${ts()} ⏭ [${def.key}] 跳过发布:${reason}`);
return { key: def.key, status: "skipped", reason };
}
if (body.length < 200) return { key: def.key, status: "skipped", reason: "生成长度过短" };
const topic = await publish(def, body, value);
return { key: def.key, status: "published", topicId: topic.id, score: value.score, selfAvg: Number(check.avg.toFixed(1)) };
}
// ============================ 真人问答(单机器人 + 边界标注) ============================
async function respond(def) {
const board = await prisma.forumCategory.findUnique({ where: { slug: def.board } });
if (!board) return { key: def.key, status: "error", reason: `板块 ${def.board} 不存在` };
const bot = await ensureBot(def);
const topics = await prisma.forumTopic.findMany({ where: { categoryId: board.id, userId: bot.id }, orderBy: { createdAt: "desc" }, take: 10 });
let replied = 0;
for (const topic of topics) {
const meta = topic.thinktank || {};
if (meta.kind !== "daily-intel") continue;
const since = meta.lastQuestionAt ? new Date(meta.lastQuestionAt) : new Date(topic.createdAt);
const questions = await prisma.forumPost.findMany({
where: { topicId: topic.id, user: { isBot: false }, createdAt: { gt: since } },
orderBy: { createdAt: "asc" },
include: { user: { select: { name: true } } },
});
for (const q of questions) {
try {
const judge = await callAIJson(
`判断提问是否与该帖主题/该行业相关。只输出 JSON:{"related":true/false,"topic":"提问的核心话题","hint":"若是行业边缘问题,指明可回答的边界"}。`,
`帖子标题:${topic.title}\n帖子正文节选:${(topic.content || "").slice(0, 800)}\n\n提问:${q.content.slice(0, 400)}`,
16384
);
const related = judge?.related === true;
const sys = [PERSONA(def), related ? "请直接、专业地回答,给出可操作判断,200-400字。" : `该提问超出本行业情报范围(${judge?.hint || "与本帖主题无关"})。请简短回复:说明边界,只就该行业中与之相关的部分给出看法(80-150字),并建议合适的提问方向。`].join("\n");
const reply = await callAI(sys, `提问(来自 ${q.user?.name || "用户"}):${q.content.slice(0, 500)}`, 8192);
await prisma.forumPost.create({
data: { topicId: topic.id, userId: bot.id, content: reply.trim() },
});
await prisma.forumTopic.update({ where: { id: topic.id }, data: { replyCount: { increment: 1 }, lastReplyAt: new Date() } });
replied++;
console.log(`${ts()} ↩️ [${def.key}] 已回复 post#${q.id}(相关=${related})`);
} catch (e) {
console.error(`${ts()} 回复失败 post#${q.id}: ${e.message}`);
}
}
await prisma.forumTopic.update({ where: { id: topic.id }, data: { thinktank: { ...meta, lastQuestionAt: new Date().toISOString() } } });
}
return { key: def.key, status: "ok", replied };
}
// ============================ CLI ============================
function parseArgs() {
const args = {};
for (const a of process.argv.slice(3)) {
if (a.startsWith("--")) {
const [k, v] = a.slice(2).split("=");
args[k] = v || true;
}
}
return args;
}
async function logTask(action, results, startedAt, error) {
const published = results.filter((r) => r.status === "published").map((r) => ({ key: r.key, score: r.score, selfAvg: r.selfAvg }));
const skipped = results.filter((r) => r.status === "skipped").map((r) => ({ key: r.key, reason: r.reason }));
try {
await prisma.taskLog.create({
data: {
taskKey: "industry-daily",
taskName: "行业情报日更引擎",
status: error || results.some((r) => r.status === "error") ? "failed" : "completed",
startedAt,
finishedAt: new Date(),
duration: Date.now() - startedAt.getTime(),
triggerBy: "cron",
result: { action, published, skipped },
error: error ? String(error.message || error).slice(0, 1000) : null,
},
});
} catch (e) { console.error(`${ts()} TaskLog error:`, e.message); }
}
async function main() {
if (!process.env.DEEPSEEK_API_KEY) throw new Error("缺少 DEEPSEEK_API_KEY");
const startedAt = new Date();
const cmd = process.argv[2];
const args = parseArgs();
const targets = args.all ? INDUSTRIES : INDUSTRIES.filter((d) => d.key === args.industry);
if (!targets.length) throw new Error(`未匹配行业: ${args.industry}`);
if (cmd !== "daily" && cmd !== "respond" && cmd !== "index") {
console.error("usage: daily|respond|index [--industry=slug] [--all]");
process.exit(1);
}
const results = [];
for (const def of targets) {
try {
if (cmd === "index") {
const board = await prisma.forumCategory.findUnique({ where: { slug: def.board } });
if (!board) throw new Error(`板块 ${def.board} 不存在`);
const bot = await ensureBot(def);
const id = await updateIndex(def, bot, board);
results.push({ key: def.key, status: "published", topicId: id });
} else {
results.push(cmd === "daily" ? await runDaily(def) : await respond(def));
}
} catch (e) {
console.error(`${ts()} ❌ [${def.key}] 失败:`, e.message);
results.push({ key: def.key, status: "error", reason: e.message });
}
}
await logTask(cmd, results, startedAt);
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error(e);
try { await prisma.$disconnect(); } catch {}
process.exit(1);
});