Files
zhuiguang-ai/scripts/industry-thinktank.mjs

749 lines
37 KiB
JavaScript
Raw Permalink 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.
#!/usr/bin/env node
/**
* 行业智库引擎 (industry-thinktank)
* =================================
* 每个行业 = 一个"常驻置顶研讨帖":
* - 1 楼(topic.content) = 最新打法(每周由专家团研讨产出并更新,版本+1)
* - 楼层(post) = 每周专家对话原文 + 真人提问与合议回复
* 自动学习进化:每期输入 = 行业近况(尽力抓取) + 往期打法 + 真人提问,专家按序讨论后合议出新打法。
*
* 用法:
* node scripts/industry-thinktank.mjs init --industry=cb-ecommerce # 建主题帖(幂等)
* node scripts/industry-thinktank.mjs collect --all # 纯采集素材入库(每日多次)
* node scripts/industry-thinktank.mjs weekly --industry=cb-ecommerce # 跑一期研讨
* node scripts/industry-thinktank.mjs weekly --all
* node scripts/industry-thinktank.mjs respond --industry=cb-ecommerce # 回复真人提问
* node scripts/industry-thinktank.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 DATA_PATH = path.join(__dirname, "..", "data", "bot-characters.json");
// 行业素材库:按天累积近 N 天 RSS 命中标题,解决单日命中率低的问题
const NEWS_DIR = path.join(__dirname, "..", "data", "industry-news");
const NEWS_RETENTION_DAYS = 10;
const PER_SOURCE_CAP = 12; // 单源单次采集最多入库条数(防单站灌满)
const MAX_PER_SOURCE_STORE = 15; // 素材库中单源最多保留条数(保证来源多样性)
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();
// ============================ 行业智库配置 ============================
// experts: 从角色库按 key 加载人设;host 负责发起研讨与合议"打法"
const INDUSTRIES = [
{
key: "cb-ecommerce",
title: "跨境电商智库",
desc: "跨境出海卖家每周打法研讨:亚马逊/独立站/Temu/物流/选品/合规",
hostKey: "cb_thinker",
experts: [
{ key: "cb_amazon", focus: "平台运营与广告" },
{ key: "cb_observer", focus: "市场与战略" },
{ key: "wuliu", focus: "物流履约与成本" },
{ key: "cb_problem", focus: "新手避坑与入门" },
],
// 素材多源:国产商业媒体 + 国外电商垂直源(2026-09-06 实测可达可用)
sources: [
{ name: "36氪", url: "https://36kr.com/feed" },
{ name: "IT之家", url: "https://www.ithome.com/rss/" },
{ name: "创业邦", url: "https://www.cyzone.cn/rss/" },
{ name: "新浪科技", url: "http://rss.sina.com.cn/tech/rollnews.xml" },
{ name: "DigitalCommerce360", url: "https://www.digitalcommerce360.com/feed/" },
{ name: "EcommerceBytes", url: "https://www.ecommercebytes.com/feed/" },
// 垂直媒体网页列表抓取(RSS 不可用)
{ name: "雨果跨境", url: "https://www.cifnews.com/", type: "html" },
{ name: "大数跨境", url: "https://www.10100.com/", type: "html" },
],
kwList: ["跨境", "亚马逊", "amazon", "temu", "shopify", "独立站", "出海", "dtc", "外贸", "海外仓", "半托管", "全托管", "tiktok shop", "tiktok", "选品", "清关", "海运", "物流", "电商", "ebay", "walmart", "shein", "ecommerce", "沃尔玛", "ozon", "美客多", "lazada", "shopee", "卖家", "店铺", "listing", "旺季", "备货", "仓储", "关税", "合规", "vat", "ioss", "黑五", "prime day", "海外"],
// 弱标题过滤:命中即不入库("现存…企业超…万家"是企业注册量水文,非打法素材)
weakTitle: [/现存[^,。;]{0,40}?企业超\s*\d+(?:\.\d+)?\s*万家/],
topicTitle: "跨境电商智库 · 每周打法",
},
{
key: "fi-insurance",
title: "保险理财智库",
desc: "保险与理财每周打法研讨:资产配置/保险规划/基金/储蓄险",
hostKey: "fi_industry",
experts: [
{ key: "fi_industry", focus: "产品与监管内行视角" },
{ key: "fi_investor", focus: "投资仓位与组合" },
{ key: "xianyu", focus: "FIRE与长期储蓄视角" },
{ key: "fi_newbie", focus: "小白需求与避坑" },
],
sources: [
{ name: "36氪", url: "https://36kr.com/feed" },
{ name: "IT之家", url: "https://www.ithome.com/rss/" },
{ name: "创业邦", url: "https://www.cyzone.cn/rss/" },
{ name: "新浪科技", url: "http://rss.sina.com.cn/tech/rollnews.xml" },
{ name: "钛媒体", url: "https://www.tmtpost.com/rss" },
// 保险垂直媒体(RSS 不可用,走网页列表抓取)
{ name: "中国保险行业协会", url: "https://www.iachina.cn/", type: "html" },
{ name: "新浪财经·保险", url: "https://finance.sina.com.cn/money/insurance/", type: "html" },
{ name: "东方财富·保险", url: "https://insurance.eastmoney.com/", type: "html" },
{ name: "和讯保险", url: "https://insurance.hexun.com/", type: "html" },
],
// 口径切分:本板块只吃"保险核心 + 稳健理财",泛财经/A股相关归 fi-stock
kwList: ["保险", "理赔", "重疾", "增额寿", "年金", "健康险", "寿险", "财产险", "保费", "偿付能力", "银保", "险企", "保险资金", "社保", "养老", "存款", "储蓄", "债券", "理财", "固收", "国债", "大额存单", "医保", "长护险", "惠民保", "车险", "交强险", "保险法", "保险资管", "险资", "分红险", "万能险", "投保"],
// 命中排除:避免"基金"误中"基金会"之类无关标题
excludeKw: ["基金会"],
// 弱标题过滤:注册量水文、撞"投资"的游戏/创投叙事(非理财学习素材)
weakTitle: [
/现存[^,。;]{0,40}?企业超\s*\d+(?:\.\d+)?\s*万家/,
/(游戏|电竞|《巫师》|加大.{0,10}投资|阿里投资)/,
],
topicTitle: "保险理财智库 · 每周打法",
},
{
key: "fi-stock",
title: "股票基金智库",
desc: "A股/基金/二级市场每周打法研讨:大盘信号/仓位/估值/ETF/行业轮动",
hostKey: "fi_observer",
experts: [
{ key: "fi_news", focus: "市场情报与政策解读" },
{ key: "fi_investor", focus: "仓位管理与组合构建" },
{ key: "fi_problem", focus: "散户常见误区与避坑" },
{ key: "fi_loser", focus: "风险控制与心态建设" },
],
sources: [
{ name: "36氪", url: "https://36kr.com/feed" },
{ name: "IT之家", url: "https://www.ithome.com/rss/" },
{ name: "创业邦", url: "https://www.cyzone.cn/rss/" },
{ name: "新浪科技", url: "http://rss.sina.com.cn/tech/rollnews.xml" },
{ name: "钛媒体", url: "https://www.tmtpost.com/rss" },
// 财经/股票垂直列表页(RSS 不可用)
{ name: "新浪财经·股票", url: "https://finance.sina.com.cn/stock/", type: "html" },
{ name: "东方财富·股票", url: "https://stock.eastmoney.com/", type: "html" },
{ name: "东方财富·财经", url: "https://finance.eastmoney.com/", type: "html" },
{ name: "和讯股票", url: "https://stock.hexun.com/", type: "html" },
],
kwList: ["A股", "港股", "美股", "股票", "基金", "ETF", "指数", "纳指", "纳斯达克", "标普", "创业板", "上证", "深证", "北向资金", "基金经理", "公募", "私募", "定投", "估值", "市盈率", "市净率", "破净", "回购", "降准", "社融", "cpi", "pmi", "牛市", "熊市", "北交所", "涨停", "跌停", "成交量", "换手率", "板块轮动", "利率", "lpr", "降息", "央行", "通胀", "银行", "券商", "证券", "ipo"],
excludeKw: ["基金会"],
weakTitle: [/现存[^,。;]{0,40}?企业超\s*\d+(?:\.\d+)?\s*万家/, /(游戏|电竞|《巫师》|加大.{0,10}投资|阿里投资)/],
topicTitle: "股票基金智库 · 每周打法",
},
{
key: "cb-brand",
title: "品牌出海智库",
desc: "中国品牌全球化每周打法研讨:独立站/DTC/AI营销/本地化/红人",
hostKey: "yingxiao",
experts: [
{ key: "cb_observer", focus: "品牌全球化与市场选择" },
{ key: "cb_expat", focus: "海外本地化与跨文化" },
{ key: "cb_news", focus: "平台政策与出海资讯" },
{ key: "liuqing", focus: "小成本试错与内容电商" },
],
sources: [
{ name: "36氪", url: "https://36kr.com/feed" },
{ name: "IT之家", url: "https://www.ithome.com/rss/" },
{ name: "创业邦", url: "https://www.cyzone.cn/rss/" },
{ name: "新浪科技", url: "http://rss.sina.com.cn/tech/rollnews.xml" },
{ name: "DigitalCommerce360", url: "https://www.digitalcommerce360.com/feed/" },
{ name: "EcommerceBytes", url: "https://www.ecommercebytes.com/feed/" },
// 出海/品牌垂直媒体网页列表抓取(RSS 不可用)
{ name: "雨果跨境", url: "https://www.cifnews.com/", type: "html" },
{ name: "大数跨境", url: "https://www.10100.com/", type: "html" },
{ name: "白鲸出海", url: "https://www.baijingapp.com/", type: "html" },
],
kwList: ["品牌出海", "出海品牌", "DTC", "独立站", "品牌全球化", "出海", "海外市场", "本地化", "localization", "红人", "达人", "KOL", "influencer", "tiktok", "instagram", "facebook", "shein", "anker", "shopify", "红人营销", "海外红人", "众筹", "kickstarter", "indiegogo", "本土化", "海外品牌", "海外营销", "品牌营销", "全球化"],
// 弱标题过滤:剔除国内消费电子新品发布等与出海无关的同质内容
weakTitle: [
/现存[^,。;]{0,40}?企业超\s*\d+(?:\.\d+)?\s*万家/,
/(国内|国产).{0,12}(发布|亮相|开售|上新|首销)/,
],
topicTitle: "品牌出海智库 · 每周打法",
},
{
key: "ai-tools-app",
title: "AI应用智库",
desc: "AI 应用与创业每周打法研讨:Agent/提效工具/付费订阅/AI创业落地",
hostKey: "jike",
experts: [
{ key: "ai_pm", focus: "产品与PMF判断" },
{ key: "ai_news", focus: "行业动态与技术趋势" },
{ key: "ai_dev", focus: "工程落地与技术选型" },
{ key: "ai_investor", focus: "商业化/融资与泡沫甄别" },
],
sources: [
{ name: "36氪", url: "https://36kr.com/feed" },
{ name: "IT之家", url: "https://www.ithome.com/rss/" },
{ name: "创业邦", url: "https://www.cyzone.cn/rss/" },
{ name: "新浪科技", url: "http://rss.sina.com.cn/tech/rollnews.xml" },
{ name: "钛媒体", url: "https://www.tmtpost.com/rss" },
],
kwList: ["AI创业", "AI应用", "AI产品", "AI编程", "AI Agent", "智能体", "大模型", "人工智能", "ChatGPT", "GPT", "Claude", "DeepSeek", "文心", "通义", "Kimi", "豆包", "Sora", "AIGC", "SaaS", "订阅制", "ARR", "付费率", "API定价", "开源模型", "开源大模型", "提示词", "RAG", "微调", "AI落地", "AI降本", "AI提效", "企业AI", "AI出海", "AI融资", "AI商业化", "AI副业"],
weakTitle: [/现存[^,。;]{0,40}?企业超\s*\d+(?:\.\d+)?\s*万家/],
topicTitle: "AI应用智库 · 每周打法",
},
{
key: "ct-short-video",
title: "内容创作智库",
desc: "短视频/自媒体每周打法研讨:起号/选题/涨粉/算法/变现",
hostKey: "upzhu",
experts: [
{ key: "ct_creator", focus: "涨粉与商业化实战" },
{ key: "ct_news", focus: "平台动态与算法变化" },
{ key: "ct_observer", focus: "内容趋势与创作者生态" },
{ key: "ct_problem", focus: "新手避坑与平台规则" },
],
sources: [
{ name: "36氪", url: "https://36kr.com/feed" },
{ name: "IT之家", url: "https://www.ithome.com/rss/" },
{ name: "创业邦", url: "https://www.cyzone.cn/rss/" },
{ name: "新浪科技", url: "http://rss.sina.com.cn/tech/rollnews.xml" },
{ name: "钛媒体", url: "https://www.tmtpost.com/rss" },
{ name: "爱范儿", url: "https://www.ifanr.com/feed" },
{ name: "极客公园", url: "http://www.geekpark.net/rss" },
// 内容营销/创作垂直列表页(RSS 不可用)
{ name: "数英网", url: "https://www.digitaling.com/", type: "html" },
{ name: "广告门", url: "https://www.adquan.com/", type: "html" },
{ name: "站长之家", url: "https://www.chinaz.com/", type: "html" },
],
kwList: ["短视频", "抖音", "快手", "视频号", "B站", "哔哩哔哩", "小红书", "公众号", "知乎", "头条", "涨粉", "起号", "选题", "流量", "算法推荐", "直播间", "直播带货", "带货", "MCN", "签约", "分成", "商单", "投流", "千川", "星图", "中视频", "播放量", "完播率", "互动率", "个人IP", "IP打造", "自媒体", "内容创作", "内容电商", "创作者", "UP主", "种草", "内容营销", "品牌营销", "直播电商", "爆款", "达人"],
weakTitle: [/现存[^,。;]{0,40}?企业超\s*\d+(?:\.\d+)?\s*万家/],
topicTitle: "内容创作智库 · 每周打法",
},
];
// ============================ 基础工具 ============================
function loadCharacters() {
const raw = fs.readFileSync(DATA_PATH, "utf-8");
const { characters } = JSON.parse(raw);
const map = {};
for (const c of characters) map[c.key] = c;
return map;
}
async function ensureBotUser(char) {
const email = char.email || `bot_${char.key}@zhuiguang.ai`;
let u = await prisma.user.findUnique({ where: { email } });
if (!u) {
u = await prisma.user.create({
data: {
email,
name: char.displayName,
oauthProvider: "bot",
oauthId: `bot_${char.key}`,
role: "user",
isBot: true,
},
});
try {
await prisma.botConfig.create({
data: { userId: u.id, displayName: char.displayName, personality: char.personality, primaryForums: [], activeHours: [] },
});
} catch {}
console.log(`${ts()} + 创建 Bot 用户: ${char.displayName}`);
}
return u;
}
async function callAI(systemPrompt, userPrompt) {
const r = await openai.chat.completions.create({
model: MODEL,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
temperature: 0.8,
max_tokens: 4096,
});
return r.choices[0].message.content || "";
}
async function callAIJson(systemPrompt, userPrompt) {
for (let attempt = 0; attempt < 3; attempt++) {
const text = await callAI(systemPrompt, userPrompt);
const m = text.match(/\{[\s\S]*\}/);
if (!m) { console.warn(`${ts()} ⚠️ JSON 缺失,重试 ${attempt + 1}/3`); continue; }
try {
let obj = JSON.parse(m[0]);
return obj;
} catch {
try { return JSON.parse(m[0].replace(/,\s*}/g, "}").replace(/,\s*]/g, "]")); } catch { console.warn(`${ts()} ⚠️ JSON 解析失败,重试`); }
}
}
throw new Error("AI 结构化输出连续失败");
}
// 解码标题中的 HTML 实体(英文源常见 &#8216; &amp;,中文源偶见 &ldquo;)
function decodeEntities(s) {
return s
.replace(/&#x([0-9a-f]+);/gi, (m, h) => decodeCp(parseInt(h, 16), m))
.replace(/&#(\d+);/g, (m, d) => decodeCp(parseInt(d, 10), m))
.replace(/&ldquo;/g, "“")
.replace(/&rdquo;/g, "”")
.replace(/&lsquo;/g, "‘")
.replace(/&rsquo;/g, "’")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
}
function decodeCp(cp, fallback) {
return Number.isInteger(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : fallback;
}
// 抓取 RSS 标题(兼容 item/entry 两种 feed,含实体解码),失败自动重试 2 次
async function fetchFeedTitles(url) {
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; ZhuiGuangBot/1.0)" }, signal: AbortSignal.timeout(12000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const xml = await res.text();
const titles = [];
for (const m of xml.matchAll(/<(?:item|entry)>([\s\S]*?)<\/(?:item|entry)>/g)) {
const block = m[1];
const tm = block.match(/<title[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/);
if (tm) titles.push(decodeEntities(tm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim()));
}
return titles.filter(Boolean);
} catch (e) {
lastErr = e;
if (attempt < 2) await new Promise((r) => setTimeout(r, 800 * (attempt + 1)));
}
}
throw lastErr;
}
// 抓取「网页列表页」标题(方案A:RSS 不可用的垂直媒体),自动识别 GBK/UTF-8 编码
async function fetchHtmlTitles(url) {
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
"Accept-Language": "zh-CN,zh;q=0.9",
},
signal: AbortSignal.timeout(15000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
const ctype = res.headers.get("content-type") || "";
const head = buf.subarray(0, 2048).toString("latin1");
const metaCharset = (head.match(/charset=["']?([\w-]+)/i) || [])[1] || "";
const charset = (ctype.match(/charset=([\w-]+)/i) || [])[1] || metaCharset || "utf-8";
let html;
if (/gb2312|gbk|gb18030/i.test(charset)) {
try { html = new TextDecoder("gbk").decode(buf); } catch { html = buf.toString("utf-8"); }
} else {
html = buf.toString("utf-8");
}
// 锚文本启发式提取:长度 10-60、过滤导航词,随后仍由关键词再过滤一遍
const skip = /^(登录|注册|首页|更多|下载|关于我们|关于|招聘|广告|合作|版权|联系|帮助|反馈|设置|English|APP|客户端|微信|微博|返回|上一页|下一页|评论|阅读全文|查看详情|无障碍|京公网安备|经营许可证|举报|意见反馈|免责声明|新浪首页|导航)$/;
const titles = [];
for (const m of html.matchAll(/<a[^>]+href=["']([^"']+)["'][^>]*>([\s\S]{0,200}?)<\/a>/gi)) {
const t = decodeEntities(m[2].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim());
if (t.length < 10 || t.length > 60) continue;
if (skip.test(t)) continue;
if (/^\d+$/.test(t)) continue;
titles.push(t);
}
const uniq = [...new Set(titles)].slice(0, 80);
if (!uniq.length) throw new Error("未提取到标题");
return uniq;
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, 800 * (attempt + 1)));
}
}
throw lastErr;
}
// 采集命中素材 → 追加到 10 天素材库(单日命中率低,靠每日多次采集 + 10 天累积提质量)
// RSS 标题 + 站内 AI 日报(近 7 天命中)都会入库文件,collect/weekly 共用
async function fetchAndStoreNews(def) {
if (!fs.existsSync(NEWS_DIR)) fs.mkdirSync(NEWS_DIR, { recursive: true });
const file = path.join(NEWS_DIR, `${def.key}.json`);
let store = [];
if (fs.existsSync(file)) {
try { store = JSON.parse(fs.readFileSync(file, "utf8")); } catch {}
}
// 存量标题统一再解码一次(幂等清洗历史实体)
store = store.map((n) => ({ ...n, title: decodeEntities(n.title) }));
const today = new Date().toISOString().slice(0, 10);
const cutoff = new Date(Date.now() - NEWS_RETENTION_DAYS * 86400000).toISOString().slice(0, 10);
const kw = (def.kwList || []).map((k) => k.toLowerCase());
const exclude = (def.excludeKw || []).map((k) => k.toLowerCase());
const weak = def.weakTitle || [];
const hit = (t) => {
const low = t.toLowerCase();
if (exclude.some((x) => low.includes(x))) return false;
if (weak.some((re) => re.test(t))) return false;
return kw.some((k) => low.includes(k));
};
// 过期 + 重校验(剔除入库后因排除词表更新/误匹配进入的噪声)
store = store.filter((n) => n.date >= cutoff && hit(n.title));
const seen = new Set(store.map((n) => n.title));
let added = 0;
const pushItem = (title, date, url, source) => {
if (seen.has(title)) return;
seen.add(title);
store.push({ date, title, url: url || "", source });
added++;
};
// 1) RSS/网页列表源(单源单次限额,避免一个站灌满素材库)
for (const s of def.sources) {
try {
const titles = s.type === "html" ? await fetchHtmlTitles(s.url) : await fetchFeedTitles(s.url);
let srcAdded = 0;
for (const title of titles) {
if (!hit(title)) continue;
if (srcAdded >= PER_SOURCE_CAP) break;
pushItem(title, today, "", s.name);
srcAdded++;
}
} catch (e) {
console.warn(`${ts()} ⚠️ 源 ${s.name} 抓取失败: ${String(e.message).slice(0, 60)}`);
}
}
// 2) 站内 AI 日报(近 7 天标题命中)→ 一并写入素材库文件
try {
const since = new Date(Date.now() - 7 * 86400000);
const items = await prisma.dailyReportItem.findMany({
where: { createdAt: { gte: since }, report: { status: "published" } },
select: { title: true, sourceUrl: true, createdAt: true },
take: 200,
orderBy: { createdAt: "desc" },
});
for (const it of items) {
if (!hit(it.title)) continue;
pushItem(it.title, it.createdAt.toISOString().slice(0, 10), it.sourceUrl || "", "AI日报");
}
} catch (e) {
console.warn(`${ts()} ⚠️ 日报素材补充失败: ${e.message}`);
}
// 按来源配额选取,保证多源多样性(否则单一来源会灌满整个素材库)
const bySource = new Map();
for (const n of store.sort((a, b) => b.date.localeCompare(a.date))) {
const k = n.source || "未知";
if (!bySource.has(k)) bySource.set(k, []);
const arr = bySource.get(k);
if (arr.length < MAX_PER_SOURCE_STORE) arr.push(n);
}
store = [...bySource.values()].flat().sort((a, b) => b.date.localeCompare(a.date)).slice(0, 60);
fs.writeFileSync(file, JSON.stringify(store, null, 2));
const srcNames = [...new Set(store.map((n) => n.source))];
console.log(`${ts()} 素材库[${def.key}] 累计 ${store.length} 条(本次新增 ${added},来源 ${srcNames.length} 个:${srcNames.join("/")})`);
return store;
}
// 组装研讨素材块(取素材库最新 20 条)
async function buildNewsBlock(def) {
const store = await fetchAndStoreNews(def);
const top = store.slice(0, 20);
return top.length
? top.map((n) => `- [${n.date}] ${n.title}${n.source ? `(${n.source})` : ""}`).join("\n")
: "";
}
// 纯采集:供每日多次定时执行,累积 10 天素材库,不产生讨论
async function collectRun(def) {
await fetchAndStoreNews(def);
console.log(`${ts()} 🗞 [${def.key}] 素材采集完成`);
}
// ============================ 主题帖管理 ============================
function charFor(def, role) {
const map = loadCharacters();
const full = map[role.key];
if (!full) throw new Error(`角色 ${role.key} 不在角色库`);
return {
key: full.key,
displayName: full.displayName || role.key,
email: full.email,
personality: full.personality || {},
};
}
function hostChar(def) {
const full = loadCharacters()[def.hostKey];
if (!full) throw new Error(`主持角色 ${def.hostKey} 不存在`);
return full;
}
async function ensureThinktankTopic(def) {
const cat = await prisma.forumCategory.findUnique({ where: { slug: def.key } });
if (!cat) throw new Error(`板块 ${def.key} 不存在,先运行 seed-bot-forum`);
// 已有智库帖?(板块帖子量小,内存过滤避免 JSON null 过滤兼容性问题)
const cats = await prisma.forumTopic.findMany({ where: { categoryId: cat.id }, take: 80, select: { id: true, thinktank: true } });
const existing = cats.find((t) => t.thinktank && t.thinktank.kind === "thinktank");
if (existing) {
const topic = await prisma.forumTopic.findUnique({ where: { id: existing.id } });
return { cat, topic };
}
const host = hostChar(def);
const hostUser = await ensureBotUser(host);
const topic = await prisma.forumTopic.create({
data: {
categoryId: cat.id,
userId: hostUser.id,
title: `${def.topicTitle} · V1`,
slug: `thinktank-${def.key}`,
content: `# ${def.title} · V1\n\n> ${def.desc}\n\n(本板块首期打法研讨将在本周生成,此处将每周更新最新打法。)`,
isPinned: true,
thinktank: { kind: "thinktank", industry: def.key, version: 0, lastTopic: "", lastRoundAt: null, lastQuestionAt: null },
},
});
await prisma.forumCategory.update({ where: { id: cat.id }, data: { topicCount: { increment: 1 } } });
console.log(`${ts()} 📌 创建智库主题帖: ${def.topicTitle} (topic#${topic.id})`);
return { cat, topic };
}
// ============================ 每周研讨 ============================
function personaText(c) {
const p = c.personality || {};
const fp = (p.forbiddenPatterns || []).join("、");
return [
p.identity ? `身份:${p.identity}` : "",
(p.expertise || []).length ? `擅长:${p.expertise.join("、")}` : "",
(p.weakness || []).length ? `短板:${p.weakness.join("、")}` : "",
p.stance ? `立场:${p.stance}` : "",
p.speakingStyle ? `说话风格:${p.speakingStyle}` : "",
fp ? `禁用句式:${fp}` : "",
].filter(Boolean).join("\n");
}
async function expertSpeak(role, char, context) {
const sys = [
"你是一位深耕行业的资深从业者,正在参加行业智库的每周研讨。",
personaText(char),
"要求:发言有信息增量、可操作,不空谈;用口语化但专业的第一人称;不重复前面观点;200-400字;严禁编造具体新闻/数字,可引用上方提供动态或讲行业通识与经验。",
].join("\n");
const user = `【本次研讨上下文】\n${context}\n\n【你的议题视角】${role.focus}\n请以「${char.displayName}」的身份发言:`;
const content = await callAI(sys, user);
return content.trim();
}
async function weeklyRun(def) {
const { topic } = await ensureThinktankTopic(def);
const meta = (topic.thinktank || {}) ;
const version = (meta.version || 0) + 1;
console.log(`${ts()} ⚔️ [${def.key}] 第 ${version} 期研讨开始...`);
// 历史存档:上一版真实打法(非占位)先入档楼层,供回看打法演进
const host = hostChar(def);
const hostUser = await ensureBotUser(host);
if ((meta.version || 0) >= 1 && !topic.content.includes("首期打法研讨将在本周生成")) {
await prisma.forumPost.create({
data: {
topicId: topic.id,
userId: hostUser.id,
content: `📚 往期打法存档 · V${meta.version}(${new Date(meta.lastRoundAt || topic.updatedAt).toISOString().slice(0, 10)},议题:${meta.lastTopic || "—"})\n\n${topic.content}`,
},
});
await prisma.forumTopic.update({ where: { id: topic.id }, data: { replyCount: { increment: 1 }, lastReplyAt: new Date() } });
console.log(`${ts()} 📚 V${meta.version} 打法已存档为楼层`);
}
const newsBlockRaw = await buildNewsBlock(def);
const newsBlock = newsBlockRaw || "(本期素材库暂无该行业动态,请基于领域经验与往期打法展开研讨,严禁编造具体新闻)";
console.log(`${ts()} 素材块就绪(${newsBlockRaw ? newsBlockRaw.split("\n").length : 0} 条素材)`);
const prevBlock = topic.content.slice(0, 1200);
const prevQa = await prisma.forumPost.findMany({
where: { topicId: topic.id, user: { isBot: false } },
orderBy: { createdAt: "asc" },
take: 6,
select: { content: true },
});
// 1) 议题与主持开场
const agendaObj = await callAIJson(
"你是行业智库主持人,负责给出本周研讨议题。议题要聚焦本周该行业最值得讨论的1-2个真问题。只输出JSON {\"topic\":\"议题\",\"brief\":\"一句话背景\"},中文。",
`行业:${def.title}。本周动态:\n${newsBlock}\n\n往期打法摘要:\n${prevBlock}`
);
const issueText = agendaObj?.topic || `${def.title}:本周策略如何调整`;
console.log(`${ts()} 本周议题: ${issueText}`);
let context = `行业:${def.title}\n本周议题:${issueText}\n背景:${agendaObj?.brief || ""}\n本周动态:\n${newsBlock}\n\n往期打法(1楼摘要):\n${prevBlock}\n${prevQa.length ? `\n近期真人提问:\n${prevQa.map((q) => "- " + q.content.slice(0, 300)).join("\n")}` : ""}`;
// 主持开场白
const opening = await callAI(
`你是行业智库主持人「${host.displayName}」。${personaText(host)}\n请用100字左右说明本周议题并请专家依次发言。`,
context
);
await prisma.forumPost.create({ data: { topicId: topic.id, userId: hostUser.id, content: `【主持人·${host.displayName}】\n${opening.trim()}` } });
await prisma.forumTopic.update({ where: { id: topic.id }, data: { replyCount: { increment: 1 }, lastReplyAt: new Date() } });
console.log(`${ts()} 🎙 主持开场已发`);
// 2) 专家按序发言(每人参考前面发言)
const speeches = [];
for (const role of def.experts) {
const char = charFor(def, role);
const user = await ensureBotUser(char);
const speech = await expertSpeak(role, char, context);
speeches.push({ displayName: char.displayName, focus: role.focus, speech });
await prisma.forumPost.create({
data: { topicId: topic.id, userId: user.id, content: `【${char.displayName} · ${role.focus}】\n${speech}` },
});
await prisma.forumTopic.update({ where: { id: topic.id }, data: { replyCount: { increment: 1 }, lastReplyAt: new Date() } });
console.log(`${ts()} 💬 ${char.displayName} 发言完成`);
context += `\n\n【${char.displayName}发言】\n${speech}`;
await new Promise((r) => setTimeout(r, 400));
}
// 3) 合议成新打法 → 更新 1 楼(空内容防护:重试 3 次,仍空则报错不落库,避免静默写空 1 楼)
let strategy = "";
const strategySys = `你是行业智库总编,把本期研讨合议成一份《打法》。要求:结构化、可直接执行、含分歧点与共识、分"本周机会Top3/行动清单5步/风险提醒",中文、总长≤1200字。直接输出正文(勿输出一级标题、勿用图片/表格语法)。禁止编造数字与新闻,行动可操作。`;
const strategyUser = `本期议题:${issueText}\n各位专家发言:\n${speeches.map((s) => `\n【${s.displayName}】${s.speech}`).join("\n")}\n\n往期打法:\n${prevBlock}`;
for (let attempt = 0; attempt < 3; attempt++) {
strategy = (await callAI(strategySys, strategyUser)).trim();
if (strategy.length >= 80) break;
console.warn(`${ts()} ⚠️ 合议输出为空/过短(${strategy.length}字),重试 ${attempt + 2}/3`);
}
if (strategy.length < 80) throw new Error(`[${def.key}] 合议策略连续 3 次生成失败/为空,已中止本次更新`);
const content = `# ${def.title} · 第 ${version} 期打法\n\n> 议题:${issueText}\n> 生成:${new Date().toISOString().slice(0, 10)}(版本 ${version})\n\n${strategy}\n\n---\n*以上由行业智库每周研讨自动合议产出,供学习参考,不构成任何投资/经营建议。*`;
// 版本历史(回看打法演进用):缺省时补齐历史真实版
const versions = Array.isArray(meta.versions)
? [...meta.versions]
: meta.version >= 1
? [{ v: meta.version, topic: meta.lastTopic || "", date: (meta.lastRoundAt || "").slice(0, 10) }]
: [];
versions.push({ v: version, topic: issueText, date: new Date().toISOString().slice(0, 10) });
await prisma.forumTopic.update({
where: { id: topic.id },
data: {
content,
title: `${def.topicTitle} · V${version}`,
thinktank: { kind: "thinktank", industry: def.key, version, lastTopic: issueText, lastRoundAt: new Date().toISOString(), lastQuestionAt: meta.lastQuestionAt || null, versions },
},
});
console.log(`${ts()} ✅ [${def.key}] 第 ${version} 期打法已更新至 1 楼`);
}
// ============================ 回复真人提问 ============================
async function respondRun(def) {
const { topic } = await ensureThinktankTopic(def);
const meta = topic.thinktank || {};
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 } } },
});
if (!questions.length) {
console.log(`${ts()} [${def.key}] 无新真人提问`);
return;
}
console.log(`${ts()} [${def.key}] 发现 ${questions.length} 条真人提问,开始合议回复...`);
for (const q of questions) {
try {
// 挑选最相关专家应答
const pick = await callAIJson(
"从下列行业专家中选一位最适合回答该问题的专家key,只返回 {\"key\":\"...\"}。",
`专家:${def.experts.map((e) => `${e.key}(${e.focus})`).join(",")}\n提问内容:${q.content.slice(0, 400)}`
);
const chosen = def.experts.find((e) => e.key === pick?.key) || def.experts[0];
const char = charFor(def, chosen);
const user = await ensureBotUser(char);
const reply = await callAI(
`你是「${char.displayName}」,${personaText(char)}\n结合本智库最新打法与你的经验,专业且友好地回答这位学习者的提问(200-400字,口语化,可引用打法要点,勿编造)。`,
`最新打法(1楼摘要):\n${topic.content.slice(0, 1000)}\n\n提问:\n${q.content.slice(0, 500)}`
);
await prisma.forumPost.create({
data: { topicId: topic.id, userId: user.id, content: `【${char.displayName} · ${chosen.focus}】\n${reply.trim()}` },
});
await prisma.forumTopic.update({ where: { id: topic.id }, data: { replyCount: { increment: 1 }, lastReplyAt: new Date() } });
console.log(`${ts()} ↩️ ${char.displayName} 已回复真人提问 post#${q.id}`);
} 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() } },
});
}
// ============================ 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;
}
// 任务执行记录(health-check 依据 TaskLog 判定"是否执行/失败")
async function logTaskRun(action, industries, failed, startedAt) {
try {
await prisma.taskLog.create({
data: {
taskKey: "industry-thinktank",
taskName: "行业智库引擎",
status: failed.length ? "failed" : "completed",
startedAt,
finishedAt: new Date(),
duration: Date.now() - startedAt.getTime(),
triggerBy: "cron",
result: { action, industries, ok: industries.length - failed.length, failed },
error: failed.length ? `失败行业: ${failed.join(", ")}` : 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}`);
const failed = [];
for (const def of targets) {
try {
if (cmd === "init") {
const { topic } = await ensureThinktankTopic(def);
console.log(`${ts()} [${def.key}] 主题帖就绪 topic#${topic.id}`);
} else if (cmd === "collect") {
await collectRun(def);
} else if (cmd === "weekly") {
await weeklyRun(def);
} else if (cmd === "respond") {
await respondRun(def);
} else {
console.error("usage: init|collect|weekly|respond [--industry=slug] [--all]");
process.exit(1);
}
} catch (e) {
console.error(`${ts()} ❌ [${def.key}] 失败:`, e.message);
failed.push(def.key);
process.exitCode = 1;
}
}
await logTaskRun(cmd, targets.map((d) => d.key), failed, startedAt);
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error(e);
try { await prisma.$disconnect(); } catch {}
process.exit(1);
});