#!/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); });