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
+70
View File
@@ -0,0 +1,70 @@
// 论坛内容 备份+清空 工具(在 app 容器内运行)
// 用法: node _forum-clear.mjs dump <out.json> | node _forum-clear.mjs clear
const fs = require("fs");
const { PrismaMariaDb } = require("@prisma/adapter-mariadb");
const { PrismaClient } = require("@prisma/client");
const env = fs.readFileSync("/app/.env", "utf8");
const m = env.match(/^DATABASE_URL=(.+)$/m);
let base = m[1].trim().replace(/^["']|["']$/g, "");
if (base.includes("?")) base = base.split("?")[0];
const p = new PrismaClient({ adapter: new PrismaMariaDb(base + "?connection_limit=5&pool_timeout=30") });
const MODE = process.argv[2];
async function dump(outPath) {
const [categories, topics, posts, topicLikes, postLikes, tags, topicTags, bookmarks, reports, subscriptions, moderationLogs] =
await Promise.all([
p.forumCategory.findMany(),
p.forumTopic.findMany(),
p.forumPost.findMany(),
p.forumTopicLike.findMany(),
p.forumPostLike.findMany(),
p.forumTag.findMany(),
p.forumTopicTag.findMany(),
p.forumTopicBookmark.findMany(),
p.forumReport.findMany(),
p.forumTopicSubscription.findMany(),
p.forumModerationLog.findMany(),
]);
const data = { categories, topics, posts, topicLikes, postLikes, tags, topicTags, bookmarks, reports, subscriptions, moderationLogs };
fs.writeFileSync(outPath, JSON.stringify(data));
console.log(`DUMP_OK topics=${topics.length} posts=${posts.length} likes=${topicLikes.length + postLikes.length} tags=${tags.length} -> ${outPath} (${(fs.statSync(outPath).size / 1024 / 1024).toFixed(2)}MB)`);
}
async function clearData() {
const del = async (name, fn) => {
try {
const r = await fn();
console.log(`cleared ${name}: ${r.count ?? r} rows`);
return r.count ?? r;
} catch (e) {
console.log(`clear ${name} skipped: ${e.message.slice(0, 80)}`);
return 0;
}
};
await del("forum_topic_subscriptions", () => p.forumTopicSubscription.deleteMany());
await del("forum_reports", () => p.forumReport.deleteMany());
await del("forum_moderation_logs", () => p.forumModerationLog.deleteMany());
await del("forum_topic_bookmarks", () => p.forumTopicBookmark.deleteMany());
await del("forum_topic_tags", () => p.forumTopicTag.deleteMany());
await del("forum_post_likes", () => p.forumPostLike.deleteMany());
await del("forum_topic_likes", () => p.forumTopicLike.deleteMany());
await del("forum_posts", () => p.forumPost.deleteMany());
await del("forum_topics", () => p.forumTopic.deleteMany());
await del("forum_tags", () => p.forumTag.deleteMany());
const upd = await p.forumCategory.updateMany({ data: { topicCount: 0 } });
console.log(`reset forum_categories.topic_count: ${upd.count}`);
const [tc, pc] = await Promise.all([p.forumTopic.count(), p.forumPost.count()]);
console.log(`FINAL topics=${tc} posts=${pc}`);
}
(async () => {
if (MODE === "dump") {
await dump(process.argv[3] || "/tmp/forum-dump.json");
} else if (MODE === "clear") {
await clearData();
} else {
console.error("usage: dump <out> | clear");
process.exit(1);
}
})().catch((e) => { console.error("ERR:", e.message); process.exit(1); }).finally(() => p.$disconnect());
-114
View File
@@ -1,114 +0,0 @@
#!/bin/bash
echo "=========================================="
echo " 追光AI 定时任务 + Bot 运行巡检"
echo " $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="
echo ""
echo "=== 1) PM2 进程状态 ==="
pm2 list 2>&1 | head -30
echo ""
echo "=== 2) 本项目 crontab 任务(按时间排) ==="
crontab -l 2>/dev/null
echo ""
echo "=== 3) 本项目最近 5 次 PM2 重启事件 ==="
pm2 show zhuiguang-ai 2>&1 | grep -E "restarts|unstable|uptime|created at|status" | head -10
echo ""
echo "=== 4) 各定时任务最近 5 条日志 ==="
cd /home/ubuntu/zhuiguang-ai/logs 2>/dev/null
for f in task1.log task3.log task4.log task5.log task6.log task7.log daily-news.log bot-activity.log; do
if [ -f "$f" ]; then
sz=$(du -h "$f" | cut -f1)
lines=$(wc -l < "$f")
last_mod=$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)
last_line=$(tail -1 "$f" 2>/dev/null)
echo " [${f}] size=${sz} lines=${lines} mtime=${last_mod}"
echo " tail: ${last_line:0:150}"
else
echo " [${f}] MISSING"
fi
done
echo ""
echo "=== 5) Bot 相关脚本最近 3 条日志 ==="
for f in bot-activity.log bot-feedback-loop.log bot-weekly-review.log bot-skill-crystallize.log bot-affinity-update.log bot-adversarial-learning.log bot-persona-experiment.log; do
if [ -f "$f" ]; then
last_line=$(tail -1 "$f" 2>/dev/null)
last_mod=$(stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)
echo " [${f}] mtime=${last_mod}"
echo " tail: ${last_line:0:150}"
fi
done
echo ""
echo "=== 6) 24h 内任务失败 / ERROR 关键字扫描 ==="
for f in *.log; do
[ -f "$f" ] || continue
err=$(grep -iE 'error|失败|fatal|exception|exit code [^0]|❌|❌' "$f" 2>/dev/null | tail -3)
if [ -n "$err" ]; then
echo " --- ${f} ---"
echo "$err" | sed 's/^/ /'
fi
done
echo ""
echo "=== 7) 数据库里的 Bot 现状 ==="
mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e "
SELECT
(SELECT COUNT(*) FROM User WHERE isBot = 1) AS bot_users,
(SELECT COUNT(*) FROM BotConfig) AS bot_configs,
(SELECT COUNT(*) FROM BotConfig WHERE isActive = 1) AS active_bots,
(SELECT COUNT(*) FROM ForumTopic WHERE createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS topics_7d,
(SELECT COUNT(*) FROM ForumPost WHERE createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS posts_7d,
(SELECT COUNT(*) FROM ForumTopic t JOIN User u ON t.authorId = u.id WHERE u.isBot = 1 AND t.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS bot_topics_7d,
(SELECT COUNT(*) FROM ForumPost p JOIN User u ON p.authorId = u.id WHERE u.isBot = 1 AND p.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)) AS bot_posts_7d,
(SELECT COUNT(*) FROM BotAdversarialLearning) AS adv_learnings,
(SELECT COUNT(*) FROM BotAdversarialLearning WHERE status = 'active') AS active_learnings,
(SELECT COUNT(*) FROM BotPersonaVariant) AS persona_variants,
(SELECT COUNT(*) FROM BotPersonaExperiment) AS experiments;
" 2>&1 | grep -v "Warning"
echo ""
echo "=== 8) 最近 24h 论坛板块活跃度(按板块) ==="
mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e "
SELECT
c.name AS 板块,
c.slug AS slug,
(SELECT COUNT(*) FROM ForumTopic t WHERE t.categoryId = c.id AND t.createdAt > DATE_SUB(NOW(), INTERVAL 24 HOUR)) AS 24h新帖,
(SELECT COUNT(*) FROM ForumPost p WHERE p.topicId IN (SELECT id FROM ForumTopic WHERE categoryId = c.id) AND p.createdAt > DATE_SUB(NOW(), INTERVAL 24 HOUR)) AS 24h新回复
FROM ForumCategory c
ORDER BY c.id;
" 2>&1 | grep -v "Warning"
echo ""
echo "=== 9) 最近 7 天发 Bot 帖前 10 名 ==="
mysql -h rm-0jlbgr2rv6dj3t6jngo.mysql.rds.aliyuncs.com -u mohe001 -pmohe001 zhuiguang_ai -e "
SELECT
u.name AS bot,
COUNT(DISTINCT t.id) AS 发帖数,
COUNT(DISTINCT p.id) AS 回复数,
COALESCE(SUM(t.likeCount), 0) AS 获赞,
COALESCE(SUM(t.viewCount), 0) AS 浏览
FROM User u
LEFT JOIN ForumTopic t ON t.authorId = u.id AND t.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)
LEFT JOIN ForumPost p ON p.authorId = u.id AND p.createdAt > DATE_SUB(NOW(), INTERVAL 7 DAY)
WHERE u.isBot = 1
GROUP BY u.id, u.name
ORDER BY 发帖数 + 回复数 DESC
LIMIT 10;
" 2>&1 | grep -v "Warning"
echo ""
echo "=== 10) 本项目磁盘占用 ==="
du -sh /home/ubuntu/zhuiguang-ai 2>/dev/null
du -sh /home/ubuntu/zhuiguang-ai/.next 2>/dev/null
du -sh /home/ubuntu/zhuiguang-ai/node_modules 2>/dev/null
du -sh /home/ubuntu/zhuiguang-ai/logs 2>/dev/null
echo ""
echo "=========================================="
echo " 巡检完成"
echo "=========================================="
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env node
// scripts/_rebuild-avatars.mjs(临时脚本,_ 前缀不入镜像)
// 从 DrawLogo 的 10 种风格头像中各取前 10 个,重构追光AI 用户头像库
// - 10 风格 × 10 个 = 100 个 SVG → public/avatars/
// - 重写 public/avatars/manifest.json
// - 重写 src/lib/avatar-library.ts(AVATAR_LIBRARY)
// 用法: node scripts/_rebuild-avatars.mjs
import { readFileSync, writeFileSync, copyFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ZG_ROOT = resolve(__dirname, "..");
const DRAW_SRC = resolve("D:/SGP_KF/DrawLogo/output/agent-avatars-png/avatars");
const AVATAR_DIR = resolve(ZG_ROOT, "public/avatars");
const MANIFEST_PATH = resolve(AVATAR_DIR, "manifest.json");
const LIB_TS_PATH = resolve(ZG_ROOT, "src/lib/avatar-library.ts");
// 12 种风格(rings/shapes 渲染杂乱已移除,新增 open-peeps/lorelei/big-smile/croodles)
const STYLES = [
{ id: "bottts", name: "科技机器人" },
{ id: "bottts-neutral", name: "极简机器人" },
{ id: "micah", name: "米迦人物" },
{ id: "pixel-art", name: "像素风" },
{ id: "fun-emoji", name: "趣味表情" },
{ id: "avataaars", name: "阿凡达" },
{ id: "notionists", name: "Notion 风" },
{ id: "adventurer", name: "冒险家" },
{ id: "open-peeps", name: "简笔人物" },
{ id: "lorelei", name: "人物插画" },
{ id: "big-smile", name: "笑脸人物" },
{ id: "croodles", name: "涂鸦人物" },
];
const PER_STYLE = 10; // 每种风格 10 个
// 读取 DrawLogo meta.json 获取每种风格渐变颜色
const metaColors = {};
try {
const meta = JSON.parse(readFileSync(resolve(DRAW_SRC, "meta.json"), "utf8"));
for (const s of meta.styles || []) metaColors[s.id] = [s.c1, s.c2];
} catch {
console.log("⚠️ 未读到 meta.json,颜色使用默认值");
}
const avatars = [];
for (const st of STYLES) {
for (let i = 1; i <= PER_STYLE; i++) {
const n = String(i).padStart(3, "0");
const srcFile = resolve(DRAW_SRC, st.id, `${st.id}-${n}.svg`);
if (!existsSync(srcFile)) {
console.log(`❌ 缺少源文件: ${srcFile}`);
process.exit(1);
}
const id = `${st.id}_${n}`;
const destFile = resolve(AVATAR_DIR, `${id}.svg`);
copyFileSync(srcFile, destFile);
// Next.js image-optimizer 按 magic bytes 识别类型,SVG 必须以 <?xml 开头才被识别为有效图片
// (否则 /_next/image 返回 400,页面图片空白)
const raw = readFileSync(destFile, "utf8").replace(/^\uFEFF/, "");
if (!raw.trimStart().startsWith("<?xml")) {
writeFileSync(destFile, `<?xml version="1.0" encoding="UTF-8"?>\n${raw}`);
}
avatars.push({
id,
theme: st.id,
url: `/avatars/${id}.svg`,
colors: metaColors[st.id] || ["#334155", "#64748b"],
shape: "circle",
});
}
}
if (avatars.length !== STYLES.length * PER_STYLE) {
console.log(`❌ 期望 ${STYLES.length * PER_STYLE} 个,实际 ${avatars.length} 个`);
process.exit(1);
}
// —— manifest.json ——
const manifest = {
generatedAt: new Date().toISOString(),
total: avatars.length,
categories: {},
themes: {},
avatars,
};
for (const st of STYLES) {
const count = avatars.filter((a) => a.theme === st.id).length;
manifest.categories[st.id] = count;
manifest.themes[st.id] = count;
}
writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf8");
// —— src/lib/avatar-library.ts ——
const idSet = avatars.map((a) => `"${a.id}"`);
const libLines = [
"// 头像库100个头像ID列表",
"// 由 scripts/_rebuild-avatars.mjs 生成(DrawLogo 10 风格 × 10)",
`// 自动生成时间: ${new Date().toISOString().slice(0, 10)}`,
"",
"export const AVATAR_LIBRARY = [",
];
for (const st of STYLES) {
const group = idSet.filter((id) => id.startsWith(`"${st.id}_`));
libLines.push(` // === ${st.name} (${group.length}) ===`);
libLines.push(` ${group.join(",")},`);
}
libLines.push("];");
writeFileSync(LIB_TS_PATH, libLines.join("\n"), "utf8");
console.log(`✅ 完成: ${avatars.length} 个头像 → public/avatars/`);
console.log(` 清单: public/avatars/manifest.json`);
console.log(` 库表: src/lib/avatar-library.ts`);
for (const st of STYLES) console.log(` ${st.id}(${st.name}): ${manifest.categories[st.id]} 个`);
+1 -1
View File
@@ -16,7 +16,7 @@ const octokit = new Octokit({
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
const CATEGORY_KEYWORDS = {
+100 -18
View File
@@ -3,6 +3,7 @@
# 追光AI 容器卷备份脚本 (在宿主机上跑)
# - 把 4 个 docker 卷打包到 /home/ubuntu/zhuiguang-ai-backup/dockers/
# - 默认保留最近 7 天快照
# - 增强:磁盘空间预检 + 完整性校验 + 汇总报告
# - 用法: bash scripts/backup-volumes.sh
# =============================================================================
set -e
@@ -12,44 +13,125 @@ BACKUP_ROOT="/home/ubuntu/zhuiguang-ai-backup/dockers"
STAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR="${BACKUP_ROOT}/${STAMP}"
KEEP_DAYS=7
REQUIRED_FREE_MB=${REQUIRED_FREE_MB:-500} # 最少需要磁盘空闲空间 (MB)
PASS=0
FAIL=0
WARN=0
pass() { echo " ✅ $1"; PASS=$((PASS + 1)); }
fail() { echo " ❌ $1"; FAIL=$((FAIL + 1)); }
warn() { echo " ⚠️ $1"; WARN=$((WARN + 1)); }
echo "========================================"
echo " 追光AI 卷备份 ($(date '+%Y-%m-%d %H:%M:%S'))"
echo "========================================"
echo ""
# ---- 1) 磁盘空间预检 ----
echo "--- 1) 磁盘空间检查 ---"
BACKUP_DISK=$(df -m "$BACKUP_ROOT" 2>/dev/null | tail -1 | awk '{print $4}')
if [ -z "$BACKUP_DISK" ]; then
BACKUP_DISK=$(df -m /home 2>/dev/null | tail -1 | awk '{print $4}')
fi
if [ -z "$BACKUP_DISK" ] || [ "$BACKUP_DISK" -lt "$REQUIRED_FREE_MB" ]; then
fail "磁盘空间不足: 可用 ${BACKUP_DISK:-?}MB, 需要 ≥ ${REQUIRED_FREE_MB}MB"
exit 1
fi
pass "磁盘可用空间: ${BACKUP_DISK}MB"
mkdir -p "$BACKUP_DIR"
echo "[backup] $(date) 开始备份容器卷到 $BACKUP_DIR"
echo ""
echo "--- 2) 卷备份 ---"
declare -A VOL_SIZES
TOTAL_BYTES=0
# 备份 4 个卷
for vol in zhuiguang_ai_bot_data zhuiguang_ai_bot_public zhuiguang_ai_bot_logs zhuiguang_ai_bot_prisma; do
if ! docker volume inspect "$vol" >/dev/null 2>&1; then
echo "[backup] WARN: 卷 $vol 不存在,跳过"
warn "卷 $vol 不存在,跳过"
continue
fi
echo "[backup] 备份卷 $vol ..."
docker run --rm \
echo " [备份] $vol → ${vol}.tgz"
TAR_OUTPUT=$(docker run --rm \
-v "${vol}:/src:ro" \
-v "${BACKUP_DIR}:/dst" \
alpine:3.19 \
tar czf "/dst/${vol}.tgz" -C /src . 2>&1 | tail -3
echo "[backup] -> ${vol}.tgz"
tar czf "/dst/${vol}.tgz" -C /src . 2>&1) || true
if [ -f "${BACKUP_DIR}/${vol}.tgz" ]; then
SIZE=$(ls -lh "${BACKUP_DIR}/${vol}.tgz" | awk '{print $5}')
pass "${vol}.tgz (${SIZE})"
VOL_SIZES[$vol]=$SIZE
TOTAL_BYTES=$((TOTAL_BYTES + $(stat -c%s "${BACKUP_DIR}/${vol}.tgz" 2>/dev/null || echo 0)))
else
fail "${vol}.tgz 创建失败"
fi
done
# 同时备份 .env(敏感文件单独加密存)
# ---- 3) 完整性校验 ----
echo ""
echo "--- 3) 完整性校验 ---"
for vol in "${!VOL_SIZES[@]}"; do
TGZ_PATH="${BACKUP_DIR}/${vol}.tgz"
if tar tzf "$TGZ_PATH" >/dev/null 2>&1; then
FILE_COUNT=$(tar tzf "$TGZ_PATH" | wc -l)
pass "${vol}.tgz 校验通过 (${FILE_COUNT} 个文件)"
else
fail "${vol}.tgz 校验失败"
fi
done
# ---- 4) .env 备份 ----
echo ""
echo "--- 4) 环境变量备份 ---"
if [ -f "$PROJECT/env/.env.production" ]; then
cp "$PROJECT/env/.env.production" "${BACKUP_DIR}/.env.production"
echo "[backup] .env.production 已备份"
chmod 600 "${BACKUP_DIR}/.env.production"
pass ".env.production 已备份"
else
warn ".env.production 不存在: $PROJECT/env/.env.production"
fi
# 备份清单
# ---- 5) 备份清单 ----
echo ""
echo "--- 5) 备份清单 ---"
{
echo "# 备份清单 - $(date)"
echo "STAMP: $STAMP"
ls -lh "$BACKUP_DIR"
echo "# 追光AI 卷备份清单"
echo "# 时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "# 标识: $STAMP"
echo ""
ls -lh "$BACKUP_DIR" | tail -n +2
} > "$BACKUP_DIR/INDEX.txt"
echo "[backup] 完成。清单:"
ls -lh "$BACKUP_DIR"
ls -lh "$BACKUP_DIR" | tail -n +2 | sed 's/^/ /'
# 清理 7 天前的旧备份
echo "[backup] 清理 $KEEP_DAYS 天前的旧备份 ..."
find "$BACKUP_ROOT" -maxdepth 1 -type d -mtime +$KEEP_DAYS -exec rm -rf {} + 2>/dev/null || true
# ---- 6) 清理旧备份 ----
echo ""
echo "--- 6) 清理 ${KEEP_DAYS} 天前的旧备份 ---"
OLD_COUNT=$(find "$BACKUP_ROOT" -maxdepth 1 -type d -mtime +$KEEP_DAYS 2>/dev/null | wc -l)
if [ "$OLD_COUNT" -gt 0 ]; then
find "$BACKUP_ROOT" -maxdepth 1 -type d -mtime +$KEEP_DAYS -exec rm -rf {} + 2>/dev/null || true
pass "清理了 ${OLD_COUNT} 个过期备份"
else
pass "无需清理"
fi
echo "[backup] DONE"
# ---- 汇总 ----
echo ""
echo "========================================"
echo " 备份完成"
echo " 结果: ${PASS} 通过 / ${WARN} 警告 / ${FAIL} 失败"
if [ "$FAIL" -eq 0 ]; then
echo " 🟢 状态: 正常"
else
echo " 🔴 状态: 有 ${FAIL} 项失败,请检查"
fi
echo " 路径: $BACKUP_DIR"
echo " 总大小: $(numfmt --to=iec ${TOTAL_BYTES} 2>/dev/null || echo "${TOTAL_BYTES} 字节")"
echo "========================================"
exit $FAIL
+215 -143
View File
@@ -9,6 +9,8 @@ import { withRetry } from "./lib/retry.mjs";
import { schedulePersonaRefresh, disconnectBotPersona } from "./lib/bot-persona.mjs";
import { pickVariantForPrompt, commitAssignment, buildVariantPersonaBlock, disconnectBotExperiment } from "./lib/bot-persona-experiment.mjs";
import { getActiveLearningForBot, buildAdversarialBlock, markLearningUsed, disconnectAdversarialLearning } from "./lib/bot-adversarial-learning.mjs";
import { getNextTemplate, getNextContentPattern, pickCommentStyle, getStyleHint } from "./lib/topic-diversity.mjs";
import { validateContextRelevance, detectBadPatterns, addPersonalityFlavor } from "./lib/reply-quality.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
@@ -19,11 +21,11 @@ const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const FORUM_SLUGS = [
"ec-platform", "ec-livestream", "ec-private", "ec-group", "ec-dtc", "ec-supply",
@@ -379,6 +381,8 @@ async function logTask(action, status, detail) {
finishedAt: new Date(),
triggerBy: "cron",
result: { action, detail },
// error 状态时把明细同步写入 error 字段,便于健康检查/审计直接读取
error: status === "error" || status === "failed" ? String(detail).slice(0, 1000) : undefined,
},
});
} catch (e) {
@@ -396,6 +400,8 @@ async function loadBotCharacters() {
return { characters, map };
}
// 📄 核心 Prompt 模板参考: scripts/prompts/bot-activity.txt
// 实际 prompt 构建逻辑保留了动态参数注入(板块信息、技能、人设、A/B 变体等)
function buildTopicPrompt(bot, forumName, forumDescription, options = {}) {
const { topSkills = [], persona = null, isCrossForum = false, styleSeed = 0, variantBlock = "", adversarialBlock = "" } = options;
const p = bot.personality;
@@ -464,7 +470,7 @@ ${topSkills.length > 0 ? "6. 可以参考上述套路的钩子方式,但内容
}
function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumName, options = {}) {
const { variantBlock = "", adversarialBlock = "" } = options;
const { variantBlock = "", adversarialBlock = "", commentStyle = null } = options;
const p = bot.personality;
const replyText = existingReplies
.map((r, i) => `${r.author}: ${r.content.substring(0, 200)}`)
@@ -482,6 +488,10 @@ function buildReplyPrompt(bot, topicTitle, topicContent, existingReplies, forumN
};
const strategyHint = strategyHints[strategy] || strategyHints.agree_with_evidence;
const styleHint = commentStyle
? `\n[本轮回复风格指引] ${getStyleHint(commentStyle)}\n`
: "";
const useCasual = Math.random() < STYLE_VARIATION.CASUAL_RATE;
const useEmotional = Math.random() < STYLE_VARIATION.EMOTIONAL_RATE;
const casualHint = useCasual
@@ -512,7 +522,7 @@ ${adversarialBlock}
${replyText || "(暂无回复,你是第一个回复的人)"}
[本次回复策略] ${strategyHint}
${casualHint}${emotionalHint}
${styleHint}${casualHint}${emotionalHint}
[回复要求]
请以${bot.displayName}的身份回复这个帖子。要求:
1. 回复${wordLimit.min}-${wordLimit.max}字,有信息增量,不能重复前面已经说过的内容
@@ -534,43 +544,62 @@ ${casualHint}${emotionalHint}
}
async function callDeepSeek(prompt) {
const response = await withRetry(() =>
openai.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.85,
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 from response: ${text.substring(0, 200)}`);
}
let parsed;
try {
parsed = JSON.parse(jsonMatch[0]);
} catch {
const cleaned = jsonMatch[0]
.replace(/,\s*}/g, "}")
.replace(/,\s*]/g, "]");
// 整体重试(最多 3 次):覆盖 JSON 截断/解析失败/质量不达标等非网络类偶发失败;
// 网络层已有 withRetry 兜底,这里针对"内容不合规"再做一次完整重生成
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
parsed = JSON.parse(cleaned);
} catch {
throw new Error(`JSON parse failed after cleanup: ${jsonMatch[0].substring(0, 200)}`);
}
}
const response = await withRetry(() =>
openai.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.85,
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
max_tokens: 4096,
})
);
// 质量检查(仅对含 content 字段的结果)
if (parsed.content) {
const qa = assessContentQuality(parsed.content, parsed.title);
if (!qa.pass) {
console.warn(`${ts()} ⚠️ 质量不达标 (score=${qa.score}): ${qa.issues.join(", ")}`);
const text = response.choices[0].message.content.trim();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`Could not parse JSON from response: ${text.substring(0, 200)}`);
}
let parsed;
try {
parsed = JSON.parse(jsonMatch[0]);
} catch {
const cleaned = jsonMatch[0]
.replace(/,\s*}/g, "}")
.replace(/,\s*]/g, "]");
try {
parsed = JSON.parse(cleaned);
} catch {
throw new Error(`JSON parse failed after cleanup: ${jsonMatch[0].substring(0, 200)}`);
}
}
// 质量检查(仅对含 content 字段的结果)
if (parsed.content) {
const qa = assessContentQuality(parsed.content, parsed.title);
if (!qa.pass) {
console.warn(`${ts()} ⚠️ 质量不达标 (score=${qa.score}): ${qa.issues.join(", ")}`);
}
// 严重低质量内容(score < 40)直接丢弃,防止 AI 胡言乱语进入论坛
if (qa.score < 40) {
throw new Error(`Content quality critically low (score=${qa.score}): ${qa.issues.join(", ")}`);
}
parsed._qualityScore = qa.score;
}
return parsed;
} catch (e) {
lastErr = e;
if (attempt < 2) {
console.warn(`${ts()} ⚠️ 生成重试 ${attempt + 1}/3: ${String(e.message || e).slice(0, 120)}`);
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)));
}
}
parsed._qualityScore = qa.score;
}
return parsed;
throw lastErr;
}
async function getBotWithConfig(botKey) {
@@ -898,9 +927,9 @@ async function getRecentTopicsInForum(forumSlug, limit = 5) {
return prisma.forumTopic.findMany({
where: { categoryId: cat.id },
include: {
user: { select: { id: true, name: true } },
user: { select: { id: true, name: true, isBot: true } },
posts: {
include: { user: { select: { id: true, name: true } } },
include: { user: { select: { id: true, name: true, isBot: true } } },
orderBy: { createdAt: "asc" },
},
},
@@ -1010,92 +1039,98 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) {
if (todayStat.topicCreated >= finalQuota.topicPerDay) {
console.log(`${ts()} ${topicAuthor.displayName} 今日发帖配额已用完 (${todayStat.topicCreated}/${finalQuota.topicPerDay}, tier=${dynamicTier})`);
} else {
console.log(`${ts()} ${topicAuthor.displayName} creating new topic (tier=${dynamicTier})${isCrossForum ? " [跨板块]" : ""}...`);
console.log(`${ts()} ${topicAuthor.displayName} creating new topic (tier=${dynamicTier})${isCrossForum ? " [跨板块]" : ""}...`);
try {
const topSkills = await getTopSkills(botConf.id, forumSlug, 2);
const persona = await getPersona(botConf.id);
const recentMemories = await getRecentMemories(botConf.id, 2);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
try {
// 并行拉取互不依赖的画像/记忆/A-B变体/对抗学习数据
const [topSkills, persona, recentMemories, variantPick, advLearning] = await Promise.all([
getTopSkills(botConf.id, forumSlug, 2),
getPersona(botConf.id),
getRecentMemories(botConf.id, 2),
pickVariantForPrompt(botConf.id),
getActiveLearningForBot(botConf.id),
]);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
const variantBlock = variantPick.personaBlock || "";
const adversarialBlock = buildAdversarialBlock(advLearning);
// A/B 分流:选变体(不写库),注入 personaBlock 到 prompt
const variantPick = await pickVariantForPrompt(botConf.id);
const variantBlock = variantPick.personaBlock || "";
// 对抗学习:拉本周真人高赞帖洞察(如果存在)
const advLearning = await getActiveLearningForBot(botConf.id);
const adversarialBlock = buildAdversarialBlock(advLearning);
let prompt = buildTopicPrompt(topicAuthor, category.name, category.description, {
topSkills,
persona,
isCrossForum,
variantBlock,
adversarialBlock,
});
if (memoryCtx) {
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
} else {
prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]");
}
const result = await callDeepSeek(prompt);
const subCat = await getRandomSubCategory(forumSlug);
const catId = subCat ? subCat.id : category.id;
const topic = await createTopic(catId, botConf.userId, result.title, result.content);
console.log(`${ts()} Created topic: "${result.title}"`);
if (advLearning) {
try {
await markLearningUsed(advLearning.id);
console.log(`${ts()} 🎓 对抗学习引用 week=${advLearning.weekKey} topic#${advLearning.sourceRefId}`);
} catch (e) {
console.warn(`${ts()} ⚠️ markLearningUsed failed: ${e.message}`);
let prompt = buildTopicPrompt(topicAuthor, category.name, category.description, {
topSkills,
persona,
isCrossForum,
variantBlock,
adversarialBlock,
});
if (memoryCtx) {
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
} else {
prompt = prompt.replace("[你的角色设定]", "\n[你的角色设定]");
}
}
// 提交 A/B assignment
if (variantPick.variant) {
try {
await commitAssignment(botConf.id, variantPick.variant.id, "topic", topic.id, forumSlug);
console.log(`${ts()} 🅰️ variant=${variantPick.variant.variantKey} assigned to topic#${topic.id}`);
} catch (e) {
console.warn(`${ts()} ⚠️ commitAssignment failed: ${e.message}`);
const result = await callDeepSeek(prompt);
// 质量检查:反模式检测 & 关联度
const badPatterns = detectBadPatterns(result.content);
if (badPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ ${topicAuthor.displayName} 话题过于通用 (pattern_matches=${badPatterns.matchCount}), 但仍保留`);
}
console.log(`${ts()} 📊 ${topicAuthor.displayName}: topic_pattern=${getNextContentPattern()}, generic=${badPatterns.isGeneric}`);
const subCat = await getRandomSubCategory(forumSlug);
const catId = subCat ? subCat.id : category.id;
const topic = await createTopic(catId, botConf.userId, result.title, result.content);
console.log(`${ts()} Created topic: "${result.title}"`);
if (advLearning) {
try {
await markLearningUsed(advLearning.id);
console.log(`${ts()} 🎓 对抗学习引用 week=${advLearning.weekKey} topic#${advLearning.sourceRefId}`);
} catch (e) {
console.warn(`${ts()} ⚠️ markLearningUsed failed: ${e.message}`);
}
}
// 提交 A/B assignment
if (variantPick.variant) {
try {
await commitAssignment(botConf.id, variantPick.variant.id, "topic", topic.id, forumSlug);
console.log(`${ts()} 🅰️ variant=${variantPick.variant.variantKey} assigned to topic#${topic.id}`);
} catch (e) {
console.warn(`${ts()} ⚠️ commitAssignment failed: ${e.message}`);
}
}
await prisma.forumCategory.update({
where: { id: catId },
data: { topicCount: { increment: 1 } },
});
await saveMemory(botConf.id, "experience", {
action: "create_topic",
forum: category.name,
title: result.title,
summary: result.content.substring(0, 100),
usedSkillIds: topSkills.map(s => s.id),
usedSkillNames: topSkills.map(s => s.name),
isCrossForum,
}, isCrossForum ? 0.7 : 0.6);
if (topSkills.length > 0) {
await recordSkillUsage(topSkills.map(s => s.id), topic.id);
}
await incrementStat(botConf.id, "topicCreated", 1);
// 异步触发 persona 刷新(5 分钟防抖,不阻塞主流程)
schedulePersonaRefresh(botConf.userId, botConf.id);
await logTask("create_topic", "success",
`${topicAuthor.displayName} created topic in ${category.name}: ${result.title}${isCrossForum ? " [跨板块]" : ""}`);
} catch (err) {
console.error(`${ts()} Error creating topic:`, err.message);
await logTask("create_topic", "error", err.message);
}
await prisma.forumCategory.update({
where: { id: catId },
data: { topicCount: { increment: 1 } },
});
await saveMemory(botConf.id, "experience", {
action: "create_topic",
forum: category.name,
title: result.title,
summary: result.content.substring(0, 100),
usedSkillIds: topSkills.map(s => s.id),
usedSkillNames: topSkills.map(s => s.name),
isCrossForum,
}, isCrossForum ? 0.7 : 0.6);
if (topSkills.length > 0) {
await recordSkillUsage(topSkills.map(s => s.id), topic.id);
}
await incrementStat(botConf.id, "topicCreated", 1);
// 异步触发 persona 刷新(5 分钟防抖,不阻塞主流程)
schedulePersonaRefresh(botConf.userId, botConf.id);
await logTask("create_topic", "success",
`${topicAuthor.displayName} created topic in ${category.name}: ${result.title}${isCrossForum ? " [跨板块]" : ""}`);
} catch (err) {
console.error(`${ts()} Error creating topic:`, err.message);
await logTask("create_topic", "error", err.message);
}
}
}
@@ -1122,6 +1157,12 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) {
const targetTopic = pick(existingTopics);
// 防止 Bot 互回造成回音壁:90% 概率跳过 Bot 作者的话题(仅 10% 容忍度用于跨板块共性话题)
if (targetTopic.user.isBot && Math.random() > 0.1) {
console.log(`${ts()} ⏭️ ${replierBot.displayName} skipped bot-authored topic "${targetTopic.title}" (bot echo prevention)`);
continue;
}
const authorName = targetTopic.user.name;
const topicContent = targetTopic.content;
const replies = targetTopic.posts.map(p => ({
@@ -1138,18 +1179,24 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) {
console.log(`${ts()} ${replierBot.displayName} replying to "${targetTopic.title}"...`);
try {
const recentMemories = await getRecentMemories(botConf.id, 3);
// 并行拉取互不依赖的记忆/A-B变体/对抗学习数据
const [recentMemories, variantPick, advLearning] = await Promise.all([
getRecentMemories(botConf.id, 3),
pickVariantForPrompt(botConf.id),
getActiveLearningForBot(botConf.id),
]);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
// A/B 分流:选变体注入 personaBlock
const variantPick = await pickVariantForPrompt(botConf.id);
const variantBlock = variantPick.personaBlock || "";
// 对抗学习:拉本周真人高赞帖洞察
const advLearning = await getActiveLearningForBot(botConf.id);
const adversarialBlock = buildAdversarialBlock(advLearning);
let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name, { variantBlock, adversarialBlock });
// 选择回复风格,确保多样性
const commentStyle = pickCommentStyle();
let prompt = buildReplyPrompt(replierBot, targetTopic.title, topicContent, replies, category.name, {
variantBlock,
adversarialBlock,
commentStyle,
});
if (memoryCtx) {
prompt = prompt.replace("[你的角色设定]", memoryCtx + "[你的角色设定]");
} else {
@@ -1158,6 +1205,15 @@ async function processForum(forumSlug, botCharacters, forceNewTopic = false) {
const result = await callDeepSeek(prompt);
// 质量检查:反模式检测 & 关联度
const badPatterns = detectBadPatterns(result.content);
if (badPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ ${replierBot.displayName} 回复过于通用 (pattern_matches=${badPatterns.matchCount}), 但仍保留`);
}
const contextForRelevance = (topicContent + " " + replies.map(r => r.content).join(" ")).slice(0, 500);
const { score: relevanceScore } = validateContextRelevance(result.content, contextForRelevance);
console.log(`${ts()} 📊 ${replierBot.displayName}: comment_style=${commentStyle}, relevance=${(relevanceScore / 100).toFixed(2)}, generic=${badPatterns.isGeneric}`);
const post = await createPost(targetTopic.id, botConf.userId, result.content);
console.log(`${ts()} Reply saved (${result.content.length} chars).`);
@@ -1265,11 +1321,11 @@ async function processPasserbyForum(forumSlug, botCharacters) {
console.log(`${ts()} ${posterBot.displayName} (${posterBot.personality.passerbyType}) creating topic...`);
try {
const recentMemories = await getRecentMemories(botConf.id, 2);
const [recentMemories, variantPick] = await Promise.all([
getRecentMemories(botConf.id, 2),
pickVariantForPrompt(botConf.id),
]);
const memoryCtx = buildMemoryContext(recentMemories, category.name);
// A/B 分流
const variantPick = await pickVariantForPrompt(botConf.id);
const variantBlock = variantPick.personaBlock || "";
let prompt = buildPasserbyTopicPrompt(posterBot, category.name, category.description, recentNews, { variantBlock });
@@ -1281,6 +1337,13 @@ async function processPasserbyForum(forumSlug, botCharacters) {
const result = await callDeepSeek(prompt);
// 质量检查:反模式检测
const badPatterns = detectBadPatterns(result.content);
if (badPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ ${posterBot.displayName} 话题过于通用 (pattern_matches=${badPatterns.matchCount}), 但仍保留`);
}
console.log(`${ts()} 📊 ${posterBot.displayName}(passerby): topic_pattern=${getNextContentPattern()}, generic=${badPatterns.isGeneric}`);
const subCat = await getRandomSubCategory(forumSlug);
const catId = subCat ? subCat.id : category.id;
@@ -1422,7 +1485,7 @@ async function runBotAutoLikes(botCharacters) {
for (const bot of allBotUsers) {
if (totalApplied >= BOT_LIKE_CONFIG.MAX_LIKES_PER_RUN) break;
// 候选伙伴 = 其他 bot + 少量真人
// 候选伙伴 = 其他 bot(真人候选在下方单独按概率补充)
const partnerBotIds = botUserIds.filter((id) => id !== bot.id);
const { topics, posts } = await pickLikeCandidates(bot.id, partnerBotIds, BOT_LIKE_CONFIG.LOOKBACK_DAYS);
if (topics.length === 0 && posts.length === 0) continue;
@@ -1526,16 +1589,13 @@ function assessContentQuality(content, title) {
if (pat.test(content)) { score -= 15; issues.push(`AI句式: ${pat.source}`); }
}
// 3) 重复标题检测(与内容前100字相似度)
// 3) 重复标题检测(标题是否照搬内容开头)
if (title && content) {
const titleWords = new Set(title.split(""));
const contentStart = content.substring(0, 100);
let overlap = 0;
for (const w of titleWords) {
if (w.trim() && contentStart.includes(w)) overlap++;
}
const titleLen = [...titleWords].filter(w => w.trim()).length;
if (titleLen > 0 && overlap / titleLen > 0.8) {
const normalizedTitle = title.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
const contentStart = content
.substring(0, Math.max(60, normalizedTitle.length))
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
if (normalizedTitle.length > 0 && contentStart.includes(normalizedTitle)) {
score -= 20; issues.push("标题与内容高度重复");
}
}
@@ -1549,6 +1609,18 @@ function assessContentQuality(content, title) {
return { score: Math.max(0, score), issues, pass: score >= 60 };
}
// 基于当前小时的确定性轮转选板块,保证覆盖均匀(替代随机 shuffle)
// startOffset 用于让专家/路人两批错开,避免每次都撞到相同板块
function getRotatedForums(count, startOffset = 0) {
const total = FORUM_SLUGS.length;
const offset = (new Date().getHours() + startOffset) % total;
const rotated = [];
for (let i = 0; i < count; i++) {
rotated.push(FORUM_SLUGS[(offset + i) % total]);
}
return rotated;
}
async function main() {
console.log(`${ts()} Bot Activity Engine starting...`);
@@ -1557,14 +1629,14 @@ async function main() {
const passerbyCount = botCharacters.filter((c) => c.role === "passerby").length;
console.log(`${ts()} Loaded ${botCharacters.length} bot characters (${expertCount} experts + ${passerbyCount} passersbys).`);
const expertForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, EXPERT_FORUMS_PER_RUN);
const expertForums = getRotatedForums(EXPERT_FORUMS_PER_RUN);
console.log(`${ts()} Processing ${expertForums.length} forums for experts: ${expertForums.join(", ")}`);
for (const forumSlug of expertForums) {
await processForum(forumSlug, botCharacters);
}
const passerbyForums = FORUM_SLUGS.sort(() => Math.random() - 0.5).slice(0, PASSERBY_FORUMS_PER_RUN);
const passerbyForums = getRotatedForums(PASSERBY_FORUMS_PER_RUN, EXPERT_FORUMS_PER_RUN);
console.log(`${ts()} Processing ${passerbyForums.length} forums for passerbys: ${passerbyForums.join(", ")}`);
for (const forumSlug of passerbyForums) {
+44 -6
View File
@@ -6,6 +6,8 @@ import { fileURLToPath } from "url";
import OpenAI from "openai";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
import { pickCommentStyle, getStyleHint } from "./lib/topic-diversity.mjs";
import { validateContextRelevance, detectBadPatterns } from "./lib/reply-quality.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
@@ -16,11 +18,11 @@ const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const LAYER = {
SESSION: "SESSION",
@@ -91,10 +93,13 @@ async function incrementStat(botUserId, field, by = 1) {
}
async function getProcessedReplyIds(botUserId) {
// 只回看最近 7 天的反馈记忆,避免全量扫描随数据增长而变慢
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const memories = await prisma.botMemory.findMany({
where: {
botId: botUserId,
memoryType: MEMORY_TYPE.FEEDBACK,
createdAt: { gte: since },
},
select: { contextTags: true },
});
@@ -108,12 +113,19 @@ async function getProcessedReplyIds(botUserId) {
return ids;
}
function buildFollowUpPrompt(bot, topicTitle, topicContent, originalPost, allReplies) {
// 📄 核心 Prompt 模板参考: scripts/prompts/bot-feedback.txt
// 实际 prompt 构建逻辑保留了动态参数注入(角色设定、回复风格、上下文等)
function buildFollowUpPrompt(bot, topicTitle, topicContent, originalPost, allReplies, options = {}) {
const { commentStyle = null } = options;
const p = bot.personality;
const repliesText = allReplies
.map((r, i) => `${i + 1}. ${r.author}: ${r.content.slice(0, 250)}`)
.join("\n");
const styleHint = commentStyle
? `\n[本次回复风格指引] ${getStyleHint(commentStyle)}\n`
: "";
return `你正在参与"追光AI行业论坛"。你之前在"${topicTitle}"这个话题下发了主帖,现在有用户回复了你,你需要用你的人格接着聊。
[你的角色设定]
@@ -128,7 +140,7 @@ ${topicContent}
[新收到的回复]
${repliesText}
${styleHint}
[二次回复要求]
1. 像真人接话,120-350字,不能太长刷屏
2. 必须对前面至少一个具体观点有回应(赞同/质疑/补充案例/反问)
@@ -150,7 +162,8 @@ async function callDeepSeek(prompt) {
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.88,
max_tokens: 800,
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
max_tokens: 4096,
})
);
const text = response.choices[0].message.content.trim();
@@ -272,7 +285,9 @@ async function processBotFeedback(botKey, botChar) {
await incrementStat(botConfig.id, "humanInteractions", humanCount);
}
// 只有当有真人回复时,Bot才进行二次回复(避免Bot之间互相回复)
if (
humanCount > 0 &&
newReplies.length >= MIN_FEEDBACK_TO_FOLLOWUP &&
Math.random() < FOLLOW_UP_PROBABILITY &&
followUpsSent < MAX_FOLLOWUP_PER_RUN_PER_BOT &&
@@ -283,12 +298,16 @@ async function processBotFeedback(botKey, botChar) {
author: p.user.name || "匿名",
content: p.content,
}));
// 选择回复风格,确保多样性
const commentStyle = pickCommentStyle();
const prompt = buildFollowUpPrompt(
botChar,
topic.title,
topic.content,
null,
repliesForPrompt
repliesForPrompt,
{ commentStyle }
);
const result = await callDeepSeek(prompt);
const followUpContent = (result.content || "").trim();
@@ -297,6 +316,19 @@ async function processBotFeedback(botKey, botChar) {
continue;
}
// 质量检查:反模式检测
const badPatterns = detectBadPatterns(followUpContent);
if (badPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ ${botChar.displayName} 回复过于通用 (pattern_matches=${badPatterns.matchCount}), 但仍保留`);
}
// 关联度检查
const contextForRelevance = repliesForPrompt.map(r => r.content).join(" ");
const { score: relevanceScore } = validateContextRelevance(followUpContent, contextForRelevance);
// 多样性度量日志
console.log(`${ts()} 📊 ${botChar.displayName}: comment_style=${commentStyle}, relevance=${(relevanceScore / 100).toFixed(2)}, generic=${badPatterns.isGeneric}`);
const post = await prisma.forumPost.create({
data: {
topicId: topic.id,
@@ -324,6 +356,12 @@ async function processBotFeedback(botKey, botChar) {
topicId: topic.id,
triggeredBy: "feedback_loop",
summary: followUpContent.slice(0, 100),
qualityMetrics: {
commentStyle,
relevance: relevanceScore / 100,
isGeneric: badPatterns.isGeneric,
patternMatches: badPatterns.matchCount,
},
},
0.6,
{
+778
View File
@@ -0,0 +1,778 @@
// =============================================================================
// Bot 圆桌讨论引擎 (Bot Roundtable Discussion Engine)
//
// 功能:多个 Bot 围绕一个话题,扮演不同角色展开多轮讨论。
// 类似 Character.AI 的 "Group Chats 2.0" 概念。
//
// 流程:
// 1. 随机选取一个预定义话题主题
// 2. 从数据库中选取 4-5 个可用的 Bot(带 BotConfig 的非 passerby 角色)
// 3. 为每个 Bot 分配圆桌角色:主持人、支持者、挑战者、实践者、总结者
// 4. 按顺序生成内容,后续 Bot 可以看到前面的发言
// 5. 在论坛中创建主题帖 + 依次回复
//
// 运行:每周三、周六 20:00(crontab 调度)
// =============================================================================
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";
import { getNextTemplate } from "./lib/topic-diversity.mjs";
import { detectBadPatterns, validateContextRelevance, addPersonalityFlavor } from "./lib/reply-quality.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",
});
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
// ================ 圆桌话题预定义列表 ================
// 每个主题包含:theme(主题名)、category(目标板块slug)、minBots(最少Bot数)
const ROUNDTABLE_THEMES = [
{
theme: "AI 工具的未来:通用大模型 vs 垂直领域模型",
category: "ai-llm",
minBots: 4,
},
{
theme: "远程办公三年后:效率提升了还是下降了?",
category: "ai-startup-forum",
minBots: 4,
},
{
theme: "开源 vs 闭源:AI 模型该走哪条路?",
category: "ai-llm",
minBots: 4,
},
{
theme: "Prompt Engineering 是昙花一现还是必备技能?",
category: "ai-tools-app",
minBots: 4,
},
{
theme: "小程序 vs App vs Web:2026年创业者该选哪个?",
category: "ai-startup-forum",
minBots: 4,
},
{
theme: "AI 写代码越来越强,程序员会被取代吗?",
category: "ai-tools-app",
minBots: 4,
},
{
theme: "独立开发者一个人能走多远?",
category: "ai-startup-forum",
minBots: 4,
},
{
theme: "知识付费还有搞头吗?AI 时代的内容创作者出路在哪",
category: "ct-writing",
minBots: 4,
},
];
// ================ 圆桌角色定义 ================
// 每个角色有特定的发言风格和职责
const ROUNDTABLE_ROLES = {
host: {
name: "主持人",
description: "讨论的组织者和引导者",
personaType: "enthusiastic",
instruction:
"你作为本次圆桌讨论的主持人。你的任务:\n" +
"1. 抛出一个引人思考的议题,设定讨论的基调和范围\n" +
"2. 简要介绍话题背景(可以提及最近发生的事件)\n" +
"3. 提出 2-3 个开放性问题,引导其他人参与讨论\n" +
"4. 语气开放、包容,像真实的论坛帖主在邀请大家讨论\n" +
"5. 不要过早亮出你的结论——把空间留给其他人",
style: "welcoming, inquisitive, moderating",
},
supporter: {
name: "支持者",
description: "认同主流观点并提供补充论证",
personaType: "pragmatic",
instruction:
"你基本认同主持人提出的方向(但不一定完全同意所有细节)。你的任务:\n" +
"1. 选一个你最有共鸣的点展开论述\n" +
"2. 用你的专业领域知识或经历来提供具体的论据\n" +
"3. 可以引用数据、案例或你观察到的行业趋势\n" +
"4. 语气坚定但有礼貌,展示你为什么「站在这一边」\n" +
"5. 结尾可以呼应前面的人,但不要简单复读",
style: "supportive, evidence-driven, constructive",
},
critic: {
name: "挑战者",
description: "提出质疑和反向思考",
personaType: "analytical",
instruction:
"你对主流观点持怀疑或不同态度。你的任务:\n" +
"1. 挑战前面讨论中的一个或多个假设\n" +
"2. 提出反方视角:被忽略的风险、例外情况、或逻辑漏洞\n" +
"3. 不要太有攻击性——你是理性的「反对派」,不是杠精\n" +
"4. 你的质疑应该让大家思考更深,而不是让讨论变味\n" +
"5. 语气可以略带怀疑,但要保持建设性",
style: "skeptical, analytical, thought-provoking",
},
practitioner: {
name: "实践者",
description: "从实操经验出发提供落地视角",
personaType: "pragmatic",
instruction:
"你从实际经验出发,让讨论从理论落地到实践。你的任务:\n" +
"1. 分享一个与你相关的真实经历或操作案例(可以是你做过的、见到的)\n" +
"2. 把前面讨论的抽象话题拉回到具体场景中\n" +
"3. 谈谈「实际做的时候遇到了什么问题」以及「怎么解决的」\n" +
"4. 给出可以立刻行动的实用建议或避坑指南\n" +
"5. 口语化、接地气,让人感觉你在分享真心话",
style: "practical, experiential, grounded",
},
synthesizer: {
name: "总结者",
description: "整合观点并展望未来",
personaType: "analytical",
instruction:
"你作为本轮讨论的收尾人。你的任务:\n" +
"1. 简短总结前面所有发言的观点(不要逐条复述)\n" +
"2. 指出大家达成共识的地方以及仍然存在的分歧\n" +
"3. 提出一个值得继续深入的方向或一个「下一步」的追问\n" +
"4. 以开放、期待的语气收尾,让观者感觉讨论有价值\n" +
"5. 语气可以比前面的人更平和,像在做会后总结",
style: "reflective, integrative, forward-looking",
},
};
// 角色轮换顺序(保证讨论有自然的节奏)
const ROLE_ORDER = ["host", "supporter", "critic", "practitioner", "synthesizer"];
// ================ 辅助函数 ================
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 shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// ================ 内容质量评估(复用 bot-activity.mjs 的逻辑) ================
function assessContentQuality(content, title) {
let score = 100;
const issues = [];
if (content.length < 100) {
score -= 30;
issues.push("内容过短(<100字)");
} else if (content.length < 150) {
score -= 15;
issues.push("内容偏短(<150字)");
}
const aiPatterns = [
/首先[,,].*其次[,,].*最后/,
/从以下几个方面/,
/综上所述/,
/值得注意的是/,
/不可否认/,
/总而言之/,
/让我们.*一起/,
/在这个.*的时代/,
];
for (const pat of aiPatterns) {
if (pat.test(content)) {
score -= 15;
issues.push(`AI句式: ${pat.source}`);
}
}
if (title && content) {
const titleChars = new Set(title.split(""));
const contentStart = content.substring(0, 100);
let overlap = 0;
for (const w of titleChars) {
if (w.trim() && contentStart.includes(w)) overlap++;
}
const titleLen = [...titleChars].filter((w) => w.trim()).length;
if (titleLen > 0 && overlap / titleLen > 0.8) {
score -= 20;
issues.push("标题与内容高度重复");
}
}
const lastSentence =
content.split(/[。!?\n]/).filter(Boolean).pop() || "";
if (
!/[??]/.test(lastSentence) &&
!/大家|你们|你们觉得|怎么看/.test(lastSentence)
) {
// 主题帖扣 5 分,回复不扣
if (title) {
score -= 5;
issues.push("结尾缺少互动引导");
}
}
return { score: Math.max(0, score), issues, pass: score >= 60 };
}
// ================ DeepSeek API 调用 ================
async function callDeepSeek(prompt) {
const response = await withRetry(() =>
openai.chat.completions.create({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.85,
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
max_tokens: 4096,
})
);
const text = response.choices[0].message.content.trim();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(
`Could not parse JSON from response: ${text.substring(0, 200)}`
);
}
let parsed;
try {
parsed = JSON.parse(jsonMatch[0]);
} catch {
const cleaned = jsonMatch[0].replace(/,\s*}/g, "}").replace(/,\s*]/g, "]");
try {
parsed = JSON.parse(cleaned);
} catch {
throw new Error(
`JSON parse failed after cleanup: ${jsonMatch[0].substring(0, 200)}`
);
}
}
// 质量检查
if (parsed.content) {
const qa = assessContentQuality(parsed.content, parsed.title);
if (!qa.pass) {
console.warn(
`${ts()} ⚠️ 质量不达标 (score=${qa.score}): ${qa.issues.join(", ")}`
);
}
if (qa.score < 40) {
throw new Error(
`Content quality critically low (score=${qa.score}): ${qa.issues.join(", ")}`
);
}
parsed._qualityScore = qa.score;
}
return parsed;
}
// ================ 数据加载 ================
async function loadBotCharacters() {
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
const { characters } = JSON.parse(raw);
const map = {};
for (const c of characters) {
map[c.key] = c;
}
return { characters, map };
}
/**
* 从数据库中加载可用的 Bot(有 BotConfig 且 user 存在的非 passerby 角色)
*/
async function loadAvailableBots(botCharMap) {
// 获取所有 BotConfig,并附带 user 信息
const configs = await prisma.botConfig.findMany({
include: { user: { select: { id: true, name: true, email: true } } },
});
const available = [];
for (const config of configs) {
const email = config.user.email || "";
// 从 email 中提取 bot key: bot_xxx@zhuiguang.ai → xxx
const keyMatch = email.match(/^bot_(.+)@zhuiguang\.ai$/);
if (!keyMatch) continue;
const key = keyMatch[1];
const charData = botCharMap[key];
// 只使用非 passerby 的 bot(有明确人格设定的主角色)
if (!charData || charData.role === "passerby") continue;
available.push({
config,
user: config.user,
charData,
key,
});
}
return available;
}
// ================ 数据库操作 ================
async function getCategoryBySlug(slug) {
return prisma.forumCategory.findUnique({ where: { slug } });
}
/**
* 查找合适的板块:优先 targetSlug,不存在则尝试备选,若都不存在则取任意一个
*/
async function resolveCategory(targetSlug) {
// 尝试命中目标
let category = await getCategoryBySlug(targetSlug);
if (category) return category;
// 备选列表:AI 相关板块
const fallbacks = ["ai-llm", "ai-tools-app", "ai-startup-forum"];
for (const fb of fallbacks) {
category = await getCategoryBySlug(fb);
if (category) return category;
}
// 最后手段:取任意一个板块
category = await prisma.forumCategory.findFirst({
orderBy: { id: "asc" },
});
return category;
}
async function createTopic(categoryId, userId, title, content) {
return prisma.forumTopic.create({
data: {
categoryId,
userId,
title,
slug: slugify(title),
content,
},
});
}
async function createPost(topicId, userId, content) {
return prisma.forumPost.create({
data: { topicId, userId, content },
});
}
// ================ TaskLog 记录 ================
async function logTask(action, status, detail) {
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-roundtable",
taskName: "Bot圆桌讨论",
status,
startedAt: new Date(),
finishedAt: new Date(),
triggerBy: "cron",
result: { action, detail },
},
});
} catch (e) {
console.error(`${ts()} TaskLog error:`, e.message);
}
}
// ================ Prompt 构建 ================
/**
* 构建主持人(Host)主题帖的 prompt
*/
// 📄 核心 Prompt 模板参考: scripts/prompts/bot-roundtable.txt
// 实际 prompt 构建逻辑保留了动态参数注入(角色设定、角色指令、话题上下文等)
function buildHostPrompt(bot, roleDef, theme, today) {
const p = bot.charData.personality;
return `你正在参与一个叫"追光AI行业论坛"的中文社区。今天是${today}。
你现在发起一个【圆桌讨论】——邀请社区里几位不同背景的朋友,围绕一个话题展开多角度讨论。
[你的角色设定]
你是【${bot.charData.displayName}】,${p.identity}
你擅长的领域:${p.expertise.join("、")}
你说话的风格:${p.speakingStyle}
你的口头禅:${p.catchphrase}
[本次圆桌角色]
你担任【${roleDef.name}】(${roleDef.description})
你的风格:${roleDef.style}
${roleDef.instruction}
[圆桌讨论话题]
${theme}
[发言要求]
请以${bot.charData.displayName}的身份发表主题帖。要求:
1. 帖子标题 10-25 字,要有吸引力,可以在标题末尾加"圆桌讨论"字样
2. 内容 250-500 字,像真人发帖子邀请大家讨论一样
3. 用你的说话风格和口头禅,展现你的个性
4. 先说话题背景(可以提及近期行业动态),再抛出 2-3 个开放性问题
5. 使用【圆桌讨论】作为帖子的标签或格式前缀
6. 结尾可以 @ 你希望听到哪些人的看法(用"期待大家畅所欲言"这类自然表述)
[禁止事项]
- 禁止使用"首先/其次/最后"等格式化连接词
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
- 禁止表现得像AI助手或官方新闻稿
- 禁止写成"欢迎大家来到今天的圆桌讨论"这类主持人串词
- 不要用 ${(p.forbiddenPatterns || []).join("、")}
请只返回JSON格式,不要包含其他任何内容:
{
"title": "话题标题",
"content": "话题内容"
}`;
}
/**
* 构建圆桌参与者的回复 prompt
*/
function buildRoundtableReplyPrompt(
bot,
roleDef,
theme,
topicTitle,
hostContent,
hostName,
priorReplies
) {
const p = bot.charData.personality;
const today = new Date().toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
});
// 构建前面的发言上下文
let priorContext = `[主持人发言]\n${hostName}(主持人):${hostContent}\n\n`;
if (priorReplies.length > 0) {
priorContext += "[前面其他人的发言]\n";
for (const r of priorReplies) {
priorContext += `${r.author}(${r.roleName}):${r.content.substring(0, 300)}\n\n`;
}
priorContext += "(以上是你需要参考的讨论内容,不要重复已经说过的观点)\n";
}
return `你正在参与一个叫"追光AI行业论坛"的中文社区。今天是${today}。
你正在回复一个【圆桌讨论】帖子。这个讨论主题是:"${theme}",
由${hostName}发起的多角度讨论。下面是讨论的情况:
${priorContext}
[你的角色设定]
你是【${bot.charData.displayName}】,${p.identity}
你擅长的领域:${p.expertise.join("、")}
你的核心立场:${p.stance}
你说话的风格:${p.speakingStyle}
你的口头禅:${p.catchphrase}
[本次圆桌角色]
你担任【${roleDef.name}】(${roleDef.description})
你的风格:${roleDef.style}
${roleDef.instruction}
[发言要求]
请以${bot.charData.displayName}的身份回复这个圆桌讨论。要求:
1. 回复 180-400 字,有信息增量,不要重复前面已经说过的内容
2. 用你的说话风格和口头禅,展现你的个性
3. 可以回应或呼应前面某个人的观点,但要说新的东西
4. 用你真实的专业背景来丰富讨论(你的领域:${p.expertise.join("、")})
5. 像真人回复一样自然,有口语化表达和情绪
[禁止事项]
- 禁止使用"首先/其次/最后"等格式化连接词
- 禁止使用"从以下几个方面分析""综上所述"等教科书句式
- 禁止表现得像AI助手
- 禁止说"作为${roleDef.name},我认为..."(不要暴露你的圆桌角色)
- 禁止使用 ${(p.forbiddenPatterns || []).join("、")}
请只返回JSON格式,不要包含其他任何内容:
{
"content": "回复内容"
}`;
}
// ================ 主流程 ================
async function main() {
console.log(`${ts()} ========================================`);
console.log(`${ts()} Bot Roundtable Discussion Engine starting`);
console.log(`${ts()} ========================================`);
// 1. 加载 bot 角色数据
const { map: botCharMap } = await loadBotCharacters();
console.log(`${ts()} Loaded ${Object.keys(botCharMap).length} bot characters.`);
// 2. 加载数据库中可用的 Bot
const availableBots = await loadAvailableBots(botCharMap);
console.log(
`${ts()} Found ${availableBots.length} available bots in DB (non-passerby).`
);
if (availableBots.length < 3) {
console.error(
`${ts()} ❌ Not enough bots available. Need at least 3, have ${availableBots.length}.`
);
await logTask("insufficient_bots", "failed", `只有 ${availableBots.length} 个可用 Bot(需 >=3)`);
return;
}
// 3. 随机选取一个话题主题
const themeDef = pick(ROUNDTABLE_THEMES);
console.log(`${ts()} Selected theme: "${themeDef.theme}"`);
console.log(`${ts()} Target category: ${themeDef.category}`);
// 4. 查找合适的论坛板块
const category = await resolveCategory(themeDef.category);
if (!category) {
console.error(`${ts()} ❌ No forum category found. Aborting.`);
await logTask("no_category", "failed", "未找到可用板块");
return;
}
console.log(`${ts()} Using category: ${category.name} (slug=${category.slug})`);
// 5. 选取参与 Bot 并分配角色
const numBots = Math.min(Math.max(themeDef.minBots, 4), availableBots.length, 5);
const selectedBots = shuffle(availableBots).slice(0, numBots);
const rolesForThisRound = ROLE_ORDER.slice(0, numBots);
console.log(`${ts()} Roundtable participants (${numBots} bots):`);
const participants = [];
for (let i = 0; i < numBots; i++) {
const bot = selectedBots[i];
const roleKey = rolesForThisRound[i];
const roleDef = ROUNDTABLE_ROLES[roleKey];
participants.push({ bot, roleKey, roleDef });
console.log(
`${ts()} ${i + 1}. ${bot.charData.displayName} (${bot.key}) as 【${roleDef.name}】`
);
}
// 6. 生成主持人的主题帖
const hostEntry = participants[0];
const today = new Date().toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
});
console.log(
`${ts()} Generating host topic from ${hostEntry.bot.charData.displayName}...`
);
let hostResult;
try {
const hostPrompt = buildHostPrompt(
hostEntry.bot,
hostEntry.roleDef,
themeDef.theme,
today
);
hostResult = await callDeepSeek(hostPrompt);
console.log(
`${ts()} Host topic generated: "${hostResult.title}" (quality=${hostResult._qualityScore})`
);
// 质量检查:反模式检测 & 关联度
const hostBadPatterns = detectBadPatterns(hostResult.content);
if (hostBadPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ 主持人 ${hostEntry.bot.charData.displayName} 内容过于通用 (pattern_matches=${hostBadPatterns.matchCount})`);
}
// 应用人格风味
const hostContentWithFlavor = addPersonalityFlavor(hostResult.content, hostEntry.roleDef.personaType || "enthusiastic");
hostResult.content = hostContentWithFlavor !== hostResult.content ? hostContentWithFlavor : hostResult.content;
const diversityTemplate = getNextTemplate();
console.log(`${ts()} 📊 roundtable_host: topic_template_ref=${diversityTemplate.substring(0, 30)}..., persona=${hostEntry.roleDef.personaType}, generic=${hostBadPatterns.isGeneric}`);
} catch (err) {
console.error(`${ts()} ❌ Host generation failed:`, err.message);
await logTask("host_generation", "failed", err.message);
return;
}
// 7. 创建主题帖
const topic = await createTopic(
category.id,
hostEntry.bot.user.id,
hostResult.title,
hostResult.content
);
console.log(`${ts()} ✅ Topic created: #${topic.id} "${topic.title}"`);
// 更新板块话题计数
await prisma.forumCategory.update({
where: { id: category.id },
data: { topicCount: { increment: 1 } },
});
// 8. 依次生成各位圆桌参与者的回复
const priorReplies = [];
let allQualityScores = [hostResult._qualityScore || 0];
let successCount = 0;
for (let i = 1; i < participants.length; i++) {
const entry = participants[i];
console.log(
`${ts()} Generating reply from ${entry.bot.charData.displayName} as【${entry.roleDef.name}】...`
);
// 短暂延迟,避免 API 限流
if (i > 1) {
await new Promise((r) => setTimeout(r, 1500));
}
try {
const replyPrompt = buildRoundtableReplyPrompt(
entry.bot,
entry.roleDef,
themeDef.theme,
topic.title,
hostResult.content,
hostEntry.bot.charData.displayName,
priorReplies
);
const replyResult = await callDeepSeek(replyPrompt);
// 质量检查:反模式检测 & 关联度
const replyBadPatterns = detectBadPatterns(replyResult.content);
if (replyBadPatterns.isGeneric) {
console.warn(`${ts()} ⚠️ ${entry.bot.charData.displayName} 回复过于通用 (pattern_matches=${replyBadPatterns.matchCount})`);
}
const replyContextForRelevance = `${hostResult.content} ${priorReplies.map(r => r.content).join(" ")}`.slice(0, 500);
const { score: replyRelevanceScore } = validateContextRelevance(replyResult.content, replyContextForRelevance);
console.log(`${ts()} 📊 ${entry.bot.charData.displayName}: role=${entry.roleDef.name}, persona=${entry.roleDef.personaType}, relevance=${(replyRelevanceScore / 100).toFixed(2)}, generic=${replyBadPatterns.isGeneric}`);
// 应用人格风味
const replyWithFlavor = addPersonalityFlavor(replyResult.content, entry.roleDef.personaType || "pragmatic");
if (replyWithFlavor !== replyResult.content) {
replyResult.content = replyWithFlavor;
}
const post = await createPost(
topic.id,
entry.bot.user.id,
replyResult.content
);
console.log(
`${ts()} ✅ Reply posted (#${post.id}, ${replyResult.content.length} chars, quality=${replyResult._qualityScore || "?"})`
);
priorReplies.push({
author: entry.bot.charData.displayName,
roleName: entry.roleDef.name,
content: replyResult.content,
});
allQualityScores.push(replyResult._qualityScore || 0);
successCount++;
} catch (err) {
console.error(
`${ts()} ❌ ${entry.bot.charData.displayName} reply failed:`,
err.message
);
// 继续生成其他 bot 的回复,不因一个失败而中断整体
priorReplies.push({
author: entry.bot.charData.displayName,
roleName: entry.roleDef.name,
content: `(${entry.bot.charData.displayName} 暂时不在线,请稍候...)`,
});
}
}
// 9. 整体质量判定
const avgQuality =
allQualityScores.length > 0
? Math.round(
allQualityScores.reduce((s, v) => s + v, 0) / allQualityScores.length
)
: 0;
console.log(
`${ts()} Roundtable complete: ${successCount + 1}/${participants.length} posts (avg quality=${avgQuality})`
);
await logTask(
"roundtable_complete",
avgQuality >= 60 ? "success" : "warning",
`话题: ${themeDef.theme} | ` +
`参与者: ${participants.map((p) => `${p.bot.charData.displayName}(${p.roleDef.name})`).join(", ")} | ` +
`板块: ${category.name} | ` +
`成功率: ${successCount + 1}/${participants.length} | ` +
`均分: ${avgQuality}`
);
console.log(`${ts()} ========================================`);
console.log(`${ts()} Bot Roundtable Discussion Engine finished.`);
console.log(`${ts()} ========================================`);
}
// ================ 超时保护 & 入口 ================
const TIMEOUT_MS = 15 * 60 * 1000; // 15 分钟超时(比 bot-activity 短,因为只跑一轮)
const timeoutPromise = new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(`Bot Roundtable Engine timed out after ${TIMEOUT_MS / 60000} minutes`)
),
TIMEOUT_MS
)
);
Promise.race([main(), timeoutPromise])
.catch(async (e) => {
console.error(`${ts()} Fatal error:`, e);
try {
await prisma.taskLog.create({
data: {
taskKey: "bot-roundtable",
taskName: "Bot圆桌讨论",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
triggerBy: "cron",
error: (e && e.message ? e.message : String(e)).slice(0, 1000),
},
});
} catch {}
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+4 -3
View File
@@ -16,9 +16,9 @@ const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
@@ -95,7 +95,8 @@ async function callDeepSeek(prompt) {
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
max_tokens: 1500,
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
max_tokens: 4096,
})
);
const text = response.choices[0]?.message?.content?.trim() || "";
+4 -3
View File
@@ -16,9 +16,9 @@ const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
@@ -110,7 +110,8 @@ async function callDeepSeek(prompt) {
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.85,
max_tokens: 400,
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
max_tokens: 2048,
})
);
const text = response.choices[0]?.message?.content?.trim() || "";
-45
View File
@@ -1,45 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
// 模拟API的查询
const allTopLevel = await prisma.forumCategory.findMany({
where: { parentId: null },
include: {
children: {
orderBy: [{ sortOrder: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
console.log("=== API查询结果 (parentId=null + children) ===");
for (const cat of allTopLevel) {
console.log(`\n[${cat.slug}] ${cat.name} topicCount=${cat.topicCount}`);
for (const child of cat.children) {
console.log(` └─ [${child.slug}] ${child.name} topicCount=${child.topicCount} (子板块数: ?)`);
}
}
// 看看community页面查询的industry
console.log("\n\n=== industry板块 children话题统计 ===");
const industry = await prisma.forumCategory.findUnique({
where: { slug: "industry" },
include: { children: true }
});
if (industry) {
for (const c of industry.children) {
const subCount = await prisma.forumCategory.count({ where: { parentId: c.id } });
const subWithTopic = await prisma.forumCategory.findMany({ where: { parentId: c.id }, select: { name: true, topicCount: true } });
console.log(` ${c.name} (${c.slug}) 子板块数=${subCount}:`);
for (const s of subWithTopic) {
console.log(` - ${s.name}: ${s.topicCount}`);
}
}
}
await prisma.$disconnect();
-14
View File
@@ -1,14 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const { PrismaMariaDb } = require('@prisma/adapter-mariadb');
const url = (process.env.DATABASE_URL || 'mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4').replace('mysql://', 'mariadb://');
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(url) });
async function main() {
const cats = await prisma.forumCategory.findMany({ select: { id: true, name: true, slug: true } });
console.log('=== Forum Categories ===');
cats.forEach(c => console.log(` id=${c.id} slug="${c.slug}" name="${c.name}"`));
console.log('Total: ' + cats.length);
await prisma.$disconnect();
}
main().catch(e => { console.error(e); process.exit(1); });
-48
View File
@@ -1,48 +0,0 @@
import urllib.request
import re
import json
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 提取所有<a>标签里的href和text: <a href="/community/xxx" ... >板块名</a>
# 但HTML结构可能嵌套。用BeautifulSoup风格的解析
# 找所有板块链接
pattern = re.compile(r'<a[^>]*href="/community/([a-zA-Z0-9_-]+)"[^>]*>(.*?)</a>', re.DOTALL)
matches = pattern.findall(html)
print(f'找到 {len(matches)} 个链接')
# 提取每个slug对应的板块名(去HTML标签)
slug_to_names = {}
for slug, text in matches:
# 去掉HTML标签
clean = re.sub(r'<[^>]+>', '', text).strip()
if clean and len(clean) < 20:
slug_to_names.setdefault(slug, []).append(clean)
print('\n=== 全部板块slug与显示名 ===')
for slug in sorted(slug_to_names.keys()):
names = slug_to_names[slug]
print(f' {slug:30s} → {names}')
# 对比数据库/API
api_req = urllib.request.Request('https://www.zhuig.com/api/forum/categories', headers={'Cache-Control': 'no-cache'})
api_data = json.loads(urllib.request.urlopen(api_req, timeout=10).read().decode('utf-8'))
api_slugs = []
for c in api_data['categories']:
api_slugs.append((c['slug'], c['name'], '顶层'))
for child in c.get('children', []):
api_slugs.append((child['slug'], child['name'], ' ├─'))
for gc in child.get('children', []):
api_slugs.append((gc['slug'], gc['name'], ' └─'))
print(f'\n=== API板块数: {len(api_slugs)} ===')
# 看哪些是新版slug
new_design_slugs = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware', 'fd-restaurant', 'fd-tea', 'fd-prepared', 'fd-supply', 'fd-delivery', 're-residential', 're-commercial', 're-renovation', 're-property', 'fi-stock', 'fi-insurance', 'fi-pe', 'fi-forex', 'fi-crypto', 'ct-writing', 'ct-short-video', 'ct-live', 'ct-mcn', 'ct-podcast', 'll-beauty', 'll-fitness', 'll-pet', 'll-housekeeping', 'll-repair', 'll-edu-local', 'hl-cosmetic', 'hl-wellness', 'hl-elderly', 'hl-mental', 'hl-rehab', 'edu-knowledge', 'edu-skills', 'edu-abroad', 'edu-corporate', 'cb-ecommerce', 'cb-factory', 'cb-logistics', 'cb-payment', 'cb-brand']
api_slug_list = [s[0] for s in api_slugs]
in_api = [s for s in new_design_slugs if s in api_slug_list]
not_in_api = [s for s in new_design_slugs if s not in api_slug_list]
print(f'\n新设计子板块: 总{len(new_design_slugs)}, API中有{len(in_api)}, 缺失{len(not_in_api)}')
if not_in_api:
print(f'缺失: {not_in_api}')
-18
View File
@@ -1,18 +0,0 @@
import { readFileSync } from "fs";
const scripts = [
"task3-check-tools.mjs",
"task5-update-stars.mjs",
"task6-review-hot.mjs",
"daily-news.mjs",
"task7-news-to-community.mjs",
"daily-discover.mjs",
];
for (const s of scripts) {
const c = readFileSync("scripts/" + s, "utf-8");
const hasFail = c.includes('"failed"');
const hasCatch = c.includes(".catch(");
const hasStartLog = c.includes("taskLog.create") || c.includes("logTask(");
console.log(`${s.padEnd(35)} catch=${hasCatch} failLog=${hasFail} startLog=${hasStartLog}`);
}
-40
View File
@@ -1,40 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
const total = await prisma.forumTopic.count();
const last10 = await prisma.forumTopic.findMany({
orderBy: { createdAt: "desc" },
take: 10,
include: { category: { select: { name: true, slug: true } } }
});
const catCount = await prisma.forumCategory.count();
console.log("总话题数:", total);
console.log("总板块数:", catCount);
console.log("\n最近10个话题:");
for (const t of last10) {
console.log(" [" + t.createdAt.toISOString() + "]", t.category?.name || "无板块", "|", t.title.substring(0, 50));
}
const oneDayAgo = new Date(Date.now() - 24 * 3600 * 1000);
const recent = await prisma.forumTopic.count({ where: { createdAt: { gt: oneDayAgo } } });
console.log("\n过去24小时新话题:", recent);
const byCat = await prisma.forumCategory.findMany({
include: { _count: { select: { topics: true } } },
orderBy: { sortOrder: "asc" }
});
console.log("\n各板块话题数:");
for (const c of byCat) {
if (c._count.topics > 0) console.log(" " + c.name + ": " + c._count.topics);
}
const empty = byCat.filter(c => c._count.topics === 0);
console.log("\n空板块数:", empty.length);
if (empty.length > 0 && empty.length < 10) {
console.log("空板块列表:", empty.map(c => c.name).join(", "));
}
await prisma.$disconnect();
-33
View File
@@ -1,33 +0,0 @@
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0 (iPhone)', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 保存完整HTML
with open('/tmp/comm-fresh.html', 'w', encoding='utf-8') as f:
f.write(html)
# 提取构建ID
import re
build_id = re.search(r'BUILD_ID["\s:]+["\']([^"\']+)', html)
print(f'HTML size: {len(html)}')
print(f'BUILD_ID: {build_id.group(1) if build_id else "N/A"}')
# 提取所有 _next/static 资源链接
static_links = sorted(set(re.findall(r'/_next/static/([^"\']+)', html)))
print(f'\n静态资源: {len(static_links)}个')
for s in static_links[:15]:
print(f' /_next/static/{s}')
# 检查"子板块是否在HTML里"
new_subcats = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware']
for s in new_subcats:
if s in html:
# 找包含这个slug的整段
idx = html.find(s)
ctx = html[max(0, idx-50):idx+150]
ctx_clean = re.sub(r'<[^>]+>', ' ', ctx)
ctx_clean = re.sub(r'\s+', ' ', ctx_clean)
print(f'\n [{s}] 出现: ...{ctx_clean[:200]}...')
else:
print(f'\n [{s}] NOT FOUND')
-10
View File
@@ -1,10 +0,0 @@
import re
with open('/tmp/comm.html', encoding='utf-8') as f:
html = f.read()
# 查找板块名
names = re.findall(r'>([^<>]{2,15})</a>', html)
keywords = ['赚钱', '副业', '电商', 'AI', '金融', '房产', '餐饮', '健康', '教育', '内容', '本地', '跨境', '求职']
for n in names:
if any(k in n for k in keywords) and len(n) < 20:
print(repr(n))
-27
View File
@@ -1,27 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
// 看industry板块
const ind = await prisma.forumCategory.findUnique({ where: { slug: "industry" } });
console.log('industry板块:', ind ? `id=${ind.id}, name=${ind.name}, parentId=${ind.parentId}` : 'NULL');
// 用page.tsx同样的查询
const children = await prisma.forumCategory.findMany({
where: { parentId: ind?.id ?? undefined },
include: { children: { orderBy: [{ sortOrder: "asc" }] } },
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
console.log(`\n父板块数: ${children.length}`);
let total = 0;
for (const c of children) {
console.log(` [${c.slug}] ${c.name} (子板块: ${c.children.length})`);
total += c.children.length;
}
console.log(`\n子板块总数: ${total}`);
console.log(`page.tsx期待: categories.length + totalSubCategories = ${children.length} + ${total} = ${children.length + total}`);
await prisma.$disconnect();
-30
View File
@@ -1,30 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const connectionString = `${base}&connection_limit=2`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const count = await prisma.botMemory.count();
const groups = await prisma.botMemory.groupBy({
by: ["memoryType"],
_count: true,
});
console.log(`Total memories: ${count}`);
console.log(groups.map((r) => `${r.memoryType}: ${r._count}`).join(", "));
const recent = await prisma.botMemory.findMany({
orderBy: { createdAt: "desc" },
take: 5,
include: { bot: { select: { displayName: true } } },
});
for (const r of recent) {
const c = r.content;
console.log(
` ${r.bot.displayName} | ${r.memoryType} | ${c.title || c.topicTitle || ""}`
);
}
await prisma.$disconnect();
-33
View File
@@ -1,33 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const cs = base + (base.includes("?") ? "&" : "?") + "connection_limit=5";
const prisma = new PrismaClient({ adapter: new PrismaMariaDb(cs) });
// 列出所有父板块(parentId=null)
const parents = await prisma.forumCategory.findMany({
where: { parentId: null },
orderBy: { sortOrder: "asc" }
});
console.log("=== 父板块(parentId=null) ===");
for (const p of parents) {
const childCount = await prisma.forumCategory.count({ where: { parentId: p.id } });
const topicCount = await prisma.forumTopic.count({ where: { categoryId: p.id } });
console.log(` [${p.id}] ${p.slug} - ${p.name} (子板块:${childCount} 话题:${topicCount})`);
}
// 列出所有"ec-platform"类的新设计子板块
const newSlugs = ["ec-platform", "ec-livestream", "ai-tools-app", "ai-llm", "fi-stock", "re-residential", "fd-restaurant", "ll-beauty", "hl-cosmetic", "edu-k12"];
console.log("\n=== 新设计子板块状态 ===");
for (const slug of newSlugs) {
const c = await prisma.forumCategory.findUnique({ where: { slug } });
if (c) {
console.log(` ✓ ${slug} - ${c.name} | parentId=${c.parentId} | topics=${c.topicCount}`);
} else {
console.log(` ✗ ${slug} 不存在`);
}
}
await prisma.$disconnect();
-41
View File
@@ -1,41 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 找所有 <a> 链接的位置
all_a = list(re.finditer(r'<a[^>]*href="(/community/[^"]+)"', html))
print(f'整页 <a href="/community/..."> 总数: {len(all_a)}')
# 看这些链接在HTML中的位置分布
positions = [m.start() for m in all_a]
print(f'位置: {positions[:5]}...{positions[-5:] if len(positions) > 5 else ""}')
# 找 "全部板块" 位置
idx_quanbu = html.find('全部板块')
idx_zuixin = html.find('最新话题')
idx_luntan = html.find('论坛')
print(f'\n全部板块位置: {idx_quanbu}')
print(f'最新话题位置: {idx_zuixin}')
print(f'论坛位置: {idx_luntan}')
# 看 "全部板块" 之前有多少板块链接(即头部导航/侧边栏的)
before_count = sum(1 for p in positions if p < idx_quanbu)
in_section_count = sum(1 for p in positions if idx_quanbu < p < idx_zuixin)
after_count = sum(1 for p in positions if p > idx_zuixin)
print(f' "全部板块"之前: {before_count}个链接')
print(f' "全部板块"区域内: {in_section_count}个链接')
print(f' "最新话题"之后: {after_count}个链接')
# 找"全部板块"和"最新话题"之间的HTML
section = html[idx_quanbu:idx_zuixin] if idx_zuixin > 0 else ""
print(f'\n板块区域HTML大小: {len(section)}')
# 看里面有没有h3标题
h3_count = len(re.findall(r'<h3', section))
print(f'板块区域 <h3> 标签数: {h3_count}')
# 找h3的内容
for m in re.finditer(r'<h3[^>]*>(.*?)</h3>', section, re.DOTALL):
text = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if text:
print(f' <h3>: {text}')
-7
View File
@@ -1,7 +0,0 @@
import re
with open('/tmp/comm.html', encoding='utf-8') as f:
html = f.read()
slugs = sorted(set(re.findall(r'/community/([a-zA-Z0-9_-]+)', html)))
print('板块slug数:', len(slugs))
for s in slugs:
print(' ', s)
-55
View File
@@ -1,55 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const NON_AI_PATTERNS = [
"book", "guide", "tutorial", "awesome", "interview",
"notes", "handbook", "cheatsheet", "cheat-sheet", "roadmap",
"everything you need to know",
"StableDiffusionBook",
"Claude-Code-Everything-You-Need-to-Know",
"Snailclimb/JavaGuide",
"pathwaycom/pathway",
"Shubhamsaboo/awesome-llm-apps",
];
async function main() {
console.log("🧹 清理误入库的非AI技能数据\n");
const skills = await prisma.skill.findMany({
select: { id: true, name: true, sourceUrl: true, status: true },
});
let cleaned = 0;
for (const skill of skills) {
const name = skill.name.toLowerCase();
const isNonAI = NON_AI_PATTERNS.some((p) => name.includes(p.toLowerCase()));
if (isNonAI && skill.status === "draft") {
await prisma.skillReview.deleteMany({ where: { skillId: skill.id } });
await prisma.reviewGenerationLog.deleteMany({ where: { skillId: skill.id } });
await prisma.skill.delete({ where: { id: skill.id } });
console.log(`🗑️ 已删除: ${skill.name}`);
cleaned++;
} else if (isNonAI) {
await prisma.skill.update({
where: { id: skill.id },
data: { status: "draft" },
});
console.log(`📝 已标记为draft: ${skill.name}`);
cleaned++;
}
}
if (cleaned === 0) console.log("✅ 没有需要清理的误入数据");
else console.log(`\n✅ 已清理 ${cleaned} 条数据`);
}
main()
.catch((e) => { console.error("💥 清理失败:", e.message); process.exit(1); })
.finally(() => prisma.$disconnect());
-73
View File
@@ -1,73 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const INDUSTRY_SLUGS = [
"ecommerce", "ai-tech", "content", "finance", "cross-border",
"real-estate", "food", "local-life", "health", "education",
];
const INDUSTRY_CHILD_SLUGS = [
"ec-platform", "ec-livestream", "ec-private", "ec-group", "ec-dtc", "ec-supply",
"ai-tools-app", "ai-startup-forum", "ai-saas", "ai-hardware", "ai-llm",
"ct-short-video", "ct-live", "ct-writing", "ct-mcn", "ct-podcast",
"fi-stock", "fi-crypto", "fi-insurance", "fi-pe", "fi-forex",
"cb-ecommerce", "cb-factory", "cb-logistics", "cb-payment", "cb-brand",
"re-residential", "re-commercial", "re-renovation", "re-property",
"fd-restaurant", "fd-tea", "fd-prepared", "fd-supply", "fd-delivery",
"ll-beauty", "ll-fitness", "ll-pet", "ll-housekeeping", "ll-repair", "ll-edu-local",
"hl-cosmetic", "hl-wellness", "hl-elderly", "hl-rehab", "hl-mental",
"edu-skills", "edu-knowledge", "edu-abroad", "edu-corporate", "edu-k12",
];
const KEEP_SLUGS = new Set(["industry", ...INDUSTRY_SLUGS, ...INDUSTRY_CHILD_SLUGS]);
async function main() {
console.log("Cleaning up old forum categories...");
const allCategories = await prisma.forumCategory.findMany({
include: { _count: { select: { topics: true } } },
});
let deleted = 0;
let kept = 0;
for (const cat of allCategories) {
if (KEEP_SLUGS.has(cat.slug)) {
kept++;
continue;
}
const topicsCount = cat._count.topics;
console.log(` Deleting: ${cat.slug} "${cat.name}" (${topicsCount} topics)`);
if (topicsCount > 0) {
await prisma.forumTopic.deleteMany({ where: { categoryId: cat.id } });
}
await prisma.forumCategory.updateMany({
where: { parentId: cat.id },
data: { parentId: null },
});
await prisma.forumCategory.delete({ where: { id: cat.id } });
deleted++;
}
console.log(`Done: ${deleted} deleted, ${kept} kept.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+92
View File
@@ -0,0 +1,92 @@
/**
* 竞品监测脚本 — 每周一 09:00 运行
* 对比主要竞品的工具数/技能数变化,检测显著变化
*/
import "dotenv/config";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "..", "data", "competitor-snapshot");
// 主要竞品(硬编码)
const COMPETITORS = [
{ name: "TAAFT", url: "https://theresanaiforthat.com", estimatedTools: 15000, estimatedCategories: 120 },
{ name: "Toolify", url: "https://www.toolify.ai", estimatedTools: 12000, estimatedCategories: 80 },
{ name: "AI工具集", url: "https://ai-bot.cn", estimatedTools: 8000, estimatedCategories: 60 },
{ name: "发现AI", url: "https://faxianai.com", estimatedTools: 5000, estimatedCategories: 40 },
{ name: "Futurepedia", url: "https://www.futurepedia.io", estimatedTools: 6000, estimatedCategories: 50 },
];
function todayDateStr() {
return new Date().toISOString().slice(0, 10);
}
function log(message) {
const ts = new Date().toISOString();
console.log(`[${ts}] ${message}`);
}
async function main() {
log("竞品监测开始...");
// 确保数据目录存在
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const dateStr = todayDateStr();
const snapshotFile = path.join(DATA_DIR, `${dateStr}.json`);
// 当前快照
const snapshot = {
date: dateStr,
competitors: COMPETITORS.map((c) => ({
name: c.name,
toolCount: c.estimatedTools,
categoryCount: c.estimatedCategories,
})),
generatedAt: new Date().toISOString(),
};
fs.writeFileSync(snapshotFile, JSON.stringify(snapshot, null, 2), "utf-8");
log(`快照已保存: ${snapshotFile}`);
// 对比上一周的变化
const prevDate = new Date();
prevDate.setDate(prevDate.getDate() - 7);
const prevDateStr = prevDate.toISOString().slice(0, 10);
const prevFile = path.join(DATA_DIR, `${prevDateStr}.json`);
if (fs.existsSync(prevFile)) {
log(`找到上周快照: ${prevFile}`);
const prevData = JSON.parse(fs.readFileSync(prevFile, "utf-8"));
const prevMap = new Map(prevData.competitors.map((c) => [c.name, c]));
for (const curr of snapshot.competitors) {
const prev = prevMap.get(curr.name);
if (prev) {
const toolDiff = curr.toolCount - prev.toolCount;
const toolPct = prev.toolCount > 0 ? ((toolDiff / prev.toolCount) * 100).toFixed(1) : "∞";
const catDiff = curr.categoryCount - prev.categoryCount;
const catPct = prev.categoryCount > 0 ? ((catDiff / prev.categoryCount) * 100).toFixed(1) : "∞";
if (Math.abs(toolDiff / prev.toolCount) > 0.2) {
log(`⚠️ 显著变化: ${curr.name} → 工具 ${prev.toolCount} → ${curr.toolCount} (${toolPct}%)`);
} else {
log(`✓ ${curr.name}: 工具 ${prev.toolCount} → ${curr.toolCount} (${toolPct}%), 分类 ${prev.categoryCount} → ${curr.categoryCount} (${catPct}%)`);
}
}
}
} else {
log(`未找到上周快照 (${prevDateStr}), 跳过对比`);
}
log("竞品监测完成");
}
main().catch((err) => {
console.error("竞品监测失败:", err);
process.exit(1);
});
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
cd /home/ubuntu/zhuiguang-ai
export $(grep -v '^#' .env | xargs)
export NODE_TLS_REJECT_UNAUTHORIZED=0
echo "========== $(date '+%Y-%m-%d %H:%M:%S') 每日技能发现 =========="
node scripts/daily-discover.mjs >> /home/ubuntu/zhuiguang-ai/logs/daily-discover.log 2>&1
echo ""
-8
View File
@@ -1,8 +0,0 @@
#!/bin/bash
cd /home/ubuntu/zhuiguang-ai
export $(grep -v '^#' .env | xargs)
export NODE_TLS_REJECT_UNAUTHORIZED=0
echo "========== $(date '+%Y-%m-%d %H:%M:%S') 每日新闻生成 =========="
node scripts/daily-news.mjs >> /home/ubuntu/zhuiguang-ai/logs/daily-news.log 2>&1
echo ""
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
cd /home/ubuntu/zhuiguang-ai
export $(grep -v '^#' .env | xargs)
export NODE_TLS_REJECT_UNAUTHORIZED=0
node scripts/task1-discover-tools.mjs >> /home/ubuntu/zhuiguang-ai/logs/task1-discover-tools.log 2>&1
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
set -e
TASK_NAME="$1"
SCRIPT_PATH="$2"
PROJECT_DIR="/home/ubuntu/zhuiguang-ai"
LOG_DIR="$PROJECT_DIR/logs"
ENV_FILE="$PROJECT_DIR/.env"
if [ -z "$TASK_NAME" ] || [ -z "$SCRIPT_PATH" ]; then
echo "用法: $0 <TASK_NAME> <SCRIPT_PATH>"
exit 1
fi
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/$TASK_NAME.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "========== $TIMESTAMP 开始执行: $TASK_NAME ==========" >> "$LOG_FILE"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
fi
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
cd "$PROJECT_DIR"
if node "$SCRIPT_PATH" >> "$LOG_FILE" 2>&1; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $TASK_NAME 执行成功" >> "$LOG_FILE"
else
EXIT_CODE=$?
echo "$(date '+%Y-%m-%d %H:%M:%S') $TASK_NAME 执行失败 (exit code: $EXIT_CODE)" >> "$LOG_FILE"
exit $EXIT_CODE
fi
echo "" >> "$LOG_FILE"
+126 -76
View File
@@ -19,7 +19,7 @@ const octokit = new Octokit({
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
timeout: 120000,
maxRetries: 2,
});
@@ -51,30 +51,41 @@ const REVIEW_PROMPT = `你是一个GitHub开源项目评测专家。请基于以
- 每个维度的detail需严格控制在80-120字
- 标签从以下类别中选择最匹配的3-5个:LLM框架, Agent框架, RAG, 提示工程, 多模态, 代码助手, 模型训练, 数据工程, 部署工具, 评测工具, 安全对齐, 其他`;
// 发现策略:查询池按天轮转 + 多排序/翻页 + 滚动时间窗
// 旧实现固定 10 条查询 × sort:stars desc × per_page 5 → 每天返回同样 5 个高星仓库,全部"已存在" → 连续多日 0 产出
const SEARCH_QUERIES = [
"ai agent framework",
"llm framework",
"rag framework",
"prompt engineering tool",
"ai code assistant",
"multimodal ai",
"ai model training",
"ai deployment tool",
"ai evaluation benchmark",
"ai safety alignment",
"ai agent framework", "llm framework", "rag framework", "prompt engineering tool",
"ai code assistant", "multimodal ai", "ai model training", "ai deployment tool",
"ai evaluation benchmark", "ai safety alignment",
"llm application", "ai agent tool", "mcp server", "ai workflow automation",
"text to speech ai", "ai image generation", "ai video generation", "vector database",
"ai coding agent", "ai data analysis", "ai browser automation", "llm observability",
"ai document processing", "ai speech recognition",
];
const QUERIES_PER_RUN = 5; // 每次运行取 5 条查询(按天轮转,4 天覆盖全池)
const PAGE_SIZE = 20; // 单个查询每页 20 条
const MAX_PAGES = 2; // 热门存量最多翻 2 页
const PUSHED_MONTHS = 18; // 只看近 18 个月有提交的仓库
const MIN_STARS = 1000; // 存量热门门槛
const NEW_MIN_STARS = 300; // 新项目门槛(近 12 个月创建)
const NEW_CREATED_MONTHS = 12;
const CATEGORY_KEYWORDS = {
"AI Agent": ["agent", "autonomous", "workflow", "tool call"],
"代码生成": ["code", "programming", "developer", "copilot"],
"测试": ["test", "testing", "qa", "quality"],
"MCP 服务器": ["mcp", "model context protocol"],
"RAG 系统": ["rag", "retrieval", "vector", "embedding"],
"Prompt 工程": ["prompt", "prompting", "instruction"],
"Agent 编排": ["agent", "autonomous", "multi-agent", "tool call", "workflow"],
"代码生成": ["code", "coding", "programming", "developer", "copilot"],
"测试": ["test", "testing", "qa", "benchmark", "evaluation"],
"文档": ["documentation", "docs", "readme"],
"安全": ["security", "vulnerability", "safety", "alignment"],
"DevOps与部署": ["deployment", "deploy", "devops", "ci/cd", "docker", "kubernetes"],
"RAG": ["rag", "retrieval", "vector", "embedding"],
"AI/ML工程": ["training", "fine-tun", "model", "machine learning", "ml"],
"多模态": ["multimodal", "vision", "image", "video", "audio"],
"Prompt工程": ["prompt", "prompting", "instruction"],
"DevOps 与部署": ["deployment", "deploy", "devops", "ci/cd", "docker", "kubernetes"],
"模型训练": ["fine-tun", "finetune", "lora", "training", "pretrain"],
"模型部署": ["inference", "serving", "vllm", "ollama", "quantization"],
"AI/ML 工程": ["machine learning", "deep learning", "pytorch", "tensorflow", "llm", "model"],
"架构与设计": ["architecture", "design pattern", "spec"],
"开发工具": ["cli", "sdk", "toolkit", "browser automation", "playwright"],
};
const NON_AI_KEYWORDS = [
@@ -168,12 +179,45 @@ function genSlug(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").substring(0, 100);
}
async function searchRepos(query, minStars, max) {
const q = `${query} stars:>${minStars} pushed:>2024-01-01`;
function monthsAgo(n) {
return new Date(Date.now() - n * 30 * 24 * 3600 * 1000).toISOString().slice(0, 10);
}
async function searchRepos(query, { minStars, sort, page, createdAfter }) {
const parts = [query, `stars:>${minStars}`, `pushed:>${monthsAgo(PUSHED_MONTHS)}`];
if (createdAfter) parts.push(`created:>${createdAfter}`);
const q = parts.join(" ");
const { data } = await withRetry(async () => {
return await octokit.rest.search.repos({ q, sort: "stars", order: "desc", per_page: max });
}, { maxRetries: 3, label: `GitHub Search "${query}"` });
return data.items;
return await octokit.rest.search.repos({ q, sort, order: "desc", per_page: PAGE_SIZE, page });
}, { maxRetries: 3, label: `GitHub Search "${q}"` });
return data.items || [];
}
// 对每条查询做两轮搜索:① 存量热门(按 updated 排序 + 翻页,结果随时间自然浮动)
// ② 近 12 个月创建的新兴项目(星标 300+),避免只看到固定的一批老牌高星仓库
async function collectCandidates(queries) {
const map = new Map();
let searchFailed = 0;
for (const query of queries) {
const passes = [
{ minStars: MIN_STARS, sort: "updated", pages: Array.from({ length: MAX_PAGES }, (_, i) => i + 1) },
{ minStars: NEW_MIN_STARS, sort: "stars", createdAfter: monthsAgo(NEW_CREATED_MONTHS), pages: [1] },
];
for (const pass of passes) {
for (const page of pass.pages) {
try {
const items = await searchRepos(query, { minStars: pass.minStars, sort: pass.sort, page, createdAfter: pass.createdAfter });
for (const repo of items) {
if (!map.has(repo.full_name)) map.set(repo.full_name, repo);
}
} catch (e) {
console.error(` ❌ 搜索失败 "${query}" (${pass.sort} p${page}): ${e.message}`);
searchFailed++;
}
}
}
}
return { candidates: [...map.values()], searchFailed };
}
async function getReadme(owner, repo) {
@@ -192,7 +236,11 @@ async function genReview(repoName, desc, stars, license, readme) {
.replace("{readmeSummary}", readme.substring(0, 1500));
const { choices } = await withRetry(async () => {
return await openai.chat.completions.create({
model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.3,
model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro",
messages: [{ role: "user", content: prompt }],
temperature: 0.3,
// DeepSeek V4 的 reasoning token 计入 max_tokens,预算不足会返回空 content(历史踩坑)
max_tokens: 16384,
}, { signal: AbortSignal.timeout(120000) });
}, { maxRetries: 3, label: "DeepSeek Review API" });
const content = choices[0]?.message?.content;
@@ -218,64 +266,66 @@ async function main() {
const DAILY_LIMIT = 30;
let discovered = 0, skipped = 0, reviewed = 0, failed = 0;
for (const query of SEARCH_QUERIES) {
const dayIndex = Math.floor(Date.now() / 86400000);
const startIdx = (dayIndex * QUERIES_PER_RUN) % SEARCH_QUERIES.length;
const todaysQueries = Array.from({ length: QUERIES_PER_RUN }, (_, i) => SEARCH_QUERIES[(startIdx + i) % SEARCH_QUERIES.length]);
console.log(`🔍 本轮查询 (${todaysQueries.length}/${SEARCH_QUERIES.length},按天轮转): ${todaysQueries.join(" / ")}`);
const { candidates, searchFailed } = await collectCandidates(todaysQueries);
failed += searchFailed;
console.log(` 候选仓库 ${candidates.length} 个(已去重)\n`);
for (const repo of candidates) {
if (discovered >= DAILY_LIMIT) break;
console.log(`🔍 "${query}"`);
let repos;
try { repos = await searchRepos(query, 1000, 5); } catch (e) { console.error(` ❌ 搜索失败: ${e.message}`); failed++; continue; }
const ex = await prisma.skill.findFirst({ where: { sourceUrl: repo.html_url } });
if (ex) { skipped++; continue; }
const exP = await prisma.pendingSkill.findFirst({ where: { sourceUrl: repo.html_url } });
if (exP) { skipped++; continue; }
const slug = genSlug(repo.full_name);
const slugEx = await prisma.skill.findUnique({ where: { slug } });
if (slugEx) { skipped++; continue; }
if (!isValidAIProject(repo)) { skipped++; continue; }
for (const repo of repos) {
if (discovered >= DAILY_LIMIT) break;
const ex = await prisma.skill.findFirst({ where: { sourceUrl: repo.html_url } });
if (ex) { console.log(` ⏭️ ${repo.full_name} 已存在`); skipped++; continue; }
const exP = await prisma.pendingSkill.findFirst({ where: { sourceUrl: repo.html_url } });
if (exP) { console.log(` ⏭️ ${repo.full_name} 待审核中`); skipped++; continue; }
const slug = genSlug(repo.full_name);
const slugEx = await prisma.skill.findUnique({ where: { slug } });
if (slugEx) { console.log(` ⏭️ ${repo.full_name} slug冲突`); skipped++; continue; }
if (!isValidAIProject(repo)) { skipped++; continue; }
console.log(`\n📦 [${discovered + 1}/${DAILY_LIMIT}] ${repo.full_name} (⭐${repo.stargazers_count})`);
try {
const [owner, name] = repo.full_name.split("/");
const readme = await getReadme(owner, name);
let reviewData = null;
try { reviewData = await genReview(repo.full_name, repo.description || "", repo.stargazers_count, repo.license?.spdx_id || "Unknown", readme); console.log(` ✅ 预评测: ${reviewData.overall}`); reviewed++; }
catch (e) { console.log(` ⚠️ 预评测失败: ${e.message}`); }
console.log(`\n📦 [${discovered + 1}/${DAILY_LIMIT}] ${repo.full_name} (⭐${repo.stargazers_count})`);
try {
const [owner, name] = repo.full_name.split("/");
const readme = await getReadme(owner, name);
let reviewData = null;
try { reviewData = await genReview(repo.full_name, repo.description || "", repo.stargazers_count, repo.license?.spdx_id || "Unknown", readme); console.log(` ✅ 预评测: ${reviewData.overall}`); reviewed++; }
catch (e) { console.log(` ⚠️ 预评测失败: ${e.message}`); }
const categoryId = await guessCategory(repo.full_name, repo.description || "", reviewData?.tags || []);
const pendingSkill = await prisma.pendingSkill.create({
data: {
name: repo.full_name.substring(0, 100), slug, categoryId,
description: repo.description || "", sourceUrl: repo.html_url, sourceType: "github",
rating: reviewData?.overall || 0,
features: {
forks: repo.forks_count,
language: repo.language,
license: repo.license?.spdx_id || null,
topics: repo.topics || [],
stars: repo.stargazers_count,
reviewData: reviewData || null,
},
tags: reviewData?.tags || [],
status: "pending",
const categoryId = await guessCategory(repo.full_name, repo.description || "", reviewData?.tags || []);
const pendingSkill = await prisma.pendingSkill.create({
data: {
name: repo.full_name.substring(0, 100), slug, categoryId,
description: repo.description || "", sourceUrl: repo.html_url, sourceType: "github",
rating: reviewData?.overall || 0,
features: {
forks: repo.forks_count,
language: repo.language,
license: repo.license?.spdx_id || null,
topics: repo.topics || [],
stars: repo.stargazers_count,
reviewData: reviewData || null,
},
});
console.log(` ✅ PendingSkill #${pendingSkill.id}`);
tags: reviewData?.tags || [],
status: "pending",
},
});
console.log(` ✅ PendingSkill #${pendingSkill.id}`);
await prisma.reviewGenerationLog.create({
data: { skillId: 0, status: "SUCCESS", prompt: `task4-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null },
});
discovered++;
} catch (e) {
console.error(` ❌ 失败: ${e.message}`);
failed++;
try {
await prisma.reviewGenerationLog.create({
data: { skillId: 0, status: "SUCCESS", prompt: `task4-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null },
data: { skillId: 0, status: "FAILED", prompt: `task4-discover: ${repo.full_name}`, error: e.message?.substring(0, 500) || "Unknown" },
});
discovered++;
} catch (e) {
console.error(` ❌ 失败: ${e.message}`);
failed++;
try {
await prisma.reviewGenerationLog.create({
data: { skillId: 0, status: "FAILED", prompt: `task4-discover: ${repo.full_name}`, error: e.message?.substring(0, 500) || "Unknown" },
});
} catch {}
}
} catch {}
}
}
+27 -5
View File
@@ -15,7 +15,7 @@ const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
timeout: 120000,
maxRetries: 2,
});
@@ -290,7 +290,7 @@ async function generateDailyNews() {
}
const newsDataText = newsResults
.slice(0, 30)
.slice(0, 14)
.map((r, i) => `[${i + 1}] 标题: ${r.title}\n 内容: ${r.content || "(无摘要)"}\n 来源: ${r.url}\n 时间: ${r.pubDate || "未知"}`)
.join("\n\n");
@@ -304,12 +304,18 @@ async function generateDailyNews() {
model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro",
messages: [{ role: "user", content: prompt }],
temperature: 0.3,
}, { signal: AbortSignal.timeout(120000) });
// DeepSeek V4 reasoning token 计入 max_tokens 预算:预算不足会导致 content 为空(finish_reason=length)
max_tokens: 16384,
}, { signal: AbortSignal.timeout(180000) });
}, { maxRetries: 3, label: "DeepSeek News API" });
const u = completion.usage;
if (u) {
console.log(` ℹ️ tokens: prompt=${u.prompt_tokens} completion=${u.completion_tokens} reasoning=${u.completion_tokens_details?.reasoning_tokens ?? "n/a"} finish=${completion.choices[0]?.finish_reason}`);
}
const content = completion.choices[0]?.message?.content;
if (!content) {
throw new Error("DeepSeek 返回内容为空");
throw new Error(`DeepSeek 返回内容为空(finish_reason=${completion.choices[0]?.finish_reason},completion_tokens=${u?.completion_tokens},疑似 reasoning 占满 max_tokens)`);
}
let parsed;
@@ -367,13 +373,29 @@ async function generateDailyNews() {
return report;
}
async function generateWithRetry() {
for (let attempt = 0; attempt < 2; attempt++) {
try {
return await generateDailyNews();
} catch (e) {
const msg = String(e.message || e);
// 偶发中止/超时/上游 5xx 才重试;业务类错误直接抛出
const retryable = /abort|timeout|ETIMEDOUT|ECONNRESET|socket|fetch failed|502|503|429/i.test(msg);
if (!retryable || attempt === 1) throw e;
console.warn(`⚠️ 日报生成中止(${msg.slice(0, 80)}),30s 后重试 (${attempt + 1}/2)...`);
await new Promise((r) => setTimeout(r, 30000));
}
}
throw new Error("unreachable");
}
async function main() {
if (!process.env.DEEPSEEK_API_KEY) throw new Error("缺少环境变量: DEEPSEEK_API_KEY");
if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL");
const startTime = new Date();
try {
const report = await generateDailyNews();
const report = await generateWithRetry();
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
await prisma.taskLog.create({
-27
View File
@@ -1,27 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
idx_q = html.find('全部板块')
idx_z = html.find('最新话题')
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
# 找 dangerous 内容
print(f'板块区域长度: {len(section)}')
print(f'\n板块区域前500字符:')
print(section[:500])
print(f'\n...')
print(f'\n板块区域后200字符:')
print(section[-200:])
# 找<a 出现
real_a = re.findall(r'<a\s', section)
print(f'\n真实<a>数: {len(real_a)}')
# 找href="/community/
href_a = re.findall(r'href="/community/[^"]+"', section)
print(f'href="/community/... 出现数: {len(href_a)}')
# 找RSC $ a
rsc_a = re.findall(r'\\"a\\"', section)
print(f'RSC \\"a\\" 组件数: {len(rsc_a)}')
-64
View File
@@ -1,64 +0,0 @@
import OpenAI from "openai";
import "dotenv/config";
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
});
async function main() {
const prompt = `你是一个GitHub开源项目评测专家。请基于以下项目信息进行五维深度评测,并以JSON格式返回。务必只返回JSON,不要包含任何其他文字或Markdown格式。
项目信息:
- 仓库全名:TabbyML/tabby
- 简介:Self-hosted AI coding assistant
- GitHub Stars:33538
- 开源许可证:Apache-2.0
- README摘要:An open-source, self-hosted AI coding assistant.
请严格按照以下JSON结构输出:
{
"capability": { "score": 4.5, "summary": "一句话亮点", "detail": "80-120字分析" },
"devExp": { "score": 3.5, "summary": "体验总结", "detail": "80-120字" },
"costLicense": { "score": 4.0, "summary": "成本总结", "detail": "80-120字" },
"community": { "score": 4.5, "summary": "社区总结", "detail": "80-120字" },
"performance": { "score": 3.5, "summary": "性能总结", "detail": "80-120字" },
"overall": 4.0,
"tags": ["标签1", "标签2", "标签3"]
}`;
const { choices } = await openai.chat.completions.create({
model: "deepseek-v4-pro",
messages: [{ role: "user", content: prompt }],
temperature: 0.3,
});
const content = choices[0]?.message?.content;
console.log("=== RAW RESPONSE ===");
console.log(content);
console.log("\n=== LENGTH ===", content?.length);
// Test extractJSON
const trimmed = content.trim();
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
let jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : trimmed;
console.log("\n=== AFTER CODE BLOCK REMOVAL ===");
console.log("Has code block:", !!codeBlockMatch);
console.log("First 200 chars:", jsonStr.substring(0, 200));
const braceMatch = jsonStr.match(/\{[\s\S]*\}/);
if (braceMatch) {
console.log("\n=== BRACE MATCH ===");
console.log("First 200 chars:", braceMatch[0].substring(0, 200));
try {
const parsed = JSON.parse(braceMatch[0]);
console.log("\n=== PARSED KEYS ===", Object.keys(parsed));
console.log("Has capability:", !!parsed.capability);
console.log("Has devExp:", !!parsed.devExp);
} catch (e) {
console.log("Parse error:", e.message);
}
}
}
main().catch(console.error);
-33
View File
@@ -1,33 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 板块区域附近
idx_q = html.find('全部板块')
idx_z = html.find('最新话题')
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
# 找"<a " 出现位置(不是RSC payload里的"a"组件,是真实HTML)
real_a = re.findall(r'<a\s', section)
real_a_in_quotes = re.findall(r'\\"a\\",', section)
print(f'真实<a>标签: {len(real_a)}')
print(f'RSC \\"a\\" 组件: {len(real_a_in_quotes)}')
# 看section里"$" RSC payload 数量
dollar_count = section.count('"$"')
print(f'RSC payload "$" 数量: {dollar_count}')
# 找第一个 RSC "a" 组件
m = re.search(r'\["\$","a",[^]]+\]', section)
if m:
print(f'\n第一个RSC a组件示例:\n{m.group()[:500]}')
# 看板块区域最后部分(应该是RSC结束+可能HTML)
print(f'\n板块区域最后200字符:')
print(section[-300:])
# 板块区域有多少个 [$,"a" 出现
rsc_a = re.findall(r'\["\$","a"', section)
print(f'\nRSC "a" 组件数: {len(rsc_a)}')
-29
View File
@@ -1,29 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
idx_q = html.find('全部板块')
idx_z = html.find('最新话题')
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
# 找"电商零售"在section中的位置
kw_pos = section.find('电商零售')
print(f'电商零售在section位置: {kw_pos}')
if kw_pos >= 0:
print(f'周围300字符: {section[max(0,kw_pos-100):kw_pos+200]}')
# 找全部的板块数据标识 - slug 出现的位置
slugs = ['ec-platform', 'ec-livestream', 'ec-supply', 'ec-dtc', 'ec-private', 'ec-group', 'ai-tools-app', 'ai-llm', 'ai-startup-forum', 'ai-saas', 'ai-hardware']
print(f'\n=== 各slug在section中是否出现 ===')
for s in slugs:
p = section.find(f'"{s}"')
p2 = section.find(f'/{s}')
print(f' {s:25s} "slug"-模式:{p} /slug-模式:{p2}')
# 找父板块的name - "电商零售" 看看上下文
print(f'\n=== 在section里查"电商零售"上下文 ===')
if '电商零售' in section:
idx = section.find('电商零售')
print(section[max(0,idx-50):idx+300])
-21
View File
@@ -1,21 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL;
const sep = base.includes("?") ? "&" : "?";
const adapter = new PrismaMariaDb(base + sep + "connection_limit=20");
const prisma = new PrismaClient({ adapter });
async function main() {
const skills = await prisma.skill.findMany({ where: { status: "draft" }, select: { id: true, name: true } });
for (const s of skills) {
await prisma.skillReview.deleteMany({ where: { skillId: s.id } });
await prisma.reviewGenerationLog.deleteMany({ where: { skillId: s.id } });
await prisma.skill.delete({ where: { id: s.id } });
console.log("Deleted:", s.name);
}
console.log("Done:", skills.length, "deleted");
}
main().catch(console.error).finally(() => prisma.$disconnect());
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# 追光AI 增量部署:重建镜像(仅 2 个首页文件变更)
set -e
cd /home/ubuntu/zhuiguang-ai
export DBURL=$(grep -E '^DATABASE_URL=' env/.env.production | tail -1 | cut -d= -f2- | tr -d '"')
echo "BUILD_DBURL=${DBURL:0:30}..."
docker build -t zhuiguang-ai:2.1.3 --build-arg DATABASE_URL="$DBURL" . > build.log 2>&1
echo "BUILD_EXIT=$?"
tail -8 build.log
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# 追光AI 部署:用新镜像重建 CRON 容器(supercronic 调度,保留全部数据卷)
set -e
cd /home/ubuntu/zhuiguang-ai
echo "=== 停止旧 cron 容器 ==="
docker stop zhuiguang-ai-cron || true
docker rm zhuiguang-ai-cron || true
echo "=== 启动新 cron 容器 ==="
docker run -d \
--name zhuiguang-ai-cron \
--restart unless-stopped \
--network host \
-e TZ=Asia/Shanghai \
-e NODE_ENV=production \
-v zhuiguang-ai_bot-public:/app/public \
-v zhuiguang-ai_bot-data:/app/data \
-v zhuiguang-ai_bot-logs:/app/logs \
-v zhuiguang-ai_bot-prisma:/app/node_modules/.prisma \
-v /home/ubuntu/zhuiguang-ai/env/.env.production:/app/.env:ro \
-v /home/ubuntu/zhuiguang-ai/crontab.txt:/app/crontab.txt:ro \
--log-opt max-size=20m --log-opt max-file=3 \
--health-cmd "pgrep -f supercronic || exit 1" \
--health-interval 60s --health-timeout 3s --health-retries 3 --health-start-period 10s \
zhuiguang-ai:2.1.3 \
/usr/local/bin/entrypoint-cron.sh
echo "CRON_CONTAINER_STARTED"
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# 追光AI 部署:用新镜像重建 APP 容器(保留全部数据卷)
set -e
cd /home/ubuntu/zhuiguang-ai
echo "=== 停止旧容器 ==="
docker stop zhuiguang-ai-app || true
docker rm zhuiguang-ai-app || true
echo "=== 启动新容器 ==="
docker run -d \
--name zhuiguang-ai-app \
--restart unless-stopped \
--network host \
-e TZ=Asia/Shanghai \
-e NODE_ENV=production \
-e PORT=8301 \
-e HOSTNAME=0.0.0.0 \
-v zhuiguang-ai_bot-public:/app/public \
-v zhuiguang-ai_bot-data:/app/data \
-v zhuiguang-ai_bot-logs:/app/logs \
-v zhuiguang-ai_bot-prisma:/app/node_modules/.prisma \
-v /home/ubuntu/zhuiguang-ai/env/.env.production:/app/.env:ro \
--log-opt max-size=50m --log-opt max-file=5 \
--health-cmd "curl -f http://localhost:8301/api/health || exit 1" \
--health-interval 30s --health-timeout 10s --health-retries 3 --health-start-period 40s \
zhuiguang-ai:2.1.3
echo "CONTAINER_STARTED"
+247
View File
@@ -0,0 +1,247 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const rawUrl = process.env.DATABASE_URL;
if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); }
const base = rawUrl.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=10&pool_timeout=15`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const USER_AGENT = "Mozilla/5.0 (compatible; EnrichBot/1.0; +https://www.zhuig.com)";
const FETCH_TIMEOUT_MS = 10000;
const RATE_LIMIT_MS = 2000;
async function delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function resolveUrl(href, base) {
try {
return new URL(href, base).href;
} catch {
return null;
}
}
async function safeFetch(url) {
try {
return await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
redirect: "follow",
headers: { "User-Agent": USER_AGENT },
});
} catch {
return null;
}
}
async function fetchOgData(websiteUrl) {
const result = { logo: null, description: null };
let url;
try {
url = new URL(websiteUrl);
if (!["http:", "https:"].includes(url.protocol)) return result;
} catch {
return result;
}
const res = await safeFetch(url.href);
if (!res || !res.ok) return result;
let html;
try {
html = await res.text();
} catch {
return result;
}
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i);
if (ogImageMatch) {
result.logo = resolveUrl(ogImageMatch[1], url.href);
}
const ogDescMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i);
if (ogDescMatch) {
const desc = ogDescMatch[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#x27;/g, "'");
if (desc.trim().length >= 10) {
result.description = desc.trim();
}
}
if (!result.logo) {
const faviconMatch = html.match(/<link\s+[^>]*rel=["'](?:shortcut\s+)?icon["'][^>]*href=["']([^"']+)["']/i)
|| html.match(/<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut\s+)?icon["']/i);
if (faviconMatch) {
const favicon = resolveUrl(faviconMatch[1], url.href);
if (favicon) {
result.logo = favicon;
}
}
}
if (!result.logo) {
const domain = url.hostname;
const faviconGuesses = [`${url.protocol}//${domain}/favicon.ico`, `${url.protocol}//${domain}/apple-touch-icon.png`];
for (const guess of faviconGuesses) {
const headRes = await safeFetch(guess);
if (headRes && headRes.ok) {
const ct = headRes.headers.get("content-type") || "";
if (ct.startsWith("image/") || ct.includes("icon") || guess.endsWith(".ico")) {
result.logo = guess;
break;
}
}
}
}
return result;
}
async function tryClearbitLogo(domain) {
try {
const url = `https://logo.clearbit.com/${encodeURIComponent(domain)}`;
const res = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(8000),
headers: { "User-Agent": USER_AGENT },
});
if (res.ok) {
const ct = res.headers.get("content-type") || "";
if (ct.startsWith("image/")) return url;
}
} catch {
// Clearbit unavailable, expected
}
return null;
}
async function enrichTool(tool) {
let domain;
try {
domain = new URL(tool.websiteUrl).hostname;
} catch {
return null;
}
const needsLogo = !tool.logoUrl;
const needsDesc = !tool.description || tool.description.length < 100;
if (!needsLogo && !needsDesc) return null;
const updates = {};
const findings = [];
const og = await fetchOgData(tool.websiteUrl);
if (needsLogo) {
if (og.logo) {
updates.logoUrl = og.logo;
findings.push("logo");
} else {
const clearbit = await tryClearbitLogo(domain);
if (clearbit) {
updates.logoUrl = clearbit;
findings.push("logo(clearbit)");
}
}
}
if (needsDesc && og.description && og.description.length > (tool.description?.length || 0)) {
updates.description = og.description.slice(0, 500);
findings.push("desc");
}
if (Object.keys(updates).length > 0) {
await prisma.tool.update({ where: { id: tool.id }, data: updates });
}
return findings;
}
async function main() {
const startTime = new Date();
const tools = await prisma.tool.findMany({
where: { status: "published", websiteUrl: { not: "" } },
select: {
id: true,
name: true,
slug: true,
websiteUrl: true,
logoUrl: true,
description: true,
},
});
const needingEnrichment = tools.filter((t) => {
const needsLogo = !t.logoUrl;
const needsDesc = !t.description || t.description.length < 100;
return needsLogo || needsDesc;
});
console.log(`Found ${tools.length} published tools, ${needingEnrichment.length} need enrichment\n`);
let enriched = 0;
let logoFound = 0;
let descFound = 0;
let failed = 0;
const errors = [];
for (let i = 0; i < needingEnrichment.length; i++) {
const tool = needingEnrichment[i];
const idx = i + 1;
const total = needingEnrichment.length;
process.stdout.write(`[${idx}/${total}] ${tool.name}: `);
try {
const findings = await enrichTool(tool);
if (findings && findings.length > 0) {
enriched++;
if (findings.includes("logo") || findings.includes("logo(clearbit)")) logoFound++;
if (findings.includes("desc")) descFound++;
process.stdout.write(`${findings.join(", ")} ✓\n`);
} else {
process.stdout.write(`nothing to enrich\n`);
}
} catch (err) {
failed++;
errors.push(`${tool.name}: ${err.message}`);
process.stdout.write(`error: ${err.message}\n`);
}
if (idx < total) {
await delay(RATE_LIMIT_MS);
}
}
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
console.log(`\nDone.`);
console.log(` Tools processed: ${needingEnrichment.length}`);
console.log(` Enriched: ${enriched} (logo: ${logoFound}, description: ${descFound})`);
console.log(` Failed: ${failed}`);
console.log(` Duration: ${(duration / 1000).toFixed(1)}s`);
if (errors.length > 0) {
console.log(`\nErrors:`);
errors.forEach((e) => console.log(` - ${e}`));
}
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error("Fatal:", err.message);
if (prisma) {
try { await prisma.$disconnect(); } catch {}
}
process.exit(1);
});
+12 -1
View File
@@ -26,12 +26,23 @@ set -a
. /app/.env
set +a
# 移除 NODE_TLS_REJECT_UNAUTHORIZED 以消除警告(在 prisma 迁移之前)
unset NODE_TLS_REJECT_UNAUTHORIZED
# 2) 跑迁移(幂等;不阻塞启动)
echo "[entrypoint-app] Running prisma migrate deploy..."
npx prisma migrate deploy --schema=prisma/schema.prisma 2>&1 | tail -20 || {
echo "[entrypoint-app] WARN: migrate failed, continuing (DB schema may be ahead of migrations)"
}
# 3) 启动 Next.js
# 3) 重新生成 Prisma client
# 防止持久卷(zhuiguang-ai_bot-prisma)中的旧 client 与 schema 不一致
# 导致运行时 "Unknown field xxx for select statement" 错误
echo "[entrypoint-app] Regenerating Prisma client..."
npx prisma generate --schema=prisma/schema.prisma 2>&1 | tail -5 || {
echo "[entrypoint-app] WARN: prisma generate failed, continuing with existing client"
}
# 4) 启动 Next.js
echo "[entrypoint-app] Starting next start on port ${PORT:-8301}..."
exec node_modules/.bin/next start --port ${PORT:-8301} --hostname 0.0.0.0
+131 -8
View File
@@ -3,36 +3,159 @@
# 追光AI CRON 容器启动入口
# - 用 supercronic 调度 /app/crontab.txt 里的任务
# - 跟 host cron 行为一致,但完全在容器内
# - 增强:信号处理、错误处理、日志轮转集成
# =============================================================================
set -e
cd /app
# =============================================================================
# 信号处理 - 优雅关闭
# =============================================================================
SUPERCRONIC_PID=""
cleanup() {
echo "[entrypoint-cron] Received shutdown signal, stopping gracefully..."
if [ -n "$SUPERCRONIC_PID" ]; then
kill -TERM "$SUPERCRONIC_PID" 2>/dev/null || true
# 等待进程结束,最多 30 秒
for i in $(seq 1 30); do
if ! kill -0 "$SUPERCRONIC_PID" 2>/dev/null; then
echo "[entrypoint-cron] Process exited cleanly"
break
fi
sleep 1
done
# 如果还没退出,强制终止
if kill -0 "$SUPERCRONIC_PID" 2>/dev/null; then
echo "[entrypoint-cron] Force killing process..."
kill -9 "$SUPERCRONIC_PID" 2>/dev/null || true
fi
fi
exit 0
}
trap cleanup TERM INT QUIT
# =============================================================================
# 日志轮转配置(通过环境变量控制)
# =============================================================================
# LOG_MAX_SIZE: 单个日志文件最大大小(默认 100M)
# LOG_MAX_FILES: 保留的历史日志文件数(默认 7)
# LOG_COMPRESS: 是否压缩历史日志(默认 true)
LOG_MAX_SIZE="${LOG_MAX_SIZE:-100M}"
LOG_MAX_FILES="${LOG_MAX_FILES:-7}"
LOG_COMPRESS="${LOG_COMPRESS:-true}"
# 如果启用了日志文件输出,配置 logrotate
if [ -n "$LOG_FILE" ]; then
echo "[entrypoint-cron] Log file configured: $LOG_FILE"
echo "[entrypoint-cron] Log rotation: max_size=$LOG_MAX_SIZE, max_files=$LOG_MAX_FILES, compress=$LOG_COMPRESS"
# 创建 logrotate 配置
cat > /etc/logrotate.d/zhuiguang-cron <<EOF
$LOG_FILE {
size $LOG_MAX_SIZE
rotate $LOG_MAX_FILES
compress
delaycompress
missingok
notifempty
create 0644 root root
postrotate
# 通知 supercronic 重新打开日志文件(如果支持)
true
endscript
}
EOF
fi
# =============================================================================
# Seed data volume if empty
# =============================================================================
if [ ! -f /app/data/bot-characters.json ] && [ -d /app/data-seed ]; then
echo "[entrypoint] Seeding data volume..."
cp -r /app/data-seed/* /app/data/
fi
# 1) .env 校验
# =============================================================================
# .env 校验
# =============================================================================
if [ ! -f /app/.env ]; then
echo "[entrypoint-cron] FATAL: /app/.env not found. Mount .env file into container."
exit 1
fi
# 2) crontab 文件校验
# =============================================================================
# crontab 文件校验
# =============================================================================
if [ ! -f /app/crontab.txt ]; then
echo "[entrypoint-cron] FATAL: /app/crontab.txt not found. Mount crontab into container."
exit 1
fi
# 3) supercronic 启动
# =============================================================================
# 健康检查端点(可选)
# =============================================================================
if [ "$HEALTH_CHECK_ENABLED" = "true" ]; then
HEALTH_PORT="${HEALTH_CHECK_PORT:-8311}"
echo "[entrypoint-cron] Health check enabled on port $HEALTH_PORT"
# 启动简单的健康检查 HTTP 服务器
(
while true; do
echo -e "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK" | nc -l -p "$HEALTH_PORT" -q 1 2>/dev/null || true
done
) &
HEALTH_PID=$!
echo "[entrypoint-cron] Health check server started (PID: $HEALTH_PID)"
fi
# =============================================================================
# supercronic 启动
# =============================================================================
echo "[entrypoint-cron] Starting supercronic..."
echo "[entrypoint-cron] crontab:"
sed 's/^/ /' /app/crontab.txt
# -prometheus-listen-address 可选 (开监控)
# -split-logs 把 stdout/stderr 拆开
exec /usr/local/bin/supercronic-linux-amd64 \
-prometheus-listen-address 0.0.0.0:8310 \
/app/crontab.txt
# 构建启动参数
SUPERCRONIC_ARGS=""
# Prometheus 监控(可选)
if [ "$PROMETHEUS_ENABLED" != "false" ]; then
PROMETHEUS_PORT="${PROMETHEUS_PORT:-8310}"
SUPERCRONIC_ARGS="$SUPERCRONIC_ARGS -prometheus-listen-address 0.0.0.0:$PROMETHEUS_PORT"
echo "[entrypoint-cron] Prometheus metrics enabled on port $PROMETHEUS_PORT"
fi
# 日志分离(可选)
if [ "$SPLIT_LOGS" = "true" ]; then
SUPERCRONIC_ARGS="$SUPERCRONIC_ARGS -split-logs"
echo "[entrypoint-cron] Split logs enabled (stdout/stderr separated)"
fi
# 调试模式
if [ "$DEBUG" = "true" ]; then
SUPERCRONIC_ARGS="$SUPERCRONIC_ARGS -debug"
echo "[entrypoint-cron] Debug mode enabled"
fi
# 启动 supercronic(后台运行以捕获 PID)
/usr/local/bin/supercronic-linux-amd64 $SUPERCRONIC_ARGS /app/crontab.txt &
SUPERCRONIC_PID=$!
echo "[entrypoint-cron] Supercronic started with PID: $SUPERCRONIC_PID"
echo "[entrypoint-cron] Container ready"
# 等待 supercronic 进程(同时保持信号处理能力)
wait "$SUPERCRONIC_PID"
EXIT_CODE=$?
echo "[entrypoint-cron] Supercronic exited with code: $EXIT_CODE"
# 清理健康检查服务器
if [ -n "$HEALTH_PID" ]; then
kill "$HEALTH_PID" 2>/dev/null || true
fi
exit $EXIT_CODE
-36
View File
@@ -1,36 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
print(f'HTML total: {len(html)} bytes')
a_tags = len(re.findall(r'<a\s', html))
print(f'真实 <a> 标签: {a_tags}')
h2 = len(re.findall(r'<h2', html))
h3 = len(re.findall(r'<h3', html))
print(f'<h2>: {h2} <h3>: {h3}')
# 找字面平台电商
m = re.search(r'<h3[^>]*>[^<]*平台电商[^<]*</h3>', html)
print(f'字面<平台电商> HTML: {bool(m)}')
# 板块区域
idx_q = html.find('全部板块')
idx_z = html.find('最新话题')
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
print(f'板块区域长度: {len(section)}')
a_pat = r'<a\s'
h3_pat = r'<h3'
a_count = len(re.findall(a_pat, section))
h3_count = len(re.findall(h3_pat, section))
print(f'板块区域 <a 数量: {a_count}')
print(f'板块区域 <h3> 数量: {h3_count}')
# 找板块标题h2
m2 = re.search(r'<h2[^>]*>全部板块</h2>', section)
print(f'板块标题 h2 全部板块 存在: {bool(m2)}')
# 看section的前500字符
print(f'\n板块区域前800字符:')
print(section[:800])
-33
View File
@@ -1,33 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 找所有 $N 引用
refs = re.findall(r'\$(\d+)', html)
unique_refs = sorted(set(int(r) for r in refs))
print(f'RSC引用编号: {unique_refs}')
# 找RSC块边界 (常见的分隔符是 \x00\x00\x00...)
# 实际上Next.js RSC使用特殊的script标签
script_blocks = re.findall(r'<script[^>]*>(self\.__next_f\.push.*?)</script>', html, re.DOTALL)
print(f'\n<self.__next_f.push> script 块: {len(script_blocks)}')
# 找 $12 在哪里
idx_12 = html.find('$12')
if idx_12 >= 0:
print(f'\n$12 在 HTML 位置: {idx_12}')
print(f'周围200字符: {html[max(0,idx_12-50):idx_12+200]}')
# 找包含"电商零售"的位置
idx_dianshang = html.find('电商零售')
print(f'\n"电商零售" 在 HTML 位置: {idx_dianshang}')
if idx_dianshang >= 0:
print(f'周围200字符: {html[max(0,idx_dianshang-30):idx_dianshang+200]}')
# 找包含"平台电商"子板块
idx_pingtai = html.find('平台电商')
print(f'\n"平台电商" 在 HTML 位置: {idx_pingtai}')
if idx_pingtai >= 0:
print(f'周围200字符: {html[max(0,idx_pingtai-100):idx_pingtai+200]}')
-25
View File
@@ -1,25 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 找"全部板块"区域
idx_q = html.find('全部板块')
idx_z = html.find('最新话题')
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
print(f'板块区域HTML长度: {len(section)}')
# 找section里第一个出现的板块数据关键词
keywords = ['电商零售', 'AI科技', '金融投资', '跨境出海', '实体经营', '本地生活', '大健康', '教育培训', 'FIRE与生活', '自媒体内容', '餐饮食品', '房地产']
print('\n=== 关键词在板块区域出现位置 ===')
for kw in keywords:
p = section.find(kw)
if p >= 0:
print(f' "{kw}": 位置{section.find(kw)}, 周围HTML: ...{section[max(0,p-30):p+80]}...')
# 找"全部板块"区域前后的HTML标签结构
print(f'\n=== "全部板块"之前的300字符 ===')
print(html[idx_q-300:idx_q])
print(f'\n=== "最新话题"之前的500字符(应该是板块区域结束) ===')
print(html[idx_z-500:idx_z])
-36
View File
@@ -1,36 +0,0 @@
const fs = require('fs');
const path = require('path');
const scriptsDir = '/home/ubuntu/zhuiguang-ai/scripts';
const files = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.mjs'));
const oldLine = 'const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";';
const newLine = 'const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");';
let fixed = 0;
let skipped = 0;
for (const file of files) {
const filePath = path.join(scriptsDir, file);
let content = fs.readFileSync(filePath, 'utf8');
if (content.includes('PrismaMariaDb') && content.includes(oldLine)) {
content = content.replace(oldLine, newLine);
fs.writeFileSync(filePath, content, 'utf8');
console.log('Fixed: ' + file);
fixed++;
} else if (content.includes('PrismaMariaDb')) {
console.log('Skipped (already fixed or different pattern): ' + file);
skipped++;
}
}
console.log('\nTotal fixed: ' + fixed + ', skipped: ' + skipped);
console.log('\n=== Verification ===');
// Show the fixed lines
const verifyFiles = ['task5-update-stars.mjs', 'task1-discover-tools.mjs', 'daily-news.mjs', 'task7-news-to-community.mjs'];
for (const f of verifyFiles) {
const content = fs.readFileSync(path.join(scriptsDir, f), 'utf8');
const match = content.match(/const base = .*/);
if (match) console.log(f + ': ' + match[0]);
}
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
cd /home/ubuntu/zhuiguang-ai/scripts
for f in *.mjs; do
if grep -q 'PrismaMariaDb' "$f" 2>/dev/null; then
OLD="const base = process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\";"
NEW="const base = (process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\").replace('mysql://', 'mariadb://');"
if grep -qF "$OLD" "$f" 2>/dev/null; then
sed -i "s|const base = process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\";|const base = (process.env.DATABASE_URL || \"mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4\").replace('mysql://', 'mariadb://');|g" "$f"
echo "Fixed: $f"
else
echo "Skipped (pattern not matched): $f"
fi
fi
done
echo "=== Verification ==="
grep -n 'const base' task5-update-stars.mjs task1-discover-tools.mjs task3-check-tools.mjs task6-review-hot.mjs task7-news-to-community.mjs daily-discover.mjs daily-news.mjs 2>/dev/null
+1 -1
View File
@@ -64,7 +64,7 @@ async function main() {
console.log("\n建议后续操作:");
console.log(" 1. 重新部署 task3-check-tools.mjs 到服务器 scripts/ 目录");
console.log(" 2. 重启定时任务: pm2 restart all 或重启 cron-wrapper.sh");
console.log(" 2. 重启 CRON 容器: docker compose restart cron");
console.log(" 3. 验证: 等待下次 task3 执行确认无 FK 错误");
}
-45
View File
@@ -1,45 +0,0 @@
const fs = require('fs');
const path = require('path');
const scriptsDir = '/home/ubuntu/zhuiguang-ai/scripts';
const files = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.mjs'));
// Patterns to remove:
// 1. `const name: Type = ` -> `const name = `
// 2. `let name: Type = ` -> `let name = `
// 3. `: Type` after function parameters (tricky - skip for now)
// 4. `: Type` after variable declarations
let totalFixed = 0;
for (const file of files) {
const filePath = path.join(scriptsDir, file);
let content = fs.readFileSync(filePath, 'utf8');
const original = content;
// Remove type annotations from const/let/var declarations
// Match: `const x: Type =` or `let x: Type =`
content = content.replace(/(const|let|var)\s+(\w+)\s*:\s*[^=]+?(\s*=\s*)/g, '$1 $2 $3');
// Remove type annotations from function return types like `): Type {`
content = content.replace(/\):\s*(Promise<[^>]+>|string|number|boolean|void|any|Record<[^>]+>|Map<[^>]+>|Set<[^>]+>)\s*\{/g, ') {');
if (content !== original) {
fs.writeFileSync(filePath, content, 'utf8');
console.log('Fixed: ' + file);
totalFixed++;
}
}
console.log('\nTotal files fixed: ' + totalFixed);
// Verify task1
const task1 = fs.readFileSync(path.join(scriptsDir, 'task1-discover-tools.mjs'), 'utf8');
const tsLines = task1.split('\n').filter((line, i) => {
const lineNum = i + 1;
return line.match(/(const|let|var)\s+\w+\s*:\s*[^=]/) || line.match(/:\s*(Record|Map|Set|Promise)<[^>]+>\s*[{(]/);
});
if (tsLines.length > 0) {
console.log('\n=== Remaining TS in task1 ===');
tsLines.forEach(l => console.log(l));
}
+482
View File
@@ -0,0 +1,482 @@
#!/usr/bin/env node
// scripts/generate-avatar-library.mjs
// 生成 500 个多样化 SVG 头像库
// 分类:男性 170 / 女性 170 / 中性 160
// 多种主题风格:商务、创意、科技、运动、自然、抽象、可爱、复古等
import { writeFileSync, mkdirSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const AVATAR_DIR = resolve(__dirname, "..", "public", "avatars");
const MANIFEST_PATH = resolve(AVATAR_DIR, "manifest.json");
if (!existsSync(AVATAR_DIR)) mkdirSync(AVATAR_DIR, { recursive: true });
// ============================================================
// 色彩体系 - 参考主流论坛的头像配色
// ============================================================
const COLOR_SETS = {
// 商务/专业系
business: [
["#1e3a5f", "#2d5f8a"], ["#2c3e50", "#34495e"], ["#1a365d", "#2b6cb0"],
["#1e3a5f", "#4338ca"], ["#1b2838", "#3182ce"], ["#1a202c", "#2d3748"],
["#13294b", "#1e4d8c"], ["#1e3a5f", "#7c3aed"],
],
// 创意/艺术系
creative: [
["#e11d48", "#be185d"], ["#7c3aed", "#db2777"], ["#dc2626", "#ea580c"],
["#6d28d9", "#c026d3"], ["#be123c", "#f43f5e"], ["#9333ea", "#ec4899"],
["#b91c1c", "#f97316"], ["#8b5cf6", "#d946ef"],
],
// 自然/清新系
nature: [
["#059669", "#10b981"], ["#047857", "#34d399"], ["#166534", "#22c55e"],
["#15803d", "#84cc16"], ["#065f46", "#14b8a6"], ["#0d9488", "#2dd4bf"],
["#047857", "#a3e635"], ["#0f766e", "#5eead4"],
],
// 科技/未来系
tech: [
["#2563eb", "#7c3aed"], ["#1d4ed8", "#06b6d4"], ["#3730a3", "#8b5cf6"],
["#1e40af", "#0ea5e9"], ["#312e81", "#6366f1"], ["#0369a1", "#22d3ee"],
["#1d4ed8", "#f0abfc"], ["#4338ca", "#38bdf8"],
],
// 温暖/活力系
warm: [
["#ea580c", "#f97316"], ["#dc2626", "#f59e0b"], ["#b91c1c", "#fb923c"],
["#c2410c", "#fbbf24"], ["#9a3412", "#f59e0b"], ["#d97706", "#fcd34d"],
["#e11d48", "#fb923c"], ["#b45309", "#fbbf24"],
],
// 柔和/可爱系
cute: [
["#db2777", "#f472b6"], ["#e11d48", "#fb7185"], ["#c026d3", "#e879f9"],
["#f43f5e", "#fda4af"], ["#a855f7", "#c4b5fd"], ["#ec4899", "#f9a8d4"],
["#be185d", "#fbbf24"], ["#d946ef", "#f0abfc"],
],
// 深色/质感系
dark: [
["#0f172a", "#334155"], ["#18181b", "#3f3f46"], ["#1c1917", "#44403c"],
["#171717", "#404040"], ["#0f172a", "#475569"], ["#1e1e2e", "#313244"],
],
// 复古系
retro: [
["#92400e", "#b45309"], ["#854d0e", "#a16207"], ["#78716c", "#57534e"],
["#78350f", "#92400e"], ["#713f12", "#a16207"], ["#9a3412", "#b45309"],
],
};
// ============================================================
// 图标路径 - 各种风格的简单图形
// ============================================================
// 面部/人物相关图标
const FACE_ICONS = {
// 抽象头像系列 - 不同发型/风格
face_round: (cx, cy, r, color) =>
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="0.9"/><circle cx="${cx-8}" cy="${cy-2}" r="3" fill="${color}"/><circle cx="${cx+8}" cy="${cy-2}" r="3" fill="${color}"/><path d="M${cx-8} ${cy+6} Q${cx} ${cy+16} ${cx+8} ${cy+6}" stroke="${color}" fill="none" stroke-width="2" stroke-linecap="round"/>`,
face_smile: (cx, cy, r, color) =>
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="0.9"/><circle cx="${cx-7}" cy="${cy-3}" r="2.5" fill="${color}"/><circle cx="${cx+7}" cy="${cy-3}" r="2.5" fill="${color}"/><path d="M${cx-8} ${cy+5} Q${cx} ${cy+16} ${cx+8} ${cy+5}" stroke="${color}" fill="none" stroke-width="2.5" stroke-linecap="round"/>`,
face_cool: (cx, cy, r, color) =>
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="0.9"/><rect x="${cx-12}" y="${cy-6}" width="8" height="5" rx="2" fill="${color}"/><rect x="${cx+4}" y="${cy-6}" width="8" height="5" rx="2" fill="${color}"/><path d="M${cx-5} ${cy+6} Q${cx} ${cy+14} ${cx+5} ${cy+6}" stroke="${color}" fill="none" stroke-width="2" stroke-linecap="round"/>`,
face_wink: (cx, cy, r, color) =>
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="0.9"/><circle cx="${cx-7}" cy="${cy-3}" r="2.5" fill="${color}"/><path d="M${cx+3} ${cy-5} L${cx+11} ${cy-1}" stroke="${color}" stroke-width="2.5" stroke-linecap="round"/><path d="M${cx-8} ${cy+5} Q${cx} ${cy+15} ${cx+8} ${cy+5}" stroke="${color}" fill="none" stroke-width="2.5" stroke-linecap="round"/>`,
};
// 职业/商务图标
const BUSINESS_ICONS = {
briefcase: (cx, cy, s, color) =>
`<rect x="${cx-s}" y="${cy-s*0.3}" width="${s*2}" height="${s*1.4}" rx="${s*0.2}" fill="white" opacity="0.85"/><rect x="${cx-s*0.3}" y="${cy-s*0.8}" width="${s*0.6}" height="${s*0.5}" rx="${s*0.1}" fill="white" opacity="0.85"/><rect x="${cx-s*0.5}" y="${cy-s*0.15}" width="${s}" height="${s*0.3}" rx="${s*0.1}" fill="${color}" opacity="0.6"/>`,
tie: (cx, cy, s, color) =>
`<polygon points="${cx},${cy-s} ${cx-s*0.4},${cy-s*0.2} ${cx-s*0.6},${cy+s}" fill="white" opacity="0.8"/><polygon points="${cx},${cy-s} ${cx+s*0.4},${cy-s*0.2} ${cx+s*0.6},${cy+s}" fill="white" opacity="0.8"/><polygon points="${cx},${cy-s} ${cx-s*0.65},${cy+s} ${cx+s*0.65},${cy+s}" fill="white" opacity="0.9"/>`,
chart: (cx, cy, s, color) =>
`<rect x="${cx-s*0.8}" y="${cy-s*0.2}" width="${s*1.6}" height="${s*1.4}" rx="${s*0.15}" fill="white" opacity="0.15"/><rect x="${cx-s*0.5}" y="${cy+s*0.1}" width="${s*0.3}" height="${s*0.8}" rx="${s*0.1}" fill="white" opacity="0.7"/><rect x="${cx-s*0.1}" y="${cy-s*0.3}" width="${s*0.3}" height="${s*1.2}" rx="${s*0.1}" fill="white" opacity="0.7"/><rect x="${cx+s*0.3}" y="${cy}" width="${s*0.3}" height="${s*0.5}" rx="${s*0.1}" fill="white" opacity="0.7"/>`,
};
// 科技/游戏图标
const TECH_ICONS = {
circuit: (cx, cy, s, color) =>
`<rect x="${cx-s*0.8}" y="${cy-s*0.8}" width="${s*1.6}" height="${s*1.6}" rx="${s*0.2}" fill="white" opacity="0.12"/><path d="M${cx-s*0.5} ${cy} L${cx-s*0.2} ${cy} L${cx-s*0.2} ${cy-s*0.4} L${cx+s*0.2} ${cy-s*0.4} L${cx+s*0.2} ${cy+s*0.4} L${cx+s*0.5} ${cy+s*0.4}" stroke="white" fill="none" stroke-width="3" opacity="0.6"/><circle cx="${cx-s*0.5}" cy="${cy}" r="${s*0.1}" fill="white" opacity="0.8"/><circle cx="${cx+s*0.5}" cy="${cy+s*0.4}" r="${s*0.1}" fill="white" opacity="0.8"/>`,
gamepad: (cx, cy, s, color) =>
`<rect x="${cx-s}" y="${cy-s*0.4}" width="${s*2}" height="${s*1.2}" rx="${s*0.3}" fill="white" opacity="0.85"/><rect x="${cx-s*0.4}" y="${cy-s*0.8}" width="${s*0.4}" height="${s*0.5}" rx="${s*0.1}" fill="white" opacity="0.85"/><rect x="${cx+s*0.05}" y="${cy-s*0.8}" width="${s*0.4}" height="${s*0.5}" rx="${s*0.1}" fill="white" opacity="0.85"/><circle cx="${cx-s*0.5}" cy="${cy}" r="${s*0.2}" fill="${color}" opacity="0.5"/><circle cx="${cx+s*0.5}" cy="${cy}" r="${s*0.2}" fill="${color}" opacity="0.5"/>`,
code: (cx, cy, s, color) =>
`<path d="M${cx-s*0.6} ${cy} L${cx-s*0.2} ${cy-s*0.5} L${cx-s*0.2} ${cy+s*0.5}" stroke="white" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"/><path d="M${cx+s*0.6} ${cy} L${cx+s*0.2} ${cy-s*0.5} L${cx+s*0.2} ${cy+s*0.5}" stroke="white" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"/>`,
};
// 自然/景观图标
const NATURE_ICONS = {
mountain: (cx, cy, s, color) =>
`<polygon points="${cx-s*0.8},${cy+s*0.5} ${cx},${cy-s*0.8} ${cx+s*0.8},${cy+s*0.5}" fill="white" opacity="0.2"/><polyline points="${cx-s*0.4},${cy} ${cx-s*0.2},${cy} ${cx},${cy-s*0.3}" stroke="white" fill="none" stroke-width="3" opacity="0.8"/><polyline points="${cx},${cy-s*0.3} ${cx+s*0.3},${cy+s*0.2} ${cx+s*0.6},${cy+s*0.2}" stroke="white" fill="none" stroke-width="3" opacity="0.8"/>`,
sun: (cx, cy, r, color) =>
`<circle cx="${cx}" cy="${cy}" r="${r*0.5}" fill="white" opacity="0.9"/>${Array.from({length:8}, (_,i) => {const a = i*Math.PI/4; return `<line x1="${cx+Math.cos(a)*r*0.65}" y1="${cy+Math.sin(a)*r*0.65}" x2="${cx+Math.cos(a)*r}" y2="${cy+Math.sin(a)*r}" stroke="white" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>`}).join("")}`,
wave: (cx, cy, s, color) =>
`<path d="M${cx-s*0.9} ${cy} Q${cx-s*0.45} ${cy-s*0.4} ${cx} ${cy} T${cx+s*0.9} ${cy}" stroke="white" fill="none" stroke-width="4" stroke-linecap="round" opacity="0.8"/>`,
leaf: (cx, cy, s, color) =>
`<path d="M${cx} ${cy-s*0.8} Q${cx+s*0.6} ${cy} ${cx} ${cy+s*0.6} Q${cx-s*0.6} ${cy} ${cx} ${cy-s*0.8}Z" fill="white" opacity="0.8"/><line x1="${cx}" y1="${cy-s*0.8}" x2="${cx}" y2="${cy+s*0.6}" stroke="${color}" stroke-width="1.5" opacity="0.5"/>`,
};
// 创意/艺术图标
const ART_ICONS = {
star: (cx, cy, r, color) =>
`<polygon points="${cx},${cy-r} ${cx+r*0.3},${cy-r*0.3} ${cx+r},${cy} ${cx+r*0.3},${cy+r*0.3} ${cx},${cy+r} ${cx-r*0.3},${cy+r*0.3} ${cx-r},${cy} ${cx-r*0.3},${cy-r*0.3}" fill="white" opacity="0.85"/>`,
diamond: (cx, cy, s, color) =>
`<polygon points="${cx},${cy-s} ${cx+s},${cy} ${cx},${cy+s} ${cx-s},${cy}" fill="white" opacity="0.8" transform="rotate(15 ${cx} ${cy})"/>`,
heart: (cx, cy, s, color) =>
`<path d="M${cx} ${cy+s*0.5} C${cx-s*0.8} ${cy-s*0.3} ${cx-s*0.5} ${cy-s*0.9} ${cx} ${cy-s*0.3} C${cx+s*0.5} ${cy-s*0.9} ${cx+s*0.8} ${cy-s*0.3} ${cx} ${cy+s*0.5}Z" fill="white" opacity="0.85"/>`,
crown: (cx, cy, s, color) =>
`<rect x="${cx-s*0.6}" y="${cy}" width="${s*1.2}" height="${s*0.6}" rx="${s*0.1}" fill="white" opacity="0.8"/><rect x="${cx-s*0.7}" y="${cy-s*0.4}" width="${s*0.3}" height="${s*0.5}" rx="${s*0.08}" fill="white" opacity="0.8"/><rect x="${cx-s*0.1}" y="${cy-s*0.7}" width="${s*0.2}" height="${s*0.8}" rx="${s*0.08}" fill="white" opacity="0.8"/><rect x="${cx+s*0.4}" y="${cy-s*0.4}" width="${s*0.3}" height="${s*0.5}" rx="${s*0.08}" fill="white" opacity="0.8"/>`,
};
// 运动图标
const SPORT_ICONS = {
basketball: (cx, cy, r, color) =>
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="0.85"/><path d="M${cx-r} ${cy} Q${cx} ${cy-r*0.3} ${cx+r} ${cy}" stroke="${color}" fill="none" stroke-width="3" opacity="0.6"/><path d="M${cx} ${cy-r} Q${cx} ${cy} ${cx} ${cy+r}" stroke="${color}" fill="none" stroke-width="3" opacity="0.6"/>`,
trophy: (cx, cy, s, color) =>
`<rect x="${cx-s*0.3}" y="${cy-s*0.2}" width="${s*0.6}" height="${s*0.8}" fill="white" opacity="0.85"/><rect x="${cx-s*0.2}" y="${cy+s*0.6}" width="${s*0.4}" height="${s*0.2}" rx="${s*0.05}" fill="white" opacity="0.7"/><path d="M${cx-s*0.5} ${cy-s*0.2} Q${cx-s*0.5} ${cy-s*0.6} ${cx-s*0.2} ${cy-s*0.6} L${cx-s*0.2} ${cy-s*0.2}" stroke="white" fill="none" stroke-width="2.5" opacity="0.6"/><path d="M${cx+s*0.5} ${cy-s*0.2} Q${cx+s*0.5} ${cy-s*0.6} ${cx+s*0.2} ${cy-s*0.6} L${cx+s*0.2} ${cy-s*0.2}" stroke="white" fill="none" stroke-width="2.5" opacity="0.6"/>`,
};
// 抽象几何
const ABSTRACT_SHAPES = {
concentric: (cx, cy, s, color, hash) => {
const rings = [];
for (let i = 0; i < 5; i++) {
const r = s * 0.25 + i * s * 0.15;
rings.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="white" stroke-width="${1.5 + i*0.5}" opacity="${0.7 - i*0.12}"/>`);
}
return rings.join("");
},
checker: (cx, cy, s, color, hash) => {
const cells = [];
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
if ((r + c + hash) % 2 === 0) {
const x = cx - s * 0.6 + c * s * 0.4;
const y = cy - s * 0.6 + r * s * 0.4;
cells.push(`<rect x="${x}" y="${y}" width="${s*0.32}" height="${s*0.32}" rx="${s*0.05}" fill="white" opacity="0.25"/>`);
}
}
}
return cells.join("");
},
rays: (cx, cy, s, color, hash) => {
const lines = [];
for (let i = 0; i < 12; i++) {
const angle = (i * 30 + hash * 7) * Math.PI / 180;
lines.push(`<line x1="${cx}" y1="${cy}" x2="${cx + Math.cos(angle) * s * 0.9}" y2="${cy + Math.sin(angle) * s * 0.9}" stroke="white" stroke-width="${1 + i%3}" opacity="0.15" stroke-linecap="round"/>`);
}
return lines.join("");
},
dots: (cx, cy, s, color, hash) => {
const dots = [];
for (let i = 0; i < 25; i++) {
const angle = i * 0.8;
const r = s * 0.1 + i * s * 0.03;
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
const size = 2 + (i % 4);
dots.push(`<circle cx="${x}" cy="${y}" r="${size}" fill="white" opacity="${0.6 - i * 0.02}"/>`);
}
return dots.join("");
},
triangles: (cx, cy, s, color, hash) => {
const tris = [];
for (let i = 0; i < 6; i++) {
const angle = i * 60 * Math.PI / 180;
const x = cx + Math.cos(angle) * s * 0.45;
const y = cy + Math.sin(angle) * s * 0.45;
const size = s * 0.25;
tris.push(`<polygon points="${x},${y-size} ${x-size*0.8},${y+size*0.5} ${x+size*0.8},${y+size*0.5}" fill="white" opacity="0.2" transform="rotate(${hash*13} ${x} ${y})"/>`);
}
return tris.join("");
},
};
// ============================================================
// SVG 生成函数
// ============================================================
function hash(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
function generateSvg(id, category, colors, shapeType, iconType, params) {
const [c1, c2] = colors;
const idHash = hash(id);
// 背景形状
let bgDef = "";
let bgRect = "";
if (shapeType === "circle") {
bgDef = `<clipPath id="clip"><circle cx="256" cy="256" r="230"/></clipPath>`;
bgRect = `<rect width="512" height="512" fill="url(#bg)" clip-path="url(#clip)"/>`;
} else if (shapeType === "rounded") {
bgDef = `<clipPath id="clip"><rect x="26" y="26" width="460" height="460" rx="120"/></clipPath>`;
bgRect = `<rect width="512" height="512" fill="url(#bg)" clip-path="url(#clip)"/>`;
} else if (shapeType === "hexagon") {
const points = "256,36 476,146 476,366 256,476 36,366 36,146";
bgDef = `<clipPath id="clip"><polygon points="${points}"/></clipPath>`;
bgRect = `<rect width="512" height="512" fill="url(#bg)" clip-path="url(#clip)"/>`;
bgRect += `<polygon points="${points}" fill="none" stroke="white" stroke-width="4" opacity="0.15"/>`;
} else {
// full square
bgRect = `<rect width="512" height="512" fill="url(#bg)"/>`;
}
// 渐变角度随机
const angles = ["0%", "100%", "45deg", "135deg", "225deg", "315deg", "180deg", "270deg"];
const gradAngle = angles[idHash % angles.length];
const x1 = gradAngle.includes("deg") ? (idHash % 2 === 0 ? "0%" : "50%") : "0%";
const y1 = gradAngle.includes("deg") ? "0%" : "0%";
const x2 = gradAngle.includes("deg") ? (idHash % 2 === 0 ? "100%" : "50%") : "100%";
const y2 = gradAngle.includes("deg") ? "100%" : "100%";
// 装饰元素
let decorations = "";
const decorType = idHash % 6;
if (decorType === 0) {
// 圆点装饰
for (let i = 0; i < 8; i++) {
const angle = (i * 45 + idHash * 3) * Math.PI / 180;
const dist = 160 + (idHash % 40);
const x = 256 + Math.cos(angle) * dist;
const y = 256 + Math.sin(angle) * dist;
const r = 6 + (idHash % 15);
decorations += `<circle cx="${x}" cy="${y}" r="${r}" fill="white" opacity="0.2"/>`;
}
} else if (decorType === 1) {
// 十字星装饰
for (let i = 0; i < 6; i++) {
const x = 60 + (idHash * (i + 1)) % 400;
const y = 60 + (idHash * (i + 3)) % 400;
const s = 5 + (idHash % 8);
decorations += `<path d="M${x-s} ${y} L${x+s} ${y} M${x} ${y-s} L${x} ${y+s}" stroke="white" stroke-width="1.5" opacity="0.25" stroke-linecap="round"/>`;
}
} else if (decorType === 2) {
// 波浪线底部
decorations += `<path d="M0 400 Q50 380 100 400 T200 400 T300 400 T400 400 T512 400 L512 512 L0 512Z" fill="white" opacity="0.1"/>`;
} else if (decorType === 3) {
// 对角条纹
for (let i = 0; i < 8; i++) {
const y = 80 + i * 55;
decorations += `<line x1="0" y1="${y}" x2="512" y2="${y}" stroke="white" stroke-width="2" opacity="0.08"/>`;
}
} else if (decorType === 4) {
// 边框圆角框
decorations += `<rect x="30" y="30" width="452" height="452" rx="40" fill="none" stroke="white" stroke-width="3" opacity="0.15"/>`;
decorations += `<rect x="50" y="50" width="412" height="412" rx="30" fill="none" stroke="white" stroke-width="1.5" opacity="0.1"/>`;
} else {
// 无额外装饰
}
// 中央图标
let centerIcon = "";
const cx = 256, cy = 250;
if (iconType === "face") {
const faces = Object.values(FACE_ICONS);
centerIcon = faces[idHash % faces.length](cx, cy, 70, c1);
} else if (iconType === "business") {
const icons = Object.values(BUSINESS_ICONS);
centerIcon = icons[idHash % icons.length](cx, cy, 60, c1);
} else if (iconType === "tech") {
const icons = Object.values(TECH_ICONS);
centerIcon = icons[idHash % icons.length](cx, cy, 60, c1);
} else if (iconType === "nature") {
const icons = Object.values(NATURE_ICONS);
centerIcon = icons[idHash % icons.length](cx, cy, 65, c1);
} else if (iconType === "art") {
const icons = Object.values(ART_ICONS);
centerIcon = icons[idHash % icons.length](cx, cy, 55, c1);
} else if (iconType === "sport") {
const icons = Object.values(SPORT_ICONS);
centerIcon = icons[idHash % icons.length](cx, cy, 60, c1);
} else if (iconType === "abstract") {
const keys = Object.keys(ABSTRACT_SHAPES);
centerIcon = ABSTRACT_SHAPES[keys[idHash % keys.length]](cx, cy, 180, c1, idHash);
}
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
${bgDef ? `<defs>` : ""}
${bgDef ? `<linearGradient id="bg" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">
<stop offset="0%" stop-color="${c1}"/>
<stop offset="100%" stop-color="${c2}"/>
</linearGradient>` : ""}
${bgDef ? `</defs>` : ""}
${bgRect}
${decorations}
${centerIcon}
</svg>`;
}
// ============================================================
// 头像定义
// ============================================================
const AVATAR_DEFS = [];
// --- 男性头像 (170个) ---
const MALE_THEMES = [
{ name: "business", icon: "business", shape: "circle", colors: "business", count: 35 },
{ name: "business", icon: "face", shape: "rounded", colors: "business", count: 15 },
{ name: "tech", icon: "tech", shape: "rounded", colors: "tech", count: 25 },
{ name: "tech", icon: "face", shape: "circle", colors: "tech", count: 15 },
{ name: "sport", icon: "sport", shape: "circle", colors: "warm", count: 20 },
{ name: "sport", icon: "face", shape: "rounded", colors: "warm", count: 15 },
{ name: "creative", icon: "art", shape: "rounded", colors: "creative", count: 20 },
{ name: "casual", icon: "face", shape: "circle", colors: "nature", count: 15 },
{ name: "dark", icon: "abstract", shape: "full", colors: "dark", count: 10 },
];
let maleIdx = 0;
for (const theme of MALE_THEMES) {
const colorSet = COLOR_SETS[theme.colors];
for (let i = 0; i < theme.count; i++) {
const id = `male_${theme.name}_${String(i).padStart(3, "0")}`;
const colors = colorSet[(maleIdx + i) % colorSet.length];
const shape = theme.icon === "abstract" ? "full" : ((maleIdx + i) % 3 === 0 ? theme.shape : (maleIdx + i) % 3 === 1 ? "rounded" : "circle");
AVATAR_DEFS.push({
id,
gender: "male",
theme: theme.name,
colors,
shape,
iconType: theme.icon,
});
}
maleIdx += theme.count;
}
// --- 女性头像 (170个) ---
const FEMALE_THEMES = [
{ name: "creative", icon: "art", shape: "rounded", colors: "creative", count: 30 },
{ name: "creative", icon: "face", shape: "circle", colors: "creative", count: 15 },
{ name: "business", icon: "business", shape: "circle", colors: "business", count: 20 },
{ name: "business", icon: "face", shape: "rounded", colors: "business", count: 15 },
{ name: "cute", icon: "face", shape: "circle", colors: "cute", count: 25 },
{ name: "cute", icon: "nature", shape: "rounded", colors: "cute", count: 15 },
{ name: "tech", icon: "tech", shape: "rounded", colors: "tech", count: 20 },
{ name: "nature", icon: "nature", shape: "circle", colors: "nature", count: 15 },
{ name: "sport", icon: "sport", shape: "circle", colors: "warm", count: 15 },
];
let femaleIdx = 0;
for (const theme of FEMALE_THEMES) {
const colorSet = COLOR_SETS[theme.colors];
for (let i = 0; i < theme.count; i++) {
const id = `female_${theme.name}_${String(i).padStart(3, "0")}`;
const colors = colorSet[(femaleIdx + i) % colorSet.length];
const shape = (femaleIdx + i) % 3 === 0 ? theme.shape : (femaleIdx + i) % 3 === 1 ? "rounded" : "circle";
AVATAR_DEFS.push({
id,
gender: "female",
theme: theme.name,
colors,
shape,
iconType: theme.icon,
});
}
femaleIdx += theme.count;
}
// --- 中性头像 (160个) ---
const NEUTRAL_THEMES = [
{ name: "abstract", icon: "abstract", shape: "full", colors: "dark", count: 30 },
{ name: "nature", icon: "nature", shape: "circle", colors: "nature", count: 25 },
{ name: "minimal", icon: "abstract", shape: "circle", colors: "tech", count: 25 },
{ name: "cute", icon: "face", shape: "circle", colors: "cute", count: 20 },
{ name: "retro", icon: "art", shape: "rounded", colors: "retro", count: 20 },
{ name: "geometric", icon: "abstract", shape: "hexagon", colors: "creative", count: 20 },
{ name: "space", icon: "nature", shape: "full", colors: "dark", count: 20 },
];
let neutralIdx = 0;
for (const theme of NEUTRAL_THEMES) {
const colorSet = COLOR_SETS[theme.colors];
for (let i = 0; i < theme.count; i++) {
const id = `neutral_${theme.name}_${String(i).padStart(3, "0")}`;
const colors = colorSet[(neutralIdx + i) % colorSet.length];
const shape = theme.icon === "abstract" ? ((neutralIdx + i) % 4 === 0 ? "full" : (neutralIdx + i) % 4 === 1 ? "circle" : (neutralIdx + i) % 4 === 2 ? "rounded" : "hexagon") : theme.shape;
AVATAR_DEFS.push({
id,
gender: "neutral",
theme: theme.name,
colors,
shape,
iconType: theme.icon,
});
}
neutralIdx += theme.count;
}
// ============================================================
// 生成并写入
// ============================================================
console.log(`生成 ${AVATAR_DEFS.length} 个头像...`);
const manifest = {
generatedAt: new Date().toISOString(),
total: AVATAR_DEFS.length,
categories: {
male: AVATAR_DEFS.filter(a => a.gender === "male").length,
female: AVATAR_DEFS.filter(a => a.gender === "female").length,
neutral: AVATAR_DEFS.filter(a => a.gender === "neutral").length,
},
themes: {},
avatars: [],
};
for (const def of AVATAR_DEFS) {
const svg = generateSvg(def.id, def.theme, def.colors, def.shape, def.iconType, {});
const filename = `${def.id}.svg`;
writeFileSync(resolve(AVATAR_DIR, filename), svg, "utf-8");
if (!manifest.themes[def.theme]) manifest.themes[def.theme] = 0;
manifest.themes[def.theme]++;
manifest.avatars.push({
id: def.id,
gender: def.gender,
theme: def.theme,
url: `/avatars/${filename}`,
colors: def.colors,
shape: def.shape,
});
}
writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), "utf-8");
console.log(`✅ 完成!`);
console.log(` 男性: ${manifest.categories.male} 个`);
console.log(` 女性: ${manifest.categories.female} 个`);
console.log(` 中性: ${manifest.categories.neutral} 个`);
console.log(` 主题分布:`, manifest.themes);
console.log(` 输出目录: ${AVATAR_DIR}`);
console.log(` 清单文件: ${MANIFEST_PATH}`);
+4 -3
View File
@@ -16,11 +16,11 @@ const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
const FORUM_SLUGS = [
"money", "ecommerce", "ai-tech", "career", "cross-border",
@@ -123,7 +123,8 @@ async function callDeepSeek(prompt) {
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.9,
max_tokens: 1024,
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
max_tokens: 4096,
})
);
+383
View File
@@ -0,0 +1,383 @@
// scripts/generate-diverse-avatars.mjs
// 创建一个真正多样化的头像库
// 不绑定具体 Bot,生成200个完全不同风格的头像
// Bot 从这个库中随机匹配
import { writeFileSync, existsSync, mkdirSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const AVATAR_DIR = resolve(__dirname, "..", "public", "bot-avatars");
const MANIFEST_PATH = resolve(AVATAR_DIR, "manifest.json");
if (!existsSync(AVATAR_DIR)) mkdirSync(AVATAR_DIR, { recursive: true });
// ---------- 调色板 ----------
const PALETTES = [
["#FF6B6B", "#FF8E8E"], ["#FF9F43", "#FFB366"], ["#FECA57", "#FFD571"],
["#48DBFB", "#6BE2FC"], ["#1DD1A1", "#45D9B3"], ["#A29BFE", "#BEB8FF"],
["#FD79A8", "#FD8FB8"], ["#00CEC9", "#33D9D5"], ["#E17055", "#E58874"],
["#6C5CE7", "#8378EC"], ["#FDCB6E", "#FED88C"], ["#E84393", "#EB6CA8"],
["#00B894", "#33C8A7"], ["#0984E3", "#339DEB"], ["#D63031", "#DE5252"],
["#55EFC4", "#77F2D3"], ["#74B9FF", "#8DC8FF"], ["#FF7675", "#FF8D8D"],
["#81ECEC", "#9BF0F0"], ["#A8E6CF", "#BCEDD7"], ["#FFD3B6", "#FFDFCD"],
["#D4A5A5", "#DFB9B9"], ["#C8D6E5", "#D5E0ED"], ["#F8B500", "#F9C533"],
["#0052CC", "#336FD6"], ["#36B37E", "#55C192"], ["#FF5630", "#FF7354"],
["#6554C0", "#7D6FCC"], ["#FFB400", "#FFC233"], ["#009DAE", "#00B3C6"],
];
// 头像尺寸
const W = 512, H = 512, CX = 256, CY = 256;
// ---------- 图标库(共66个不同图标)----------
// 每个图标是一个返回 SVG path/元素的函数
const ICONS = [];
// === 动物 ===
ICONS.push(() => {
// 猫
return `<g transform="translate(${CX},${CY-20})">
<ellipse cx="0" cy="20" rx="70" ry="60" fill="white" opacity="0.95"/>
<polygon points="-50,-50 -25,-15 25,-15 50,-50" fill="white" opacity="0.9"/>
<circle cx="-25" cy="-30" r="8" fill="rgba(0,0,0,0.6)"/><circle cx="25" cy="-30" r="8" fill="rgba(0,0,0,0.6)"/>
<ellipse cx="0" cy="-25" rx="6" ry="4" fill="rgba(0,0,0,0.4)"/>
<path d="M-8,-15 Q0,-10 8,-15" stroke="rgba(0,0,0,0.3)" stroke-width="2" fill="none"/>
<path d="M-15,-50 L-25,-80 L0,-55" fill="white" opacity="0.8"/>
<path d="M15,-50 L25,-80 L0,-55" fill="white" opacity="0.8"/>
</g>`;
});
ICONS.push(() => {
// 狗
return `<g transform="translate(${CX},${CY-30})">
<ellipse cx="0" cy="30" rx="75" ry="60" fill="white" opacity="0.95"/>
<ellipse cx="0" cy="-15" rx="50" ry="40" fill="white" opacity="0.9"/>
<ellipse cx="-60" cy="-5" rx="30" ry="20" fill="white" opacity="0.8"/>
<ellipse cx="60" cy="-5" rx="30" ry="20" fill="white" opacity="0.8"/>
<circle cx="-15" cy="-25" r="6" fill="rgba(0,0,0,0.6)"/><circle cx="15" cy="-25" r="6" fill="rgba(0,0,0,0.6)"/>
<ellipse cx="0" cy="-18" rx="10" ry="6" fill="rgba(0,0,0,0.5)"/>
<path d="M-10,-10 Q0,-5 10,-10" stroke="rgba(0,0,0,0.3)" stroke-width="2" fill="none"/>
<path d="M0,-10 L0,25" stroke="rgba(0,0,0,0.15)" stroke-width="3"/>
</g>`;
});
ICONS.push(() => {
// 鱼
return `<g transform="translate(${CX},${CY})">
<ellipse cx="0" cy="0" rx="100" ry="60" fill="white" opacity="0.95"/>
<polygon points="100,0 160,-40 160,40" fill="white" opacity="0.8"/>
<circle cx="-40" cy="-15" r="10" fill="rgba(0,0,0,0.5)"/>
<circle cx="-40" cy="-15" r="5" fill="rgba(0,0,0,0.3)"/>
<path d="M-20,-20 Q0,-40 20,-20" stroke="rgba(0,0,0,0.15)" stroke-width="3" fill="none"/>
<path d="M-30,10 Q0,30 30,10" stroke="rgba(0,0,0,0.15)" stroke-width="3" fill="none"/>
</g>`;
});
ICONS.push(() => {
// 鸟
return `<g transform="translate(${CX},${CY-30})">
<ellipse cx="0" cy="20" rx="50" ry="40" fill="white" opacity="0.95"/>
<circle cx="-10" cy="-5" r="20" fill="white" opacity="0.9"/>
<circle cx="-20" cy="-10" r="5" fill="rgba(0,0,0,0.5)"/>
<polygon points="-10,-25 10,-15 -8,-15" fill="white" opacity="0.8"/>
<path d="M-80,30 Q-50,10 -30,35" stroke="white" stroke-width="12" fill="none" opacity="0.7"/>
<path d="M30,35 Q50,10 80,25" stroke="white" stroke-width="12" fill="none" opacity="0.7"/>
</g>`;
});
ICONS.push(() => {
// 兔子
return `<g transform="translate(${CX},${CY-30})">
<ellipse cx="0" cy="30" rx="55" ry="50" fill="white" opacity="0.95"/>
<circle cx="0" cy="-10" r="35" fill="white" opacity="0.9"/>
<ellipse cx="-15" cy="-60" rx="12" ry="30" fill="white" opacity="0.85"/>
<ellipse cx="15" cy="-60" rx="12" ry="30" fill="white" opacity="0.85"/>
<circle cx="-12" cy="-15" r="5" fill="rgba(0,0,0,0.5)"/><circle cx="12" cy="-15" r="5" fill="rgba(0,0,0,0.5)"/>
<ellipse cx="0" cy="-8" rx="4" ry="3" fill="rgba(0,0,0,0.4)"/>
<path d="M-5,-5 Q0,0 5,-5" stroke="rgba(0,0,0,0.25)" stroke-width="2" fill="none"/>
</g>`;
});
ICONS.push(() => {
// 狐狸
return `<g transform="translate(${CX},${CY-30})">
<ellipse cx="0" cy="30" rx="55" ry="45" fill="white" opacity="0.95"/>
<polygon points="-40,-20 0,-80 40,-20" fill="white" opacity="0.9"/>
<polygon points="-40,-20 -70,-50 -30,-15" fill="white" opacity="0.7"/>
<polygon points="40,-20 70,-50 30,-15" fill="white" opacity="0.7"/>
<circle cx="-12" cy="-5" r="5" fill="rgba(0,0,0,0.5)"/><circle cx="12" cy="-5" r="5" fill="rgba(0,0,0,0.5)"/>
<ellipse cx="0" cy="2" rx="4" ry="3" fill="rgba(0,0,0,0.4)"/>
</g>`;
});
ICONS.push(() => {
// 熊猫
return `<g transform="translate(${CX},${CY-30})">
<ellipse cx="0" cy="30" rx="65" ry="60" fill="white" opacity="0.95"/>
<circle cx="0" cy="-10" r="45" fill="white" opacity="0.9"/>
<ellipse cx="-20" cy="-40" rx="18" ry="14" fill="rgba(0,0,0,0.7)"/>
<ellipse cx="20" cy="-40" rx="18" ry="14" fill="rgba(0,0,0,0.7)"/>
<circle cx="-15" cy="-15" r="6" fill="rgba(0,0,0,0.5)"/><circle cx="15" cy="-15" r="6" fill="rgba(0,0,0,0.5)"/>
<ellipse cx="0" cy="-7" rx="8" ry="5" fill="rgba(0,0,0,0.4)"/>
<ellipse cx="-40" cy="30" rx="20" ry="16" fill="rgba(0,0,0,0.7)"/>
<ellipse cx="40" cy="30" rx="20" ry="16" fill="rgba(0,0,0,0.7)"/>
</g>`;
});
ICONS.push(() => {
// 大象
return `<g transform="translate(${CX},${CY-30})">
<ellipse cx="0" cy="30" rx="75" ry="65" fill="white" opacity="0.95"/>
<ellipse cx="-30" cy="-10" rx="18" ry="22" fill="white" opacity="0.85"/>
<ellipse cx="30" cy="-10" rx="18" ry="22" fill="white" opacity="0.85"/>
<circle cx="-12" cy="-10" r="4" fill="rgba(0,0,0,0.5)"/><circle cx="12" cy="-10" r="4" fill="rgba(0,0,0,0.5)"/>
<path d="M0,-15 Q-10,-40 10,-40 Q20,-40 15,-15" stroke="white" stroke-width="8" fill="none" opacity="0.8"/>
<ellipse cx="-60" cy="30" rx="22" ry="16" fill="white" opacity="0.75"/>
<ellipse cx="60" cy="30" rx="22" ry="16" fill="white" opacity="0.75"/>
</g>`;
});
ICONS.push(() => {
// 猫头鹰
return `<g transform="translate(${CX},${CY-30})">
<ellipse cx="0" cy="20" rx="55" ry="50" fill="white" opacity="0.95"/>
<ellipse cx="0" cy="-15" rx="50" ry="38" fill="white" opacity="0.9"/>
<polygon points="-30,-50 30,-50 0,-25" fill="white" opacity="0.7"/>
<circle cx="-18" cy="-20" r="12" fill="white" opacity="0.85"/>
<circle cx="18" cy="-20" r="12" fill="white" opacity="0.85"/>
<circle cx="-18" cy="-20" r="5" fill="rgba(0,0,0,0.5)"/><circle cx="18" cy="-20" r="5" fill="rgba(0,0,0,0.5)"/>
<polygon points="-5,-10 0,-5 5,-10" fill="rgba(0,0,0,0.4)"/>
</g>`;
});
// === 自然 ===
ICONS.push(() => {
// 山
return `<g transform="translate(${CX},${CY+30})">
<polygon points="-140,80 0,-120 140,80" fill="white" opacity="0.9"/>
<polygon points="-100,80 -30,-40 40,80" fill="rgba(255,255,255,0.5)"/>
<rect x="-30" y="50" width="60" height="50" rx="5" fill="white" opacity="0.4"/>
</g>`;
});
ICONS.push(() => {
// 太阳
return `<g transform="translate(${CX},${CY})">
<circle cx="0" cy="0" r="70" fill="white" opacity="0.95"/>
${Array.from({length:12}, (_,i) => {
const a = i * 30 * Math.PI/180;
return `<line x1="${Math.cos(a)*85}" y1="${Math.sin(a)*85}" x2="${Math.cos(a)*120}" y2="${Math.sin(a)*120}" stroke="white" stroke-width="8" stroke-linecap="round" opacity="0.7"/>`;
}).join("\n ")}
</g>`;
});
ICONS.push(() => {
// 月亮
return `<g transform="translate(${CX},${CY})">
<circle cx="0" cy="0" r="80" fill="white" opacity="0.95"/>
<circle cx="30" cy="-15" r="60" fill="rgba(0,0,0,0.08)"/>
${Array.from({length:5}, (_,i) => {
const a = i * 60 * Math.PI/180;
const cx2 = Math.cos(a)*35, cy2 = Math.sin(a)*35;
return `<circle cx="${cx2+5}" cy="${cy2-5}" r="${4-i*0.5}" fill="white" opacity="${0.6-i*0.1}"/>`;
}).join("\n ")}
</g>`;
});
ICONS.push(() => {
// 星
return `<g transform="translate(${CX},${CY})">
<polygon points="0,-100 22,-31 95,-31 36,11 59,80 0,40 -59,80 -36,11 -95,-31 -22,-31" fill="white" opacity="0.95"/>
</g>`;
});
ICONS.push(() => {
// 树
return `<g transform="translate(${CX},${CY+10})">
<rect x="-15" y="60" width="30" height="50" rx="4" fill="white" opacity="0.7"/>
<polygon points="0,-120 -100,60 100,60" fill="white" opacity="0.9"/>
<polygon points="0,-80 -80,30 80,30" fill="white" opacity="0.8"/>
<polygon points="0,-40 -60,0 60,0" fill="white" opacity="0.7"/>
</g>`;
});
ICONS.push(() => {
// 花
return `<g transform="translate(${CX},${CY})">
${Array.from({length:6}, (_,i) => {
const a = i * 60 * Math.PI/180;
const cx2 = Math.cos(a)*50, cy2 = Math.sin(a)*50;
return `<ellipse cx="${cx2}" cy="${cy2}" rx="30" ry="20" fill="white" opacity="0.8" transform="rotate(${i*60} ${cx2} ${cy2})"/>`;
}).join("\n ")}
<circle cx="0" cy="0" r="25" fill="white" opacity="0.95"/>
</g>`;
});
ICONS.push(() => {
// 叶子
return `<g transform="translate(${CX},${CY})">
<path d="M0,-100 Q80,-50 60,30 Q20,-10 0,100 Q-20,-10 -60,30 Q-80,-50 0,-100Z" fill="white" opacity="0.95"/>
<line x1="0" y1="-90" x2="0" y2="90" stroke="rgba(0,0,0,0.1)" stroke-width="3"/>
${Array.from({length:7}, (_,i) => {
const y = -60 + i*20;
return `<line x1="-15" y1="${y}" x2="15" y2="${y}" stroke="rgba(0,0,0,0.08)" stroke-width="2"/>`;
}).join("\n ")}
</g>`;
});
ICONS.push(() => {
// 云
return `<g transform="translate(${CX},${CY})">
<circle cx="-50" cy="10" r="35" fill="white" opacity="0.9"/>
<circle cx="0" cy="-10" r="45" fill="white" opacity="0.95"/>
<circle cx="50" cy="10" r="35" fill="white" opacity="0.9"/>
<rect x="-50" y="10" width="100" height="30" rx="15" fill="white" opacity="0.9"/>
</g>`;
});
ICONS.push(() => {
// 闪电
return `<g transform="translate(${CX},${CY-10})">
<polygon points="-30,-100 10,-20 -10,-20 40,90 0,10 20,10 -20,90" fill="white" opacity="0.95"/>
</g>`;
});
ICONS.push(() => {
// 雨滴
return `<g transform="translate(${CX},${CY})">
<path d="M0,-90 Q-40,-20 0,60 Q40,-20 0,-90Z" fill="white" opacity="0.9"/>
<ellipse cx="-10" cy="-40" rx="6" ry="3" fill="rgba(255,255,255,0.3)"/>
</g>`;
});
ICONS.push(() => {
// 火焰
return `<g transform="translate(${CX},${CY})">
<path d="M0,-100 Q-50,-30 -30,50 Q-15,80 0,90 Q15,80 30,50 Q50,-30 0,-100Z" fill="white" opacity="0.95"/>
<path d="M0,-70 Q-25,-10 -15,50 Q-5,70 0,80 Q5,70 15,50 Q25,-10 0,-70Z" fill="rgba(255,255,255,0.4)"/>
</g>`;
});
// === 科技 ===
ICONS.push(() => {
// 齿轮
return `<g transform="translate(${CX},${CY})">
<circle cx="0" cy="0" r="50" fill="white" opacity="0.95"/>
${Array.from({length:8}, (_,i) => {
const a = i * 45 * Math.PI/180;
return `<rect x="${Math.cos(a)*65-15}" y="${Math.sin(a)*65-10}" width="30" height="20" rx="5" fill="white" transform="rotate(${i*45} ${Math.cos(a)*65} ${Math.sin(a)*65})" opacity="0.8"/>`;
}).join("\n ")}
<circle cx="0" cy="0" r="20" fill="rgba(0,0,0,0.1)"/>
</g>`;
});
ICONS.push(() => {
// 机器人
return `<g transform="translate(${CX},${CY})">
<rect x="-50" y="-60" width="100" height="80" rx="20" fill="white" opacity="0.95"/>
<rect x="-70" y="-40" width="20" height="40" rx="10" fill="white" opacity="0.8"/>
<rect x="50" y="-40" width="20" height="40" rx="10" fill="white" opacity="0.8"/>
<circle cx="-18" cy="-25" r="10" fill="rgba(0,0,0,0.1)"/><circle cx="-18" cy="-25" r="5" fill="rgba(0,0,0,0.3)"/>
<circle cx="18" cy="-25" r="10" fill="rgba(0,0,0,0.1)"/><circle cx="18" cy="-25" r="5" fill="rgba(0,0,0,0.3)"/>
<rect x="-20" y="0" width="40" height="5" rx="3" fill="rgba(0,0,0,0.2)"/>
<line x1="0" y1="-80" x2="0" y2="-100" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.8"/>
<circle cx="0" cy="-100" r="8" fill="white" opacity="0.8"/>
</g>`;
});
ICONS.push(() => {
// 灯泡
return `<g transform="translate(${CX},${CY-10})">
<circle cx="0" cy="-30" r="55" fill="white" opacity="0.95"/>
<rect x="-15" y="20" width="30" height="25" rx="4" fill="white" opacity="0.85"/>
<rect x="-25" y="45" width="50" height="12" rx="4" fill="white" opacity="0.75"/>
<rect x="-20" y="57" width="40" height="8" rx="3" fill="white" opacity="0.65"/>
${Array.from({length:4}, (_,i) => {
const a = (i*30-45) * Math.PI/180;
return `<line x1="${Math.cos(a)*25}" y1="${Math.sin(a)*25-20}" x2="${Math.cos(a)*50}" y2="${Math.sin(a)*50-20}" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.5"/>`;
}).join("\n ")}
</g>`;
});
ICONS.push(() => {
// 代码符号 </>
return `<g transform="translate(${CX},${CY})">
<path d="M-80,-60 L-20,0 L-80,60" stroke="white" stroke-width="18" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
<path d="M80,-60 L20,0 L80,60" stroke="white" stroke-width="18" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
</g>`;
});
ICONS.push(() => {
// WiFi
return `<g transform="translate(${CX},${CY+30})">
<circle cx="0" cy="-80" r="10" fill="white" opacity="0.9"/>
<path d="M-40,-40 Q0,-90 40,-40" stroke="white" stroke-width="10" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M-75,-10 Q0,-80 75,-10" stroke="white" stroke-width="10" fill="none" stroke-linecap="round" opacity="0.45"/>
</g>`;
});
ICONS.push(() => {
// 芯片
return `<g transform="translate(${CX},${CY})">
<rect x="-70" y="-70" width="140" height="140" rx="15" fill="white" opacity="0.95"/>
<rect x="-40" y="-40" width="80" height="80" rx="8" fill="rgba(0,0,0,0.1)"/>
${Array.from({length:8}, (_,i) => {
const pos = -56 + i * 16;
return `<rect x="${pos}" y="-75" width="8" height="10" rx="2" fill="white" opacity="0.7"/>
<rect x="${pos}" y="65" width="8" height="10" rx="2" fill="white" opacity="0.7"/>
<rect x="-75" y="${pos}" width="10" height="8" rx="2" fill="white" opacity="0.7"/>
<rect x="65" y="${pos}" width="10" height="8" rx="2" fill="white" opacity="0.7"/>`;
}).join("\n ")}
</g>`;
});
ICONS.push(() => {
// 火箭
return `<g transform="translate(${CX},${CY-20})">
<path d="M0,-110 L-30,30 L0,80 L30,30 Z" fill="white" opacity="0.95"/>
<ellipse cx="0" cy="-10" rx="14" ry="18" fill="rgba(0,0,0,0.08)"/>
<polygon points="-15,30 0,100 15,30" fill="rgba(255,255,255,0.5)"/>
<polygon points="-40,30 -10,70 0,30" fill="white" opacity="0.6"/>
<polygon points="40,30 10,70 0,30" fill="white" opacity="0.6"/>
</g>`;
});
ICONS.push(() => {
// 盾牌
return `<g transform="translate(${CX},${CY-20})">
<path d="M0,-110 L-70,-30 L-70,40 Q-70,90 0,110 Q70,90 70,40 L70,-30 Z" fill="white" opacity="0.95"/>
<path d="M-30,-10 L0,20 L30,-10" stroke="rgba(0,0,0,0.15)" stroke-width="12" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="0" y1="-30" x2="0" y2="30" stroke="rgba(0,0,0,0.15)" stroke-width="12" stroke-linecap="round"/>
<line x1="0" y1="40" x2="0" y2="70" stroke="rgba(0,0,0,0.15)" stroke-width="8" stroke-linecap="round"/>
</g>`;
});
// === 商业/学习 ===
ICONS.push(() => {
// 图表(柱状图)
return `<g transform="translate(${CX},${CY-10})">
<rect x="-80" y="30" width="30" height="50" rx="5" fill="white" opacity="0.85"/>
<rect x="-30" y="-10" width="30" height="90" rx="5" fill="white" opacity="0.95"/>
<rect x="20" y="10" width="30" height="70" rx="5" fill="white" opacity="0.9"/>
<rect x="70" y="-40" width="30" height="120" rx="5" fill="white" opacity="0.8"/>
<line x1="-95" y1="80" x2="115" y2="80" stroke="white" stroke-width="4" opacity="0.6"/>
</g>`;
});
ICONS.push(() => {
// 钱包/钱袋
return `<g transform="translate(${CX},${CY-10})">
<path d="M-70,-40 Q-80,-80 0,-100 Q80,-80 70,-40 L80,80 Q80,100 60,100 L-60,100 Q-80,100 -80,80 Z" fill="white" opacity="0.95"/>
<rect x="-30" y="-10" width="60" height="35" rx="10" fill="rgba(0,0,0,0.1)"/>
<circle cx="-15" cy="8" r="5" fill="rgba(0,0,0,0.15)"/><circle cx="5" cy="8" r="5" fill="rgba(0,0,0,0.15)"/><circle cx="25" cy="8" r="5" fill="rgba(0,0,0,0.15)"/>
<line x1="-60" y1="-40" x2="60" y2="-40" stroke="rgba(0,0,0,0.1)" stroke-width="6" stroke-linecap="round"/>
</g>`;
});
ICONS.push(() => {
// 书本
return `<g transform="translate(${CX},${CY})">
<path d="M-90,-70 L0,-90 L90,-70 L90,80 L0,100 L-90,80 Z" fill="white" opacity="0.9"/>
<path d="M-70,-55 L0,-75 L70,-55 L70,65 L0,85 L-70,65 Z" fill="rgba(0,0,0,0.06)"/>
+442
View File
@@ -0,0 +1,442 @@
import { readFileSync, writeFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const BOT_DATA_PATH = resolve(__dirname, "..", "data", "bot-characters.json");
// 行业板块配置
const FORUM_CONFIGS = {
money: {
name: "赚钱与副业",
slugs: ["money-projects", "money-pitfalls", "money-models", "money-side-hustle", "money-freelance"]
},
ecommerce: {
name: "电商运营",
slugs: ["ec-platform", "ec-livestream", "ec-product", "ec-supply", "ec-private", "ec-dtc"]
},
"ai-tech": {
name: "AI科技",
slugs: ["ai-tools-app", "ai-startup-forum", "ai-llm", "ai-saas", "ai-agent"]
},
career: {
name: "职场创业",
slugs: ["career-grow", "career-startup", "career-35", "career-transition", "career-remote"]
},
"cross-border": {
name: "跨境电商",
slugs: ["cb-platform", "cb-logistics", "cb-local", "cb-brand", "cb-supply"]
},
"real-estate": {
name: "房产投资",
slugs: ["re-buy", "re-invest", "re-market", "re-rent", "re-renovation"]
},
content: {
name: "自媒体内容",
slugs: ["ct-short-video", "ct-live", "ct-mcn", "ct-writing", "ct-podcast"]
},
"offline-biz": {
name: "实体生意",
slugs: ["ob-shop", "ob-food", "ob-service", "ob-franchise", "ob-location"]
},
education: {
name: "教育成长",
slugs: ["edu-skills", "edu-mindset", "edu-books", "edu-online", "edu-kids"]
},
"fire-life": {
name: "FIRE与生活",
slugs: ["fl-fire", "fl-invest", "fl-lifestyle", "fl-retire", "fl-balance"]
}
};
// 路人角色模板
const ROLE_TEMPLATES = [
{
type: "newbie",
suffix: "新手",
identity: "刚入行{industry}不到半年,还在摸索中",
stance: "大家好,我是新人,想请教一下{topic}的问题",
expertise: ["学习能力", "信息搜集"],
weakness: ["实战经验", "行业人脉", "资金"],
catchphrase: "这个我还不懂",
activityLevel: "high",
replyChance: 0.5
},
{
type: "observer",
suffix: "观察者",
identity: "关注{industry}动态{years}年,喜欢从旁观者角度看问题",
stance: "我虽然不直接做这个,但看了很多案例,发现{insight}",
expertise: ["案例分析", "趋势观察", "信息整合"],
weakness: ["实操经验", "执行细节"],
catchphrase: "我观察到一个现象",
activityLevel: "medium",
replyChance: 0.3
},
{
type: "questioner",
suffix: "求问者",
identity: "在{industry}遇到瓶颈,想听听大家的建议",
stance: "我现在的情况是{situation},不知道该怎么办",
expertise: ["问题描述", "反思总结"],
weakness: ["解决方案", "决策能力"],
catchphrase: "大家怎么看",
activityLevel: "medium",
replyChance: 0.4
},
{
type: "enthusiast",
suffix: "爱好者",
identity: "对{industry}有浓厚兴趣,业余时间都在研究这个",
stance: "虽然这不是我的主业,但我真的很喜欢研究{topic}",
expertise: ["热情", "自学能力", "信息搜集"],
weakness: ["商业化", "实战经验"],
catchphrase: "我最近在看",
activityLevel: "high",
replyChance: 0.4
},
{
type: "skeptic",
suffix: "质疑者",
identity: "对{industry}的一些说法持怀疑态度,喜欢追问本质",
stance: "你们说的这个我真的不太信,{doubt}",
expertise: ["批判性思维", "逻辑分析"],
weakness: ["执行力", "乐观主义"],
catchphrase: "这真的靠谱吗",
activityLevel: "low",
replyChance: 0.2
},
{
type: "learner",
suffix: "学习者",
identity: "正在系统学习{industry},报了好几个课还在消化中",
stance: "我刚学了这个知识点,不知道理解得对不对{learning}",
expertise: ["学习方法", "知识整理"],
weakness: ["实战应用", "经验积累"],
catchphrase: "我学到的是",
activityLevel: "high",
replyChance: 0.5
},
{
type: "comparer",
suffix: "对比者",
identity: "喜欢对比不同{industry}玩法,研究各自的优劣",
stance: "我对比了A和B两种方式,发现{comparison}",
expertise: ["对比分析", "优劣势判断"],
weakness: ["深度执行", "选择困难"],
catchphrase: "我对比了一下",
activityLevel: "medium",
replyChance: 0.3
},
{
type: "pragmatist",
suffix: "务实派",
identity: "在{industry}只关心能落地的方法,不喜欢空谈",
stance: "别整那些虚的,就说{practical}",
expertise: ["实操经验", "成本控制"],
weakness: ["创新思维", "理论学习"],
catchphrase: "具体怎么做",
activityLevel: "medium",
replyChance: 0.4
}
];
// 行业描述
const INDUSTRIES = {
money: ["副业", "赚钱", "理财", "投资"],
ecommerce: ["电商", "淘宝", "抖音", "拼多多"],
"ai-tech": ["AI", "人工智能", "大模型", "AI应用"],
career: ["职场", "创业", "自由职业", "远程工作"],
"cross-border": ["跨境电商", "亚马逊", "独立站", "海外仓"],
"real-estate": ["房产", "买房", "投资房", "装修"],
content: ["自媒体", "短视频", "直播", "内容创作"],
"offline-biz": ["实体店", "餐饮", "服务业", "线下生意"],
education: ["学习", "教育", "培训", "知识付费"],
"fire-life": ["FIRE", "理财", "提前退休", "生活平衡"]
};
// 话题示例
const TOPICS = {
money: ["副业选择", "理财规划", "成本控制", "盈利模式"],
ecommerce: ["选品策略", "流量获取", "转化率优化", "供应链管理"],
"ai-tech": ["AI工具使用", "AI创业方向", "大模型应用", "AI变现"],
career: ["职业规划", "创业方向", "技能提升", "人脉建立"],
"cross-border": ["平台选择", "物流方案", "本地化运营", "合规问题"],
"real-estate": ["买房时机", "投资回报", "装修预算", "租金收益"],
content: ["内容定位", "涨粉方法", "变现模式", "平台选择"],
"offline-biz": ["选址策略", "成本控制", "获客方法", "复购提升"],
education: ["学习方法", "课程选择", "知识管理", "技能变现"],
"fire-life": ["理财策略", "被动收入", "生活成本", "退休规划"]
};
// 洞察示例
const INSIGHTS = {
money: ["真正赚钱的人都很低调", "副业最重要的是坚持", "理财要先学会省钱"],
ecommerce: ["流量越来越贵了", "供应链才是核心", "复购比拉新重要"],
"ai-tech": ["AI工具更新太快了", "真正落地的案例不多", "技术门槛在降低"],
career: ["自由职业比想象的难", "人脉比能力重要", "持续学习是必须的"],
"cross-border": ["合规成本越来越高", "本地化是关键", "物流时效很重要"],
"real-estate": ["地段真的决定一切", "现金流比升值重要", "装修是个大坑"],
content: ["内容同质化太严重了", "人设比内容重要", "变现要趁早"],
"offline-biz": ["选址定生死", "成本控制是核心", "服务体验很重要"],
education: ["学以致用最难", "系统学习比碎片学习有效", "实践出真知"],
"fire-life": ["被动收入不容易", "生活成本要控制", "心态很重要"]
};
// 情境描述
const SITUATIONS = {
money: ["想做副业但不知道从哪开始", "有点闲钱但不知道怎么理财", "主业收入不够想增加收入来源"],
ecommerce: ["店铺流量上不去", "转化率很低", "供应链经常出问题"],
"ai-tech": ["想用AI但不知道怎么用", "学了几个工具但不知道能干嘛", "想创业但不知道方向"],
career: ["工作遇到瓶颈想转行", "想创业但没方向", "想提升但不知道怎么学"],
"cross-border": ["不知道选哪个平台", "物流成本太高", "不知道怎么做本地化"],
"real-estate": ["不知道现在该不该买房", "投资房不知道选哪里", "装修预算超支了"],
content: ["不知道做什么内容", "有流量但不知道怎么变现", "内容没灵感了"],
"offline-biz": ["店里没客人", "成本太高不赚钱", "想开分店但没经验"],
education: ["学了很多但用不上", "不知道学什么有用", "想系统学习但没时间"],
"fire-life": ["不知道FIRE是否可行", "被动收入不够", "生活成本降不下来"]
};
// 质疑点
const DOUBTS = {
money: ["哪有那么多副业能赚钱", "理财不都是骗人的吗", "富人越富穷人越穷是真的吗"],
ecommerce: ["现在做电商还来得及吗", "那些成功案例都是幸存者偏差吧", "平台规则改来改去谁能跟上"],
"ai-tech": ["AI真的能替代人吗", "那些AI创业的不都是套壳吗", "大模型有什么实际用处"],
career: ["自由职业真的自由吗", "创业九死一生是真的吗", "35岁危机是真的吗"],
"cross-border": ["跨境电商真的能赚钱吗", "亚马逊越来越难做了吧", "独立站流量从哪来"],
"real-estate": ["房价真的会跌吗", "投资房真的能赚钱吗", "装修真的能省钱吗"],
content: ["自媒体真的能赚钱吗", "那些大V都是运气好吧", "内容创业是不是已经饱和了"],
"offline-biz": ["实体店真的没出路了吗", "餐饮是不是太卷了", "选址真的那么重要吗"],
education: ["知识付费是不是割韭菜", "学那么多真的有用吗", "在线教育真的好吗"],
"fire-life": ["FIRE真的可行吗", "提前退休不会无聊吗", "被动收入真的稳定吗"]
};
// 学习内容
const LEARNINGS = {
money: ["理财要先学会记账", "副业要从兴趣开始", "投资要分散风险"],
ecommerce: ["选品要看市场需求", "流量要多元化", "供应链要稳定"],
"ai-tech": ["AI工具要从简单开始", "prompt很重要", "AI要结合实际场景"],
career: ["职业规划要先了解自己", "创业要找对合伙人", "技能要持续更新"],
"cross-border": ["平台规则要研究透", "物流要提前规划", "本地化要深入"],
"real-estate": ["买房要看地段", "投资要算现金流", "装修要控制预算"],
content: ["内容要有定位", "人设要一致", "变现要多元化"],
"offline-biz": ["选址要人流量大", "成本要控制", "服务要好"],
education: ["学习要有目标", "知识要系统化", "实践出真知"],
"fire-life": ["理财要早开始", "被动收入要多元", "生活要简约"]
};
// 对比内容
const COMPARISONS = {
money: ["理财和投资的差别很大", "副业和主业的逻辑不一样", "省钱和赚钱同样重要"],
ecommerce: ["淘宝和抖音的玩法完全不同", "标品和非标品的策略不一样", "流量和转化要平衡"],
"ai-tech": ["不同AI工具有不同用途", "开源和闭源各有优劣", "技术和应用是两回事"],
career: ["打工和创业差别很大", "自由职业和创业不一样", "技能和人脉都重要"],
"cross-border": ["亚马逊和独立站逻辑不同", "FBA和自发货各有优劣", "欧美和东南亚市场差异大"],
"real-estate": ["买房和租房逻辑不同", "新房和二手房各有优劣", "投资和自住考虑不同"],
content: ["短视频和长视频逻辑不同", "图文和视频各有优劣", "涨粉和变现要平衡"],
"offline-biz": ["餐饮和零售逻辑不同", "直营和加盟各有优劣", "选址和运营都重要"],
education: ["线上和线下学习不同", "系统学习和碎片学习各有优劣", "理论和实践要结合"],
"fire-life": ["激进和保守策略不同", "理财和生活要平衡", "提前退休和正常工作各有优劣"]
};
// 务实内容
const PRACTICALS = {
money: ["怎么低成本开始副业", "怎么控制日常开支", "怎么找到靠谱的理财渠道"],
ecommerce: ["怎么找到好的供应商", "怎么提高转化率", "怎么降低物流成本"],
"ai-tech": ["怎么用AI提高工作效率", "怎么找到AI落地场景", "怎么学习AI不落后"],
career: ["怎么找到靠谱合伙人", "怎么提升核心竞争力", "怎么建立行业人脉"],
"cross-border": ["怎么选择适合的平台", "怎么降低物流成本", "怎么做本地化运营"],
"real-estate": ["怎么判断房价走势", "怎么计算投资回报", "怎么控制装修成本"],
content: ["怎么找到内容定位", "怎么提高内容质量", "怎么实现内容变现"],
"offline-biz": ["怎么选择好的位置", "怎么控制成本", "怎么提高复购率"],
education: ["怎么高效学习", "怎么选择课程", "怎么把知识变现"],
"fire-life": ["怎么增加被动收入", "怎么控制生活成本", "怎么规划退休生活"]
};
// 名字生成
const SURNAMES = ["张", "王", "李", "赵", "刘", "陈", "杨", "黄", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "何", "高", "林", "罗"];
const GIVEN_NAMES_MALE = ["伟", "强", "磊", "军", "勇", "杰", "涛", "明", "辉", "鹏", "飞", "超", "浩", "志", "亮", "刚", "平", "琴", "林", "云"];
const GIVEN_NAMES_FEMALE = ["芳", "娜", "敏", "静", "丽", "强", "洁", "霞", "玲", "燕", "红", "萍", "琴", "云", "琳", "婷", "雪", "倩", "悦", "欣"];
// 生成随机名字
function generateName() {
const surname = SURNAMES[Math.floor(Math.random() * SURNAMES.length)];
const isMale = Math.random() > 0.5;
const givenNames = isMale ? GIVEN_NAMES_MALE : GIVEN_NAMES_FEMALE;
const givenName = givenNames[Math.floor(Math.random() * givenNames.length)];
return surname + givenName;
}
// 生成头像提示
function generateAvatarPrompt(role, industry, age) {
const gender = Math.random() > 0.5 ? "男性" : "女性";
const industryDesc = INDUSTRIES[industry][0];
const appearances = {
newbie: `看起来比较年轻,穿着朴素,眼神中带着好奇`,
observer: `穿着休闲,眼神敏锐,看起来善于观察`,
questioner: `看起来有些焦虑,穿着普通,表情认真`,
enthusiast: `穿着有个性,眼神热情,看起来很投入`,
skeptic: `穿着简约,表情严肃,眼神质疑`,
learner: `穿着学生气,拿着笔记本,看起来很认真`,
comparer: `穿着商务休闲,拿着手机,看起来在对比什么`,
pragmatist: `穿着朴实,表情务实,看起来很接地气`
};
return `${age}岁中国${gender},${industryDesc}${role.suffix},${appearances[role.type]}`;
}
// 生成路人角色
function generatePasserby(index, forumSlug, roleTemplate) {
const industry = INDUSTRIES[forumSlug];
const industryName = industry[Math.floor(Math.random() * industry.length)];
const forumName = FORUM_CONFIGS[forumSlug].name;
const displayName = generateName();
const key = `passerby_${forumSlug}_${roleTemplate.type}_${index}`;
const email = `bot_${key}@zhuiguang.ai`;
const age = 22 + Math.floor(Math.random() * 20);
const avatarPrompt = generateAvatarPrompt(roleTemplate, forumSlug, age);
const years = 1 + Math.floor(Math.random() * 5);
const topic = TOPICS[forumSlug][Math.floor(Math.random() * TOPICS[forumSlug].length)];
const insight = INSIGHTS[forumSlug][Math.floor(Math.random() * INSIGHTS[forumSlug].length)];
const situation = SITUATIONS[forumSlug][Math.floor(Math.random() * SITUATIONS[forumSlug].length)];
const doubt = DOUBTS[forumSlug][Math.floor(Math.random() * DOUBTS[forumSlug].length)];
const learning = LEARNINGS[forumSlug][Math.floor(Math.random() * LEARNINGS[forumSlug].length)];
const comparison = COMPARISONS[forumSlug][Math.floor(Math.random() * COMPARISONS[forumSlug].length)];
const practical = PRACTICALS[forumSlug][Math.floor(Math.random() * PRACTICALS[forumSlug].length)];
// 替换模板中的变量
const identity = roleTemplate.identity
.replace("{industry}", industryName)
.replace("{years}", years.toString());
let stance = roleTemplate.stance
.replace("{industry}", industryName)
.replace("{topic}", topic)
.replace("{insight}", insight)
.replace("{situation}", situation)
.replace("{doubt}", doubt)
.replace("{learning}", learning)
.replace("{comparison}", comparison)
.replace("{practical}", practical);
const speakingStyles = {
newbie: "谦虚好学,喜欢问问题,语气比较谨慎",
observer: "客观冷静,喜欢从旁观者角度分析,不下结论",
questioner: "焦虑但真诚,喜欢描述自己的困境,寻求建议",
enthusiast: "热情洋溢,喜欢分享自己的发现,语气兴奋",
skeptic: "质疑一切,喜欢追问本质,语气比较直接",
learner: "认真记录,喜欢分享学习心得,语气谦虚",
comparer: "理性分析,喜欢对比不同方案,语气客观",
pragmatist: "直接务实,不喜欢空谈,语气接地气"
};
// 选择2-3个论坛
const forumSlugs = FORUM_CONFIGS[forumSlug].slugs;
const selectedForums = [];
const forumCount = 2 + Math.floor(Math.random() * 2);
const shuffled = [...forumSlugs].sort(() => Math.random() - 0.5);
for (let i = 0; i < Math.min(forumCount, shuffled.length); i++) {
selectedForums.push(shuffled[i]);
}
// 活跃时间
const activeHours = {
weekday: [],
weekend: []
};
const timeSlots = [
"07:00-09:00", "09:00-12:00", "12:00-14:00",
"14:00-18:00", "18:00-20:00", "20:00-23:00"
];
const weekdayCount = 2 + Math.floor(Math.random() * 3);
const shuffledSlots = [...timeSlots].sort(() => Math.random() - 0.5);
for (let i = 0; i < Math.min(weekdayCount, shuffledSlots.length); i++) {
activeHours.weekday.push(shuffledSlots[i]);
}
if (Math.random() > 0.3) {
const weekendSlots = ["09:00-12:00", "14:00-18:00", "19:00-22:00"];
const weekendCount = 1 + Math.floor(Math.random() * 2);
const shuffledWeekend = [...weekendSlots].sort(() => Math.random() - 0.5);
for (let i = 0; i < Math.min(weekendCount, shuffledWeekend.length); i++) {
activeHours.weekend.push(shuffledWeekend[i]);
}
} else {
activeHours.weekend.push("不定时");
}
return {
key,
displayName,
email,
role: "passerby",
avatarPrompt,
personality: {
identity,
expertise: [...roleTemplate.expertise],
weakness: [...roleTemplate.weakness],
stance,
speakingStyle: speakingStyles[roleTemplate.type],
catchphrase: roleTemplate.catchphrase,
forbiddenPatterns: [],
wordLimit: {
min: 120 + Math.floor(Math.random() * 30),
max: 350 + Math.floor(Math.random() * 100)
},
passerbyType: roleTemplate.type
},
primaryForums: selectedForums,
activeHours,
activityLevel: roleTemplate.activityLevel,
replyChance: roleTemplate.replyChance
};
}
async function main() {
console.log("开始生成新的路人角色...");
// 读取现有数据
const raw = readFileSync(BOT_DATA_PATH, "utf-8");
const data = JSON.parse(raw);
const existingPasserbyCount = data.characters.filter(c => c.role === "passerby").length;
console.log(`当前路人角色数量: ${existingPasserbyCount}`);
// 需要增加的路人数量(2倍)
const targetCount = existingPasserbyCount * 2;
const newCount = targetCount - existingPasserbyCount;
console.log(`需要新增路人角色: ${newCount}`);
const newPasserbies = [];
const forumSlugs = Object.keys(FORUM_CONFIGS);
// 均匀分配到各个行业和角色类型
let index = 0;
while (newPasserbies.length < newCount) {
const forumSlug = forumSlugs[index % forumSlugs.length];
const roleTemplate = ROLE_TEMPLATES[Math.floor(index / forumSlugs.length) % ROLE_TEMPLATES.length];
const passerby = generatePasserby(index, forumSlug, roleTemplate);
newPasserbies.push(passerby);
index++;
}
console.log(`生成了 ${newPasserbies.length} 个新路人角色`);
// 合并到现有数据
data.characters = [...data.characters, ...newPasserbies];
// 写回文件
writeFileSync(BOT_DATA_PATH, JSON.stringify(data, null, 2), "utf-8");
console.log(`完成!总角色数: ${data.characters.length}`);
console.log(`路人角色数: ${data.characters.filter(c => c.role === "passerby").length}`);
}
main().catch(console.error);
+1 -1
View File
@@ -11,7 +11,7 @@ const prisma = new PrismaClient({ adapter });
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
function extractJSON(str) {
+90
View File
@@ -0,0 +1,90 @@
// 每月1号运行:给活跃用户发放断签保护卡
// Grant freeze cards to active users on the 1st of each month
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=15`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
function getCurrentMonth() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
async function main() {
const currentMonth = getCurrentMonth();
console.log(`[grant-freeze-cards] 开始发放 ${currentMonth} 月断签保护卡...`);
// 活跃判定:近 30 天有 签到 / 发帖 / 回复 任一即可(真人用户)。
// 原实现只统计签到(forumCheckIn 常为空),导致断签卡永远发不出。
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [checkinIds, topicIds, postIds] = await Promise.all([
prisma.forumCheckIn.findMany({
where: { checkInDate: { gte: thirtyDaysAgo } },
distinct: ["userId"],
select: { userId: true },
}),
prisma.forumTopic.findMany({
where: { createdAt: { gte: thirtyDaysAgo }, user: { isBot: false } },
distinct: ["userId"],
select: { userId: true },
}),
prisma.forumPost.findMany({
where: { createdAt: { gte: thirtyDaysAgo }, user: { isBot: false } },
distinct: ["userId"],
select: { userId: true },
}),
]);
const activeUserIds = [...new Set([...checkinIds, ...topicIds, ...postIds].map((r) => r.userId))];
console.log(`[grant-freeze-cards] 找到 ${activeUserIds.length} 位近30天活跃用户`);
let granted = 0;
let skipped = 0;
let newUsers = 0;
for (const userId of activeUserIds) {
const key = `streak_freeze_${userId}`;
const existing = await prisma.systemConfig.findUnique({ where: { key } });
if (existing) {
const data = existing.value;
if (data.lastGrantedMonth === currentMonth) {
skipped++;
continue; // Already granted this month
}
// Grant 2 cards, cap at 2
data.freezeCards = Math.min((data.freezeCards || 0) + 2, 2);
data.lastGrantedMonth = currentMonth;
await prisma.systemConfig.update({
where: { key },
data: { value: data },
});
granted++;
} else {
// New user: grant initial 2 cards
await prisma.systemConfig.create({
data: {
key,
value: { freezeCards: 2, lastGrantedMonth: currentMonth },
},
});
newUsers++;
}
}
console.log(`[grant-freeze-cards] 完成: 发放 ${granted} 人, 新用户 ${newUsers} 人, 跳过 ${skipped} 人 (本月已发放) `);
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error("[grant-freeze-cards] 执行失败:", e.message);
if (prisma) await prisma.$disconnect();
process.exit(1);
});
+72 -6
View File
@@ -26,6 +26,9 @@ async function main() {
where: { startedAt: { gte: since } },
orderBy: { startedAt: "desc" },
});
// 兼容历史 status 语义:success == completed,error == failed
const isOk = (s) => s === "completed" || s === "success";
const isBad = (s) => s === "failed" || s === "error";
console.log(`\n--- 最近 7 天日志(${logs.length} 条) ---`);
const byKey = new Map();
for (const l of logs) {
@@ -33,8 +36,8 @@ async function main() {
byKey.get(l.taskKey).push(l);
}
for (const [key, list] of byKey.entries()) {
const success = list.filter((l) => l.status === "completed").length;
const failed = list.filter((l) => l.status === "failed").length;
const success = list.filter((l) => isOk(l.status)).length;
const failed = list.filter((l) => isBad(l.status)).length;
const last = list[0];
const lastDate = new Date(last.startedAt).toLocaleString("zh-CN");
console.log(` ${key.padEnd(30)} 共${list.length}次 成功${success} 失败${failed} 最近=${lastDate} status=${last.status}`);
@@ -45,6 +48,7 @@ async function main() {
const executedKeys = new Set(logs.map((l) => l.taskKey));
const allExpectedKeys = new Set([
...configs.map((c) => c.taskKey),
// 工具/技能发现
"task1-discover-tools",
"task3-check-tools",
"task4-discover-skills",
@@ -52,11 +56,34 @@ async function main() {
"task6-review-hot",
"task7-news-to-community",
"daily-news",
// Bot 核心引擎
"bot-activity",
"bot-affinity-update",
"bot-feedback-loop",
"bot-weekly-review",
"bot-skill-crystallize",
"bot-affinity-update",
"bot-weekly-review",
"bot-feedback-loop",
"bot-adversarial-learning-run",
"bot-persona-experiment-run",
"bot-roundtable",
// 运维
"cleanup-logs",
"cleanup-task-logs",
"reconcile-like-counts",
"health-check",
// 内容运营
"weekly-recommend-email",
"grant-freeze-cards",
"notification-digest",
"newsletter-generate",
// 竞品/趋势
"competitor-monitor",
"trend-alert",
// 数据维护
"enrich-tool-data",
// 行业智库
"industry-thinktank",
// 行业情报日更
"industry-daily",
]);
const knownButNeverRun = [...allExpectedKeys].filter((k) => !executedKeys.has(k));
console.log("从未执行:", knownButNeverRun.length ? knownButNeverRun.join(", ") : "无");
@@ -73,6 +100,13 @@ async function main() {
orderBy: { createdAt: "desc" },
select: { createdAt: true, title: true },
});
// 新形态(每日情报):Bot 主要产出是"楼层/帖子",主题帖只创建一次,
// 因此活跃度判定必须用最新的 Bot 楼层(post)而不是主题帖创建时间
const lastBotPost = await prisma.forumPost.findFirst({
where: { user: { isBot: true } },
orderBy: { createdAt: "desc" },
select: { createdAt: true, content: true },
});
console.log(` Bot 用户总数: ${totalBots}`);
console.log(` BotConfig 数: ${totalBotConfigs}`);
console.log(` BotMemory 数: ${totalMemories}`);
@@ -86,13 +120,45 @@ async function main() {
}
// 5. 最近失败的详情
const recentFailed = logs.filter((l) => l.status === "failed").slice(0, 5);
const recentFailed = logs.filter((l) => isBad(l.status)).slice(0, 5);
console.log(`\n--- 最近失败任务 (${recentFailed.length}) ---`);
for (const f of recentFailed) {
console.log(` ${f.taskKey} @ ${new Date(f.startedAt).toLocaleString("zh-CN")}`);
if (f.error) console.log(` ${f.error.substring(0, 200)}`);
}
// 6. 告警汇总
const criticalFailures = logs.filter(l => isBad(l.status) && (Date.now() - new Date(l.startedAt).getTime()) < 24 * 3600 * 1000);
console.log("\n--- ⚠️ 24小时告警汇总 ---");
if (criticalFailures.length === 0) {
console.log(" ✅ 无告警,所有任务正常");
} else {
console.log(` 🚨 ${criticalFailures.length} 个任务失败!`);
for (const f of criticalFailures) {
console.log(` - ${f.taskKey}: ${f.error?.substring(0, 100) || '未知错误'}`);
}
}
// Bot 活跃度(新形态):以"行业情报日更引擎最近一次执行"为准(发布/跳过都算在跑),
// 引擎每天 08:30(北京)一次,故阈值 26h;无执行记录时回退到最新 Bot 楼层时间
const lastDailyRun = await prisma.taskLog.findFirst({
where: { taskKey: "industry-daily" },
orderBy: { startedAt: "desc" },
select: { startedAt: true, status: true, result: true },
});
const lastActiveAt = lastDailyRun ? new Date(lastDailyRun.startedAt) : lastBotPost ? new Date(lastBotPost.createdAt) : null;
if (lastActiveAt) {
const hoursAgo = (Date.now() - lastActiveAt.getTime()) / 3600000;
const which = lastDailyRun ? `日更引擎(${lastDailyRun.status})` : "Bot 楼层";
if (hoursAgo > 26) {
console.log(` 🚨 Bot 体系活跃异常:最近 ${which} 在 ${hoursAgo.toFixed(1)}h 前(>26h),请检查 industry-daily 调度!`);
} else {
console.log(` ✅ Bot 活动正常(最近 ${which} ${hoursAgo.toFixed(1)}h 前)`);
}
} else {
console.log(" ⚠️ 无 Bot 活跃记录(industry-daily 尚未执行过)");
}
await prisma.$disconnect();
}
+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);
});
+748
View File
@@ -0,0 +1,748 @@
#!/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);
});
-102
View File
@@ -1,102 +0,0 @@
#!/bin/bash
# 追光AI 定时任务安装/修复脚本
# 用法: bash install-cron.sh
PROJECT_DIR="/home/ubuntu/zhuiguang-ai"
echo "========================================"
echo " 追光AI 定时任务 - 安装/修复"
echo "========================================"
mkdir -p "$PROJECT_DIR/logs"
echo "✅ 日志目录已创建: $PROJECT_DIR/logs/"
chmod +x "$PROJECT_DIR/scripts/cron-wrapper.sh"
echo "✅ cron-wrapper.sh 已加执行权限"
# 直接写入crontab(覆盖模式)
cat > /tmp/cron-temp << 'CRONEOF'
# ========== 追光AI 定时任务 ==========
# 使用 cron-wrapper.sh 统一入口(自动加载 .env)
WRAPPER="/home/ubuntu/zhuiguang-ai/scripts/cron-wrapper.sh"
PROJECT="/home/ubuntu/zhuiguang-ai"
# Task5 每天凌晨1:00 更新技能星值
0 1 * * * bash $WRAPPER task5 $PROJECT/scripts/task5-update-stars.mjs
# Task3 每天凌晨3:00 工具巡检
0 3 * * * bash $WRAPPER task3 $PROJECT/scripts/task3-check-tools.mjs
# Task6 每天凌晨4:00 评测热门技能
0 4 * * * bash $WRAPPER task6 $PROJECT/scripts/task6-review-hot.mjs
# Task4 每天凌晨5:00 发现AI技能
0 5 * * * bash $WRAPPER task4 $PROJECT/scripts/daily-discover.mjs
# Task1 每天早上6:00 发现AI工具
0 6 * * * bash $WRAPPER task1 $PROJECT/scripts/task1-discover-tools.mjs
# 每天早上7:30 生成AI日报
30 7 * * * bash $WRAPPER daily-news $PROJECT/scripts/daily-news.mjs
# 每天早上8:00 新闻推送到社区版块
0 8 * * * bash $WRAPPER task7 $PROJECT/scripts/task7-news-to-community.mjs
# 数字人bot活动引擎(每整点运行,9:00-23:00共15轮/天,每轮5+5=10板块+4回复/板块+5点赞)
# 2026-06-10 翻倍:原 4+4=8 板块、3回复、3点赞 → 5+5=10 板块、4回复、5点赞
0 9,10,11,12,13,14,15,16,17,18,19,20,21,22,23 * * * bash $WRAPPER bot-activity $PROJECT/scripts/bot-activity.mjs
# Bot跨板块亲和度与画像:每6小时滚动一次
0 */6 * * * bash $WRAPPER bot-affinity-update $PROJECT/scripts/bot-affinity-update.mjs
# Bot反馈循环:每3小时一次(基于历史回复生成跟进)
0 */3 * * * bash $WRAPPER bot-feedback-loop $PROJECT/scripts/bot-feedback-loop.mjs
# Bot技能沉淀:每12小时一次
0 */12 * * * bash $WRAPPER bot-skill-crystallize $PROJECT/scripts/bot-skill-crystallize.mjs
# Bot周复盘:每周一凌晨2:30执行
30 2 * * 1 bash $WRAPPER bot-weekly-review $PROJECT/scripts/bot-weekly-review.mjs
# 每周日凌晨6:30 清理 30 天前的 task_log 数据
30 6 * * 0 bash $WRAPPER cleanup-task-logs $PROJECT/scripts/cleanup-task-logs.mjs
# 每6小时15分对齐 forumTopic/forumPost.likeCount 与真实 Like 表
15 */6 * * * bash $WRAPPER reconcile-like-counts $PROJECT/scripts/reconcile-like-counts.mjs --fix
# Bot Persona A/B 测试调度:每4小时45分(采指标 + 显著性分析 + winner 权重切换)
45 */4 * * * bash $WRAPPER bot-persona-experiment-run $PROJECT/scripts/bot-persona-experiment-run.mjs --lookback=7
# Bot 对抗学习(真人高赞帖):每周日23:00 跑一次
0 23 * * 0 bash $WRAPPER bot-adversarial-learning-run $PROJECT/scripts/bot-adversarial-learning-run.mjs --lookback=7
CRONEOF
crontab /tmp/cron-temp
rm -f /tmp/cron-temp
echo "✅ crontab 已更新(15个定时任务)"
echo ""
echo "当前 crontab 内容:"
crontab -l
echo ""
echo "========================================"
echo " 安装完成!每天自动执行时间:"
echo " 01:00 - Task5 更新技能星值"
echo " 02:30 - Bot周复盘(仅周一)"
echo " 03:00 - Task3 工具巡检"
echo " 04:00 - Task6 评测热门技能"
echo " 05:00 - Task4 发现AI技能"
echo " 06:00 - Task1 发现AI工具"
echo " 06:15 - 点赞数对齐巡检(每6h)"
echo " 06:30 - 清理任务日志(仅周日)"
echo " 07:30 - 生成AI日报"
echo " 08:00 - Task7 新闻→社区"
echo " 每3h - Bot反馈循环(0/3/6/9/12/15/18/21)"
echo " 每4h - Bot Persona A/B 调度(3/7/11/15/19/23)"
echo " 每周日23:00 - Bot 对抗学习(真人高赞帖)"
echo " 每6h - Bot亲和度与画像(0/6/12/18)"
echo " 每12h - Bot技能沉淀(0/12)"
echo " 9-23 - 数字人bot活动引擎(每小时1轮)"
echo "========================================"
+4 -3
View File
@@ -36,12 +36,12 @@ function getOpenAI() {
if (!process.env.DEEPSEEK_API_KEY) return null;
_openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
return _openai;
}
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const MODEL = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
function ts() {
return `[${new Date().toISOString()}]`;
@@ -219,7 +219,8 @@ async function extractInsightWithLLM(botChar, topic) {
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.6,
max_tokens: 800,
// DeepSeek V4 推理 token 计入 max_tokens,预算不足会导致正文截断/为空
max_tokens: 4096,
})
);
const text = response.choices?.[0]?.message?.content?.trim() || "";
+76 -31
View File
@@ -82,7 +82,8 @@ export const PALETTE = [
];
export function pickPalette(key) {
return PALETTE[pickFromKey(key, PALETTE.length)];
// 改为真正的随机,而不是基于key的哈希
return PALETTE[Math.floor(Math.random() * PALETTE.length)];
}
// ---------- Lunaris 调用 ----------
@@ -121,35 +122,80 @@ export async function isLunarisDefault(buffer) {
* 根据 bot persona 生成 512x512 SVG 头像
* 设计要素:
* - 渐变背景(palette.from → palette.to)
* - 4 个装饰几何形(位置/大小/旋转由 key 决定)
* - 中央大字 initial(displayName 首字符)
* - 右下角 🤖 标识(用 SVG path 画,不依赖 emoji 字体)
* - 独特的几何图案(基于 key 哈希生成)
* - 中央标识(使用 displayName 的完整首字或独特符号)
* - 装饰元素多样化(圆形、方形、三角形、线条等)
*/
export function generateSvgAvatar({ key, displayName, persona }) {
const palette = pickPalette(key);
const [b1, b2, b3, b4] = keyToBytes(key);
const hash = hashKey(key);
const initial = (displayName || key).charAt(0).toUpperCase();
const isPasserby = persona?.role === "passerby";
// 4 个装饰形:圆/三角/方/菱形,位置基于 key 哈希
const shapes = [
{ type: "circle", cx: 40 + b1 * 0.7, cy: 60 + b2 * 0.4, r: 30 + (b3 % 40), opacity: 0.18 },
{ type: "rect", x: 320 + b2 * 0.3, y: 30 + b3 * 0.2, w: 60 + (b4 % 50), h: 60 + (b1 % 50), rot: b1 % 90, opacity: 0.14 },
{ type: "circle", cx: 380 + b3 * 0.2, cy: 320 + b4 * 0.4, r: 40 + (b2 % 60), opacity: 0.12 },
{ type: "polygon", points: `60,${380 + b1 * 0.2} ${120 + b2 * 0.2},${440 + b3 * 0.15} ${20 + b4 * 0.3},${460}`, opacity: 0.16 },
];
// 基于 hash 生成多样化的设计元素
const designType = hash % 5; // 5种不同的设计风格
const rotation = (hash % 360);
const scale = 0.8 + (hash % 40) / 100; // 0.8-1.2
const shapeSvg = shapes
.map((s) => {
if (s.type === "circle") {
return `<circle cx="${s.cx}" cy="${s.cy}" r="${s.r}" fill="white" opacity="${s.opacity}"/>`;
let shapeSvg = "";
if (designType === 0) {
// 风格0:放射状圆形
const circles = [];
for (let i = 0; i < 8; i++) {
const angle = (i * 45) * Math.PI / 180;
const cx = 256 + Math.cos(angle) * 150;
const cy = 256 + Math.sin(angle) * 150;
const r = 30 + (hash % 30);
circles.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="0.15"/>`);
}
shapeSvg = circles.join("\n ");
} else if (designType === 1) {
// 风格1:网格方块
const rects = [];
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 6; j++) {
if ((i + j + hash) % 3 === 0) {
const x = 50 + i * 70;
const y = 50 + j * 70;
rects.push(`<rect x="${x}" y="${y}" width="50" height="50" fill="white" opacity="0.12" transform="rotate(${rotation} ${x + 25} ${y + 25})"/>`);
}
}
if (s.type === "rect") {
return `<rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" transform="rotate(${s.rot} ${s.x + s.w / 2} ${s.y + s.h / 2})" fill="white" opacity="${s.opacity}"/>`;
}
return `<polygon points="${s.points}" fill="white" opacity="${s.opacity}"/>`;
})
.join("\n ");
}
shapeSvg = rects.join("\n ");
} else if (designType === 2) {
// 风格2:波浪线条
const paths = [];
for (let i = 0; i < 5; i++) {
const y = 80 + i * 80;
const amplitude = 40 + (hash % 30);
paths.push(`<path d="M 0 ${y} Q 128 ${y - amplitude} 256 ${y} T 512 ${y}" stroke="white" stroke-width="3" fill="none" opacity="0.2"/>`);
}
shapeSvg = paths.join("\n ");
} else if (designType === 3) {
// 风格3:三角形阵列
const triangles = [];
for (let i = 0; i < 12; i++) {
const cx = (hash * (i + 1)) % 450 + 30;
const cy = (hash * (i + 2)) % 450 + 30;
const size = 20 + (hash % 25);
const rot = (hash * i) % 360;
triangles.push(`<polygon points="${cx},${cy - size} ${cx - size * 0.866},${cy + size * 0.5} ${cx + size * 0.866},${cy + size * 0.5}" fill="white" opacity="0.15" transform="rotate(${rot} ${cx} ${cy})"/>`);
}
shapeSvg = triangles.join("\n ");
} else {
// 风格4:螺旋圆点
const dots = [];
for (let i = 0; i < 20; i++) {
const angle = i * 0.5;
const radius = 50 + i * 10;
const cx = 256 + Math.cos(angle) * radius;
const cy = 256 + Math.sin(angle) * radius;
const r = 8 + (i % 5) * 2;
dots.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="white" opacity="${0.3 - i * 0.01}"/>`);
}
shapeSvg = dots.join("\n ");
}
// 角色副标识
const roleBadge = isPasserby
@@ -158,6 +204,10 @@ export function generateSvgAvatar({ key, displayName, persona }) {
: `<rect x="20" y="20" width="92" height="28" rx="14" fill="rgba(255,255,255,0.25)"/>
<text x="66" y="38" text-anchor="middle" fill="white" font-size="13" font-weight="600" font-family="system-ui">行业专家</text>`;
// 中央文字:使用完整的首字,并添加阴影效果
const textShadow = `<text x="258" y="322" text-anchor="middle" fill="rgba(0,0,0,0.3)" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif">${escapeXml(initial)}</text>`;
const textMain = `<text x="256" y="320" text-anchor="middle" fill="white" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif">${escapeXml(initial)}</text>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
@@ -165,12 +215,6 @@ export function generateSvgAvatar({ key, displayName, persona }) {
<stop offset="0%" stop-color="${palette.from}"/>
<stop offset="100%" stop-color="${palette.to}"/>
</linearGradient>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
<feOffset dx="0" dy="4" result="offsetblur"/>
<feComponentTransfer><feFuncA type="linear" slope="0.35"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- 背景渐变 -->
<rect width="512" height="512" fill="url(#bg)"/>
@@ -178,9 +222,10 @@ export function generateSvgAvatar({ key, displayName, persona }) {
${shapeSvg}
<!-- 角色徽章 -->
${roleBadge}
<!-- 中央大字母 -->
<text x="256" y="320" text-anchor="middle" fill="white" font-size="280" font-weight="800" font-family="system-ui,-apple-system,sans-serif" filter="url(#shadow)">${escapeXml(initial)}</text>
<!-- 右下角 🤖 标识(用 path 画,不依赖 emoji 字体) -->
<!-- 中央大字母(带阴影) -->
${textShadow}
${textMain}
<!-- 右下角 🤖 标识 -->
<g transform="translate(440 440)">
<circle cx="0" cy="0" r="32" fill="white" opacity="0.95"/>
<path d="M-12 -4 L-12 8 Q-12 14 -6 14 L6 14 Q12 14 12 8 L12 -4 Q12 -10 6 -10 L-6 -10 Q-12 -10 -12 -4 Z" fill="${palette.from}"/>
+1 -15
View File
@@ -8,6 +8,7 @@
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
import { tokenize } from "./chinese-text.mjs";
const base = (process.env.DATABASE_URL || "").replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
@@ -29,21 +30,6 @@ const TOP_HUMAN_USERS = 5;
const TOP_TOPIC_TYPES = 5;
const LAST_TITLES_COUNT = 5;
const STOP_WORDS = new Set([
"的", "了", "是", "在", "和", "与", "或", "也", "都", "就", "不", "没", "要", "我", "你", "他", "她", "它", "们",
"这", "那", "有", "为", "到", "对", "及", "等", "把", "被", "从", "向", "以", "其", "之", "于", "上", "下",
"里", "外", "中", "一个", "一些", "我们", "你们", "他们", "什么", "怎么", "为什么", "啊", "吗", "呢", "吧",
"嗯", "哦", "哈", "啦", "嘿", "唉", "这个", "那个", "一个", "真的", "应该", "可能", "觉得", "认为",
]);
function tokenize(text) {
if (!text) return [];
return text
.replace(/[,。!?、;:""''【】《》()()\[\]·—\.\,\!\?\;\:\'\"\\\/]/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 2 && !STOP_WORDS.has(w));
}
function pickTopWithCount(arr, topN) {
const counts = new Map();
for (const item of arr) {
+35
View File
@@ -0,0 +1,35 @@
// =============================================================================
// 中文轻量分词工具(不依赖第三方分词库)
// 用「字符 bigram」做特征提取,兼顾关键词统计与相似度计算
// 复用方:reply-quality.mjs(关联度)、bot-persona.mjs(立场关键词)
// =============================================================================
// 高频虚词/停用字(单字)。bigram 两端都是停用字时视为无意义片段。
const STOP_CHARS = new Set(
(
"的了是在和与或也都就不没要我你他她它们这那有为到对及等把被从向以其之于上下里外中" +
"啊吗呢吧嗯哦哈啦嘿唉真应该可能觉得认为什么怎么为什么个些这个那个"
).split("")
);
function isMeaningfulGram(gram) {
const [a, b] = gram;
return !(STOP_CHARS.has(a) && STOP_CHARS.has(b));
}
/**
* 将中文文本切分为 bigram 词元列表。
* 英文/数字保留原样参与切分,标点与空白被剔除。
* @param {string} text
* @returns {string[]}
*/
export function tokenize(text) {
if (!text) return [];
const cleaned = String(text).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]+/g, "");
const grams = [];
for (let i = 0; i < cleaned.length - 1; i++) {
const gram = cleaned.slice(i, i + 2);
if (isMeaningfulGram(gram)) grams.push(gram);
}
return grams;
}
+42
View File
@@ -0,0 +1,42 @@
// Structured JSON logger with traceId support
import { randomUUID } from "crypto";
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const CURRENT_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL || "info"] ?? LOG_LEVELS.info;
function formatLog(level, message, data = {}) {
const entry = {
ts: new Date().toISOString(),
level,
msg: message,
...data,
};
if (!entry.traceId) {
entry.traceId = randomUUID().slice(0, 8);
}
return JSON.stringify(entry);
}
function createLogger(module) {
function log(level, message, data = {}) {
if (LOG_LEVELS[level] < CURRENT_LEVEL) return;
const line = formatLog(level, message, { module, ...data });
if (level === "error") {
console.error(line);
} else if (level === "warn") {
console.warn(line);
} else {
console.log(line);
}
}
return {
debug: (msg, data) => log("debug", msg, data),
info: (msg, data) => log("info", msg, data),
warn: (msg, data) => log("warn", msg, data),
error: (msg, data) => log("error", msg, data),
child: (extra) => createLogger(`${module}:${extra.sub || "child"}`),
};
}
export { createLogger };
+1 -1
View File
@@ -8,7 +8,7 @@ function getRedis() {
host: process.env.REDIS_HOST || "127.0.0.1",
port: parseInt(process.env.REDIS_PORT || "6379"),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || "1"),
db: parseInt(process.env.REDIS_SCRIPT_DB || "1"),
lazyConnect: true,
maxRetriesPerRequest: 2,
retryStrategy: (times) => Math.min(times * 200, 3000),
+115
View File
@@ -0,0 +1,115 @@
// =============================================================================
// Reply Quality Utilities
// 共享工具:Bot 回复质量检查、反模式检测、个性化风味
// =============================================================================
import { tokenize } from "./chinese-text.mjs";
// 最小上下文长度(低于此长度不做关联度评分)
export const MIN_CONTEXT_LENGTH = 20;
// 检查回复与上下文的相关度
// 返回 { valid, score } – valid 为 true 表示关联度达标,score 为 0-100 的量化评分
export function validateContextRelevance(reply, context) {
if (!context || context.length < MIN_CONTEXT_LENGTH) {
return { valid: true, score: 70 };
}
const replyGrams = tokenize(reply);
if (replyGrams.length === 0) {
return { valid: false, score: 0 };
}
const contextSet = new Set(tokenize(context));
const matched = replyGrams.filter(g => contextSet.has(g)).length;
const relevance = matched / replyGrams.length;
return {
valid: relevance >= 0.05,
score: Math.min(100, Math.round(relevance * 100)),
};
}
// 反模式检测 – 识别过于通用/模板化的 AI 回复
export function detectBadPatterns(text) {
const patterns = [
/很高兴[能为可以]您/,
/必须[地得]说/,
/值得[一关]注/,
/这是个好(问题|话题)/,
/希望[能对]你[有们]帮助/,
/感谢[您的你]分享/,
/非常有[见解启]发/,
];
const matches = patterns.filter(p => p.test(text));
return {
isGeneric: matches.length >= 3,
matchCount: matches.length,
patterns: patterns.filter(p => p.test(text)),
};
}
// 人格风味前缀 – 给回复添加个性化开场白
const PERSONALITY_PREFIXES = {
pragmatic: ["实际测试下来", "根据我的经验", "试过之后发现"],
enthusiastic: ["太棒了!", "超赞!", "强烈推荐!"],
analytical: ["从数据来看", "分析之后发现", "对比了一下数据"],
cautious: ["客观来说", "理性分析一下", "需要说明的是"],
};
export function addPersonalityFlavor(text, personaType = "pragmatic") {
const prefixes =
PERSONALITY_PREFIXES[personaType] || PERSONALITY_PREFIXES.pragmatic;
// 检查是否已包含任何前缀风味,避免重复添加
const allPrefixes = Object.values(PERSONALITY_PREFIXES).flat();
if (allPrefixes.some(p => text.startsWith(p))) {
return text;
}
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
return `${prefix},${text}`;
}
// 回复多样性指数 – 检测回复中是否包含重复短语
export function checkDiversityScore(text) {
if (text.length < 50) return 0.8;
// 将文本分成句子
const sentences = text.split(/[。!?\n]/).filter(Boolean);
if (sentences.length < 2) return 0.6;
// 检查开头词的多样性
const sentenceStarts = sentences
.map(s => s.trim().substring(0, 2))
.filter(Boolean);
const uniqueStarts = new Set(sentenceStarts);
const startVariety = uniqueStarts.size / sentenceStarts.length;
// 检查句长分布
const lengths = sentences.map(s => s.length);
const avgLen = lengths.reduce((a, b) => a + b, 0) / lengths.length;
const variance =
lengths.reduce((sum, l) => sum + (l - avgLen) ** 2, 0) / lengths.length;
// 句子开头变化率 + 句长变化率
const lengthScore = Math.min(1, variance / 1000);
return Math.round((startVariety * 0.6 + lengthScore * 0.4) * 100) / 100;
}
// 获取质量摘要统计
export function getQualityMetrics(reply, context, style) {
const { score: relevance } = validateContextRelevance(reply, context);
const { isGeneric, matchCount } = detectBadPatterns(reply);
const diversity = checkDiversityScore(reply);
return {
relevance,
isGeneric,
patternMatches: matchCount,
diversity,
style,
};
}
+25 -12
View File
@@ -1,24 +1,37 @@
// 简易熔断器:连续失败 N 次后,在冷却期内直接抛错
const circuitState = { failures: 0, openUntil: 0 };
// 简易熔断器:按 label 分桶,连续失败 N 次后,在冷却期内直接抛错
// 不同 label(如 deepseek / github / http)各自独立熔断,互不影响
const circuits = new Map(); // label -> { failures, openUntil }
const CIRCUIT_THRESHOLD = 5; // 连续失败 5 次触发熔断
const CIRCUIT_COOLDOWN_MS = 60000; // 熔断冷却 60 秒
function getCircuit(label) {
let c = circuits.get(label);
if (!c) {
c = { failures: 0, openUntil: 0 };
circuits.set(label, c);
}
return c;
}
function checkCircuit(label) {
if (Date.now() < circuitState.openUntil) {
throw new Error(`[circuit-breaker] ${label} 熔断中,${Math.ceil((circuitState.openUntil - Date.now()) / 1000)}s 后恢复`);
const c = getCircuit(label);
if (Date.now() < c.openUntil) {
throw new Error(`[circuit-breaker] ${label} 熔断中,${Math.ceil((c.openUntil - Date.now()) / 1000)}s 后恢复`);
}
}
function recordSuccess() {
circuitState.failures = 0;
circuitState.openUntil = 0;
function recordSuccess(label) {
const c = getCircuit(label);
c.failures = 0;
c.openUntil = 0;
}
function recordFailure(label) {
circuitState.failures++;
if (circuitState.failures >= CIRCUIT_THRESHOLD) {
circuitState.openUntil = Date.now() + CIRCUIT_COOLDOWN_MS;
console.warn(` 🔌 [circuit-breaker] ${label} 连续失败 ${circuitState.failures} 次,熔断 ${CIRCUIT_COOLDOWN_MS / 1000}s`);
const c = getCircuit(label);
c.failures++;
if (c.failures >= CIRCUIT_THRESHOLD) {
c.openUntil = Date.now() + CIRCUIT_COOLDOWN_MS;
console.warn(` 🔌 [circuit-breaker] ${label} 连续失败 ${c.failures} 次,熔断 ${CIRCUIT_COOLDOWN_MS / 1000}s`);
}
}
@@ -28,7 +41,7 @@ async function withRetry(fn, { maxRetries = 3, baseDelay = 1000, label = "operat
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await fn();
recordSuccess();
recordSuccess(label);
return result;
} catch (error) {
lastError = error;
+96
View File
@@ -0,0 +1,96 @@
// Task runner with retry, timeout, and structured logging
import { createLogger } from "./logger.mjs";
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
const logger = createLogger("task-runner");
function createPrisma() {
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=3&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
return new PrismaClient({ adapter });
}
async function runTask(taskKey, taskName, fn, { timeoutMs = 30 * 60 * 1000, maxRetries = 2 } = {}) {
const prisma = createPrisma();
const startedAt = new Date();
let attempt = 0;
let lastError;
logger.info(`Starting task: ${taskName}`, { taskKey });
while (attempt <= maxRetries) {
if (attempt > 0) {
const delay = Math.min(5000 * Math.pow(2, attempt - 1), 30000);
logger.warn(`Retry ${attempt}/${maxRetries} for ${taskName} after ${delay}ms`, { taskKey });
await new Promise((r) => setTimeout(r, delay));
}
try {
// Timeout wrapper
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Task timed out after ${timeoutMs / 60000} minutes`)), timeoutMs)
);
const result = await Promise.race([fn(prisma), timeoutPromise]);
// Log success
await prisma.taskLog.create({
data: {
taskKey,
taskName,
status: "success",
startedAt,
finishedAt: new Date(),
duration: Date.now() - startedAt.getTime(),
triggerBy: "cron",
result: result ? JSON.parse(JSON.stringify(result).slice(0, 2000)) : undefined,
},
}).catch(() => {});
logger.info(`Task completed: ${taskName}`, {
taskKey,
durationMs: Date.now() - startedAt.getTime(),
attempt,
});
await prisma.$disconnect();
return result;
} catch (error) {
lastError = error;
attempt++;
logger.error(`Task attempt failed: ${taskName}`, {
taskKey,
attempt,
error: error.message,
});
}
}
// All retries exhausted
const errorMsg = (lastError?.message || String(lastError)).slice(0, 1000);
await prisma.taskLog.create({
data: {
taskKey,
taskName,
status: "failed",
startedAt,
finishedAt: new Date(),
duration: Date.now() - startedAt.getTime(),
triggerBy: "cron",
error: errorMsg,
},
}).catch(() => {});
logger.error(`Task failed after ${maxRetries + 1} attempts: ${taskName}`, {
taskKey,
error: errorMsg,
});
await prisma.$disconnect();
throw lastError;
}
export { runTask, createLogger };
+83
View File
@@ -0,0 +1,83 @@
// =============================================================================
// Topic Diversity Utilities
// 共享工具:为所有 Bot 脚本提供多样化的内容生成模板和风格
// =============================================================================
// 话题模板 – 避免话题标题千篇一律
export const TOPIC_TEMPLATES = [
"【真实体验】{tool_name}试用{n}天后的真实感受",
"【对比评测】{tool_a} vs {tool_b}:到底谁更好用?",
"【使用技巧】{tool_name}的{n}个隐藏功能你知道吗?",
"【效率提升】用了{tool_name},我的工作效率提升了{n}%",
"【避坑指南】{tool_name}常见的{n}个使用误区",
"【学习心得】零基础{n}天掌握{tool_name}",
"【行业思考】{category}领域AI工具的进化方向",
"【故事分享】我是如何用{skill_type}完成一个不可能的任务的",
"【趋势预测】{year}年{category}工具的发展趋势",
"【问题交流】大家用{tool_name}遇到过这个问题吗?",
];
// 回复风格类型 – 确保回复语调多样化
export const COMMENT_STYLES = [
"agree_expert", // 资深用户赞同
"question_detail", // 追问细节
"share_experience", // 分享自己经验
"constructive_critique",// 建设性批评
"appreciation", // 感谢分享
"alternative_suggest", // 推荐替代方案
];
// 回复风格对应的中文提示(注入 prompt 指导 AI 生成特定风格)
export const COMMENT_STYLE_HINTS = {
agree_expert: "用资深用户的身份,赞同前面某个具体观点,并补充你自己的实战数据或行业经验来佐证",
question_detail: "针对前面内容的某个具体细节,提出一个有深度的问题,引导对方或其他人进一步展开",
share_experience: "分享一个你亲身经历的相关案例或故事,让讨论更接地气",
constructive_critique: "提出一个不同的视角或潜在的盲点,语气要理性、建设性,不是抬杠",
appreciation: "表达对前面讨论的欣赏,指出哪些观点对你有启发,然后自然地延伸一个相关话题",
alternative_suggest: "推荐一个实用的替代方案或工具,说明它的优缺点和适用场景",
};
// 轮转索引 – 确保话题模板不会短期内重复
let templateIndex = 0;
export function getNextTemplate() {
const t = TOPIC_TEMPLATES[templateIndex % TOPIC_TEMPLATES.length];
templateIndex++;
return t;
}
// 重置轮转索引(用于测试或手动干预)
export function resetTemplateIndex() {
templateIndex = 0;
}
// 选择回复风格 – 避免与最近使用过的风格重复
export function pickCommentStyle(recentStyles = []) {
const available = COMMENT_STYLES.filter(s => !recentStyles.includes(s));
if (available.length === 0) {
// 所有风格都在最近使用过,随机选一个
return COMMENT_STYLES[Math.floor(Math.random() * COMMENT_STYLES.length)];
}
return available[Math.floor(Math.random() * available.length)];
}
// 获取风格的中文提示
export function getStyleHint(style) {
return COMMENT_STYLE_HINTS[style] || COMMENT_STYLE_HINTS.share_experience;
}
// 话题内容类型 – 为发帖内容提供多样化结构
export const TOPIC_CONTENT_PATTERNS = [
"story_lead", // 以个人故事开头
"question_kickoff", // 以一个尖锐问题开头
"data_insight", // 以数据洞察开头
"controversial_take",// 以争议性观点开头
"trend_notice", // 以最近趋势变化开头
"personal_struggle", // 以个人困惑/难题开头
];
let patternIndex = 0;
export function getNextContentPattern() {
const p = TOPIC_CONTENT_PATTERNS[patternIndex % TOPIC_CONTENT_PATTERNS.length];
patternIndex++;
return p;
}
+305
View File
@@ -0,0 +1,305 @@
/**
* newsletter-generate.mjs
* 每周一生成追光AI Newsletter
*
* 功能:
* 1. 获取近14天热门工具(按浏览数排序)
* 2. 获取近7天GitHub星值增长最多的技能
* 3. 获取近7天最活跃的社区话题
* 4. 生成 JSON + HTML 两个版本的输出
* 5. 输出到 public/newsletter/
*
* 调度: 每周一上午 10:00 (crontab: 0 10 * * 1)
*/
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import "dotenv/config";
// ─── Prisma 初始化 ────────────────────────────────────────────
const rawUrl = process.env.DATABASE_URL;
if (!rawUrl) {
console.error("DATABASE_URL not set");
process.exit(1);
}
const base = rawUrl.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ─── 工具函数 ─────────────────────────────────────────────────
function getWeekStart() {
const now = new Date();
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1);
const monday = new Date(now.getFullYear(), now.getMonth(), diff);
monday.setHours(0, 0, 0, 0);
return monday;
}
function formatDate(date) {
const d = new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function formatNumber(n) {
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
async function main() {
const startTime = new Date();
const now = new Date();
const weekStart = getWeekStart();
const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
console.log(`📰 [Newsletter] 开始生成 ${formatDate(startTime)} 的 Newsletter...`);
// ── 1. 热门工具 ─────────────────────────────────────────────
const hotTools = await prisma.tool.findMany({
where: {
status: "published",
createdAt: { gte: twoWeeksAgo },
},
select: {
id: true,
name: true,
slug: true,
description: true,
logoUrl: true,
viewCount: true,
avgRating: true,
category: { select: { name: true, slug: true } },
},
orderBy: { viewCount: "desc" },
take: 5,
});
// ── 2. 热门技能(本周星值增长)──────────────────────────────
const hotSkills = await prisma.skill.findMany({
where: {
status: "published",
starsChangeWeek: { gt: 0 },
},
select: {
id: true,
name: true,
slug: true,
description: true,
logoUrl: true,
githubStars: true,
starsChangeWeek: true,
sourceUrl: true,
sourceType: true,
category: { select: { name: true, slug: true } },
},
orderBy: { starsChangeWeek: "desc" },
take: 3,
});
// ── 3. 热门话题 ─────────────────────────────────────────────
const hotTopics = await prisma.forumTopic.findMany({
where: {
isHidden: false,
createdAt: { gte: weekStart },
},
select: {
id: true,
title: true,
slug: true,
replyCount: true,
likeCount: true,
viewCount: true,
category: { select: { name: true, slug: true } },
},
orderBy: { replyCount: "desc" },
take: 3,
});
// ── 4. 本周期数 ────────────────────────────────────────────
const issueNumber = Math.floor(
(now.getTime() - new Date("2025-01-01").getTime()) / (7 * 24 * 60 * 60 * 1000)
);
// ── 5. 构建 Newsletter 数据 ─────────────────────────────────
const newsletter = {
issue: issueNumber,
date: formatDate(now),
weekStart: formatDate(weekStart),
weekEnd: formatDate(now),
summary: `本周推荐 ${hotTools.length} 款热门AI工具、${hotSkills.length} 个上升技能、${hotTopics.length} 个社区热帖`,
sections: {
hotTools: hotTools.map((t) => ({
name: t.name,
slug: t.slug,
description: t.description?.substring(0, 120) || "",
logoUrl: t.logoUrl,
viewCount: formatNumber(t.viewCount),
rating: t.avgRating?.toFixed(1) || "0.0",
category: t.category?.name || "未分类",
})),
hotSkills: hotSkills.map((s) => ({
name: s.name,
slug: s.slug,
description: s.description?.substring(0, 120) || "",
logoUrl: s.logoUrl,
githubStars: formatNumber(s.githubStars),
starsChange: `+${s.starsChangeWeek}`,
sourceUrl: s.sourceUrl,
sourceType: s.sourceType,
category: s.category?.name || "未分类",
})),
hotTopics: hotTopics.map((t) => ({
title: t.title,
slug: t.slug,
replyCount: t.replyCount,
likeCount: t.likeCount,
viewCount: formatNumber(t.viewCount),
category: t.category?.name || "综合",
})),
},
generatedAt: now.toISOString(),
};
// ── 6. 写入文件 ─────────────────────────────────────────────
const outDir = path.join(__dirname, "..", "public", "newsletter");
fs.mkdirSync(outDir, { recursive: true });
// JSON 输出
const jsonPath = path.join(outDir, "latest.json");
fs.writeFileSync(jsonPath, JSON.stringify(newsletter, null, 2), "utf-8");
console.log(` ✅ JSON 已写入: ${jsonPath}`);
// HTML 输出
const html = buildHtml(newsletter);
const htmlPath = path.join(outDir, "latest.html");
fs.writeFileSync(htmlPath, html, "utf-8");
console.log(` ✅ HTML 已写入: ${htmlPath}`);
// 归档 JSON(按日期)
const archiveDir = path.join(outDir, "archive");
fs.mkdirSync(archiveDir, { recursive: true });
const archivePath = path.join(archiveDir, `${formatDate(now)}.json`);
fs.writeFileSync(archivePath, JSON.stringify(newsletter, null, 2), "utf-8");
const elapsed = Date.now() - startTime.getTime();
console.log(`✅ [Newsletter] 第 ${issueNumber} 期生成完成,耗时 ${elapsed}ms`);
await prisma.$disconnect();
}
function buildHtml(n) {
const toolsHtml = n.sections.hotTools
.map(
(t) => `
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:12px;">
${t.logoUrl ? `<img src="${t.logoUrl}" alt="${t.name}" width="48" height="48" style="border-radius:10px;object-fit:contain;border:1px solid #e5e7eb;">` : ""}
<div>
<a href="https://www.zhuig.com/tools/${t.slug}" style="font-size:16px;font-weight:600;color:#7c3aed;text-decoration:none;">${t.name}</a>
<span style="margin-left:8px;font-size:12px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${t.category}</span>
<p style="margin:4px 0 0;font-size:13px;color:#6b7280;">${t.description}</p>
<span style="font-size:12px;color:#9ca3af;">👁 ${t.viewCount} 浏览 · ⭐ ${t.rating}</span>
</div>
</div>
</div>`
)
.join("");
const skillsHtml = n.sections.hotSkills
.map(
(s) => `
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:12px;">
<div style="display:flex;align-items:center;gap:12px;">
${s.logoUrl ? `<img src="${s.logoUrl}" alt="${s.name}" width="48" height="48" style="border-radius:10px;object-fit:contain;border:1px solid #e5e7eb;">` : ""}
<div>
<a href="https://www.zhuig.com/skills/${s.slug}" style="font-size:16px;font-weight:600;color:#7c3aed;text-decoration:none;">${s.name}</a>
<span style="margin-left:8px;font-size:12px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${s.category}</span>
<p style="margin:4px 0 0;font-size:13px;color:#6b7280;">${s.description}</p>
<span style="font-size:12px;color:#9ca3af;">⭐ ${s.githubStars} · 📈 ${s.starsChange} 本周</span>
</div>
</div>
</div>`
)
.join("");
const topicsHtml = n.sections.hotTopics
.map(
(t) => `
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:12px;">
<a href="https://www.zhuig.com/forum/${t.slug}" style="font-size:16px;font-weight:600;color:#7c3aed;text-decoration:none;">${t.title}</a>
<span style="margin-left:8px;font-size:12px;color:#6b7280;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${t.category}</span>
<div style="margin-top:4px;font-size:12px;color:#9ca3af;">
💬 ${t.replyCount} 回复 · 👍 ${t.likeCount} 赞 · 👁 ${t.viewCount} 浏览
</div>
</div>`
)
.join("");
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>追光AI 周刊 #${n.issue} - ${n.date}</title>
</head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:640px;margin:0 auto;background:#fff;">
<tr>
<td style="padding:40px 32px 24px;text-align:center;background:linear-gradient(135deg,#7c3aed,#a855f7);">
<h1 style="color:#fff;margin:0;font-size:28px;">🔦 追光AI 周刊</h1>
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px;">第 ${n.issue} 期 · ${n.weekStart} ~ ${n.weekEnd}</p>
</td>
</tr>
<tr>
<td style="padding:24px 32px;">
<p style="color:#4b5563;font-size:15px;line-height:1.6;margin:0;">${n.summary}</p>
</td>
</tr>
${toolsHtml ? `
<tr>
<td style="padding:0 32px 24px;">
<h2 style="color:#1f2937;font-size:20px;margin:0 0 16px;">🔥 本周热门工具</h2>
${toolsHtml}
</td>
</tr>` : ""}
${skillsHtml ? `
<tr>
<td style="padding:0 32px 24px;">
<h2 style="color:#1f2937;font-size:20px;margin:0 0 16px;">📈 技能上升榜</h2>
${skillsHtml}
</td>
</tr>` : ""}
${topicsHtml ? `
<tr>
<td style="padding:0 32px 24px;">
<h2 style="color:#1f2937;font-size:20px;margin:0 0 16px;">💡 社区热帖</h2>
${topicsHtml}
</td>
</tr>` : ""}
<tr>
<td style="padding:24px 32px;text-align:center;border-top:1px solid #e5e7eb;">
<p style="color:#9ca3af;font-size:12px;margin:0;">
<a href="https://www.zhuig.com/newsletter" style="color:#7c3aed;text-decoration:none;">查看往期周刊</a> ·
<a href="https://www.zhuig.com" style="color:#7c3aed;text-decoration:none;">追光AI</a>
</p>
<p style="color:#d1d5db;font-size:11px;margin:4px 0 0;">由追光AI自动生成于 ${n.generatedAt}</p>
</td>
</tr>
</table>
</body>
</html>`;
}
main().catch((err) => {
console.error("❌ [Newsletter] 生成失败:", err);
process.exit(1);
});
+185
View File
@@ -0,0 +1,185 @@
// 每日 09:00 运行:为活跃用户生成通知摘要
// Daily notification digest for active users
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = (process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4")
.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=15`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
// Map NotifType enum values to human-readable Chinese labels
const TYPE_LABEL = {
COMMENT_REPLY: "新回复",
COMMENT_LIKE: "评论点赞",
TOPIC_REPLY: "话题回复",
MENTION: "提及",
BEST_ANSWER: "最佳答案",
POST_LIKE: "点赞",
TOPIC_LIKE: "话题点赞",
SYSTEM: "系统通知",
DIGEST: "摘要",
};
async function main() {
const taskKey = "notification-digest";
const taskName = "通知摘要生成";
const startedAt = new Date();
// Step 1: Find active users - users with activity in the last 7 days
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000);
// Get users who have notifications or recent profile updates (proxy for active)
const activeUserIds = await prisma.notification.findMany({
where: {
createdAt: { gte: sevenDaysAgo },
type: { notIn: ["DIGEST"] },
},
distinct: ["userId"],
select: { userId: true },
});
console.log(
`[notification-digest] 找到 ${activeUserIds.length} 位活跃用户(近7天有通知)`
);
// Step 2: For each active user, check their notification prefs and compile digest
const past24h = new Date(Date.now() - 24 * 3600 * 1000);
let digestCreated = 0;
let usersSkipped = 0;
let totalDigested = 0;
for (const { userId } of activeUserIds) {
// Check preferences
const prefKey = `notif_prefs_${userId}`;
const prefConfig = await prisma.systemConfig.findUnique({
where: { key: prefKey },
});
let prefs = {
digestMode: "realtime",
channels: {},
quietHours: { enabled: false, start: "22:00", end: "08:00" },
};
if (prefConfig) {
try {
prefs = { ...prefs, ...(prefConfig.value || {}) };
} catch {
// fall back to defaults
}
}
// Skip users who want real-time notifications
if (prefs.digestMode === "realtime") {
usersSkipped++;
continue;
}
// Step 3: Compile their unread notifications from the past 24h (or past 7 days for weekly)
const digestWindow =
prefs.digestMode === "weekly"
? new Date(Date.now() - 7 * 24 * 3600 * 1000)
: past24h;
const recentNotifs = await prisma.notification.findMany({
where: {
userId,
type: { notIn: ["DIGEST"] },
isRead: false,
createdAt: { gte: digestWindow },
},
orderBy: { createdAt: "desc" },
});
if (recentNotifs.length === 0) {
usersSkipped++;
continue;
}
// Group by type
const typeCounts = {};
for (const n of recentNotifs) {
const label = TYPE_LABEL[n.type] || n.type;
typeCounts[label] = (typeCounts[label] || 0) + 1;
}
// Build summary message
const parts = Object.entries(typeCounts).map(
([label, count]) => `${count} 条${label}`
);
const modeLabel =
prefs.digestMode === "weekly" ? "本周" : "今天";
const title = `${modeLabel}你有 ${recentNotifs.length} 条新通知`;
const content = parts.join("、");
// Create the digest notification
try {
await prisma.notification.create({
data: {
userId,
type: "DIGEST",
title,
content,
link: "/notifications",
isRead: false,
},
});
digestCreated++;
totalDigested += recentNotifs.length;
} catch (err) {
console.error(`[notification-digest] 为用户 ${userId} 创建摘要失败:`, err.message);
}
}
const finishedAt = new Date();
const duration = Math.round((finishedAt - startedAt) / 1000);
console.log(
`[notification-digest] 完成: 创建 ${digestCreated} 条摘要,覆盖 ${totalDigested} 条通知,跳过 ${usersSkipped} 位用户,耗时 ${duration}s`
);
// Step 4: Log to TaskLog
await prisma.taskLog.create({
data: {
taskKey,
taskName,
status: "completed",
startedAt,
finishedAt,
duration,
result: {
activeUsers: activeUserIds.length,
digestCreated,
totalDigested,
usersSkipped,
},
},
});
await prisma.$disconnect();
}
main().catch(async (e) => {
console.error("[notification-digest] 执行失败:", e.message);
try {
await prisma.taskLog.create({
data: {
taskKey: "notification-digest",
taskName: "通知摘要生成",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: e.message?.slice(0, 500),
},
});
} catch {}
if (prisma) await prisma.$disconnect();
process.exit(1);
});
-24
View File
@@ -1,24 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 找self.__next_f.push的script块
push_pattern = re.compile(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', re.DOTALL)
chunks = push_pattern.findall(html)
print(f'RSC push 块数: {len(chunks)}')
# 解码第一块
for i, chunk in enumerate(chunks):
# 反转义
decoded = chunk.encode().decode('unicode_escape')
# 找"电商零售"在不在
if '电商零售' in decoded or '平台电商' in decoded:
print(f'\n=== Chunk {i} (长{len(decoded)}) 包含板块数据 ===')
# 找电商零售位置
idx = decoded.find('电商零售')
print(f'电商零售位置: {idx}')
print(f'周围200字符: {decoded[max(0,idx-50):idx+400]}')
if i < 3:
break
-30
View File
@@ -1,30 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 找所有 script 标签里的self.__next_f.push
scripts = re.findall(r'<script[^>]*>(self\.__next_f\.push.*?)</script>', html, re.DOTALL)
print(f'RSC push script 块数: {len(scripts)}')
for i, s in enumerate(scripts):
# 第一个字符切片, 找$12声明
if '"$12"' in s or '"$L12"' in s or '$12' in s:
# 找"$12":开头的声明
m = re.search(r'"\$?L?12":\s*"([^"]{0,500})', s)
if m:
print(f'\n=== Script {i} 含 $12 引用 (前500字符) ===')
print(m.group(1)[:500])
# 也找"children":"$12"
m2 = re.search(r'__html":\s*"(\$12|\$L12)"', s)
if m2:
print(f' Script {i} 在 __html 用 $12')
# 找所有包含"平台电商"的script
for i, s in enumerate(scripts):
if '平台电商' in s or '电商零售' in s:
idx = s.find('平台电商')
if idx == -1: idx = s.find('电商零售')
print(f'\n=== Script {i} 含"电商零售/平台电商" 位置{idx} ===')
print(s[max(0,idx-30):idx+300])
+12
View File
@@ -0,0 +1,12 @@
你是一个{forum_name}板块的AI专家,名叫{bot_name}。
当前时间:{current_time}
你的角色设定:{persona_description}
请从以下角度发表一个论坛话题:
- {angle}
话题要求:
- 标题15-30字,吸引人但不标题党
- 正文150-300字,有实质内容
- 使用中文
- 风格:{style}
+10
View File
@@ -0,0 +1,10 @@
你是一个AI社区成员,在论坛上看到有人回复了你的帖子。
你的原帖内容:{original_post}
对方回复内容:{reply_content}
请以{style}风格回复,要求:
- 保持友好、有建设性
- 50-150字
- 使用中文
- 不要机械重复对方的话
+11
View File
@@ -0,0 +1,11 @@
你是追光AI论坛的{role_name},正在进行一场圆桌讨论。
讨论主题:{topic}
你的角色定位:{role_description}
你的立场:{stance}
前一轮发言摘要:{previous_summary}
请发表你的观点(80-200字):
- 从{role_name}的角度出发
- 与主题紧密相关
- 使用中文
- 风格:{persona_type}
-41
View File
@@ -1,41 +0,0 @@
import re
import urllib.request
# 重新拉取
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 找"全部板块"区域
idx = html.find('全部板块')
print(f'HTML size: {len(html)}, 全部板块 位置: {idx}')
# 取 "全部板块" 之后的板块区域
section_start = html.find('全部板块')
section_end = html.find('最新话题', section_start)
section = html[section_start:section_end] if section_end > 0 else html[section_start:section_start+15000]
print(f'板块区域长度: {len(section)} 字符')
# 提取所有 <a> 链接
pattern = re.compile(r'<a[^>]*href="/community/([^"]+)"[^>]*>(.*?)</a>', re.DOTALL)
links = []
for m in pattern.finditer(section):
slug = m.group(1)
text = re.sub(r'<[^>]+>', ' ', m.group(2))
text = re.sub(r'\s+', ' ', text).strip()
if text and len(text) < 30:
links.append((slug, text))
print(f'\n=== 板块区域内的所有链接 (共{len(links)}个) ===')
# 按slug排序去重
seen = set()
for slug, text in links:
if slug in seen:
continue
seen.add(slug)
print(f' /community/{slug:25s} {text}')
# 看父板块"电商零售"周围的HTML
print('\n=== 电商零售(ecommerce) 周围的HTML片段 ===')
m = re.search(r'.{200}电商零售.{500}', section)
if m:
print(m.group()[:800])
+96 -16
View File
@@ -30,8 +30,8 @@ const TASK_DEFS = [
taskName: "发现AI技能",
enabled: true,
cronExpr: "0 5 * * *",
description: "每天凌晨5点从GitHub搜索高星AI项目(stars≥1000),DeepSeek预评测后入库PendingSkill待审核",
config: { minStars: 1000, dailyLimit: 30, model: "deepseek-v4-pro" },
description: "每天凌晨5点按天轮转查询池搜索AI开源项目(存量热门 stars≥1000 + 近12个月新项目 stars≥300,按 updated 排序并翻页),DeepSeek预评测后入库 PendingSkill 待审核",
config: { minStars: 1000, newMinStars: 300, queriesPerRun: 5, dailyLimit: 30, model: "deepseek-v4-pro" },
},
{
taskKey: "task5-update-stars",
@@ -63,7 +63,7 @@ const TASK_DEFS = [
enabled: true,
cronExpr: "0 9-23 * * *",
description: "每小时(9-23点)调度行业论坛Bot发言:4个专家板块+4个路人板块(每个路人板块最多2帖),按活跃时段过滤bot,按最近发帖加权去重",
config: { expertForumsPerRun: 4, passerbyForumsPerRun: 4, passerbyTopicsPerForum: 2, model: "deepseek-chat" },
config: { expertForumsPerRun: 4, passerbyForumsPerRun: 4, passerbyTopicsPerForum: 2, model: "deepseek-v4-pro" },
},
{
taskKey: "bot-feedback-loop",
@@ -77,24 +77,24 @@ const TASK_DEFS = [
taskKey: "bot-skill-crystallize",
taskName: "Bot技能沉淀",
enabled: true,
cronExpr: "0 */12 * * *",
description: "每12小时扫描过去14天高互动Bot话题(回复≥5或真人≥1或点赞≥3),DeepSeek解构共性后入库BotSkill;末尾回收7天前成熟技能的实际表现,更新successRate/avgReplies,<30%且使用≥3次自动降级",
cronExpr: "0 2 * * *",
description: "每天凌晨2点扫描过去14天高互动Bot话题(回复≥5或真人≥1或点赞≥3),DeepSeek解构共性后入库BotSkill;末尾回收7天前成熟技能的实际表现,更新successRate/avgReplies,<30%且使用≥3次自动降级",
config: { lookbackDays: 14, minReplies: 5, minHumanReplies: 1, minLikes: 3, maxTopicsPerBot: 8, recycleAfterDays: 7, minSuccessRate: 0.3 },
},
{
taskKey: "bot-affinity-update",
taskName: "Bot亲和度与画像",
enabled: true,
cronExpr: "0 */6 * * *",
description: "每6小时:①统计每个Bot话题收到的跨板块真人互动,刷BotCrossForumAffinity(affinity=min(1, samples/total/4))②扫过去30天发言,刷BotPersona(avgReplyLength/questionRatio/stanceKeywords/topHumanUsers/topTopicTypes/lastTopicTitles)",
cronExpr: "30 22 * * 0",
description: "每周日22:30:①统计每个Bot话题收到的跨板块真人互动,刷BotCrossForumAffinity(affinity=min(1, samples/total/4))②扫过去30天发言,刷BotPersona(avgReplyLength/questionRatio/stanceKeywords/topHumanUsers/topTopicTypes/lastTopicTitles)",
config: { affinityLookbackDays: 14, personaLookbackDays: 30, minAffinity: 0.1 },
},
{
taskKey: "bot-weekly-review",
taskName: "Bot周度复盘",
enabled: true,
cronExpr: "30 2 * * 1",
description: "每周一凌晨2:30对每个Bot算engagementScore(回复/帖子比+真人占比+点赞对数),生成REFLECTION记忆并决定下周配额tier(high/medium/low)",
cronExpr: "0 23 * * 0",
description: "每周日23:00对每个Bot算engagementScore(回复/帖子比+真人占比+点赞对数),生成REFLECTION记忆并决定下周配额tier(high/medium/low)",
config: { weights: { replyPerTopic: 0.4, humanRatio: 0.3, likesLog: 0.3 }, thresholds: { high: 1.0, medium: 0.5 } },
},
{
@@ -109,8 +109,8 @@ const TASK_DEFS = [
taskKey: "cleanup-task-logs",
taskName: "清理任务日志",
enabled: true,
cronExpr: "30 6 * * 0",
description: "每周日凌晨清理 30 天前的 task_log 数据,避免表无限膨胀",
cronExpr: "30 6 * * *",
description: "每天早上6:30清理 30 天前的 task_log 数据,避免表无限膨胀",
config: { retentionDays: 30 },
},
{
@@ -119,24 +119,104 @@ const TASK_DEFS = [
enabled: true,
cronExpr: "15 */6 * * *",
description: "每6小时对齐 forumTopic.likeCount / forumPost.likeCount 与真实 Like 表的计数,确保 Redis 缓存 + UI 显示与真实点赞一致(drift 自动修正)",
config: { scope: "both", botOnly: false, autoFix: true, limit: 5000 },
config: { scope: "both", botOnly: false, autoFix: true, limit: 2000 },
},
{
taskKey: "bot-persona-experiment-run",
taskName: "Bot Persona A/B 调度",
enabled: true,
cronExpr: "45 */4 * * *",
description: "每4小时:①采集最近7天 bot_persona_assignments 的真实互动数据(reply/like/humanReplies)回填到 bot_persona_metrics ②对每个bot的变体做双比例z检验(p<0.1, 样本≥20)③winner变体权重自动提到0.85,其他降到0.075,标记experiment=completed",
cronExpr: "0 * * * *",
description: "每小时:①采集最近7天 bot_persona_assignments 的真实互动数据(reply/like/humanReplies)回填到 bot_persona_metrics ②对每个bot的变体做双比例z检验(p<0.1, 样本≥20)③winner变体权重自动提到0.85,其他降到0.075,标记experiment=completed",
config: { lookbackDays: 7, minSamplesPerArm: 20, significanceLevel: 0.1 },
},
{
taskKey: "bot-adversarial-learning-run",
taskName: "Bot 对抗学习(真人高赞帖)",
enabled: true,
cronExpr: "0 23 * * 0",
description: "每周日23:00:给每个专家bot选1篇过去7天由真人发布的高互动topic(真人reply+点赞高),LLM 提取“为什么火”的洞察(hook/story/data/question/contrarian/empathy/practical),写入 bot_adversarial_learnings(unique bot+weekKey)+ BotMemory,bot下次发帖时可注入到 prompt",
cronExpr: "30 23 * * 0",
description: "每周日23:30:给每个专家bot选1篇过去7天由真人发布的高互动topic(真人reply+点赞高),LLM 提取\u201c为什么火\u201d的洞察(hook/story/data/question/contrarian/empathy/practical),写入 bot_adversarial_learnings(unique bot+weekKey)+ BotMemory,bot下次发帖时可注入到 prompt",
config: { lookbackDays: 7, expireDays: 7, minReplies: 3, minLikes: 5, minHumanReplies: 1 },
},
{
taskKey: "bot-roundtable",
taskName: "Bot 圆桌讨论",
enabled: true,
cronExpr: "0 20 * * 3,6",
description: "每周三、周六20:00:多个Bot围绕预定义话题扮演不同角色(主持人/支持者/挑战者/实践者/总结者)进行多轮圆桌讨论,在论坛创建主题帖+依次回复",
config: { maxBots: 5, minBots: 4 },
},
{
taskKey: "cleanup-logs",
taskName: "清理日志文件",
enabled: true,
cronExpr: "30 4 * * *",
description: "每天凌晨4:30清理/app/logs/下超过7天或超过10MB的.log文件(cron.log除外),防止日志文件占用过多磁盘",
config: { maxAgeDays: 7, maxSizeBytes: 10485760 },
},
{
taskKey: "weekly-recommend-email",
taskName: "每周推荐邮件",
enabled: true,
cronExpr: "30 10 * * 1",
description: "每周一上午10:30:分析每个活跃用户的兴趣(收藏、浏览历史、社区活动),基于兴趣类别匹配推荐AI工具,生成个性化HTML邮件输出到 data/emails/",
config: { maxUsers: 500, sections: { interest: 3, trending: 3, new: 2 }, weightFavorite: 3, weightBrowsing: 1, weightForum: 2 },
},
{
taskKey: "grant-freeze-cards",
taskName: "发放断签保护卡",
enabled: true,
cronExpr: "0 8 1 * *",
description: "每月1号早上8点:给近30天内有签到记录的活跃用户发放2张断签保护卡,卡片存储于SystemConfig表",
config: { cardsPerMonth: 2, maxCards: 2, activeWindowDays: 30 },
},
{
taskKey: "notification-digest",
taskName: "通知摘要生成",
enabled: true,
cronExpr: "0 9 * * *",
description: "每天早上9点:为启用每日/每周摘要模式的活跃用户生成通知摘要,汇总24小时内未读通知并创建DIGEST类型通知",
config: { activeWindowDays: 7, excludeTypes: ["DIGEST"] },
},
{
taskKey: "health-check",
taskName: "任务健康检查",
enabled: true,
cronExpr: "30 8 * * *",
description: "每天早上8:30审计所有定时任务最近一次成功率,检测Bot活跃度、发帖频率、失败任务详情",
config: { lookbackDays: 7 },
},
{
taskKey: "newsletter-generate",
taskName: "周刊生成",
enabled: true,
cronExpr: "0 10 * * 1",
description: "每周一10:00生成追光AI周刊:热门工具5+技能3+话题3,输出JSON+HTML+归档",
config: { sections: { tools: 5, skills: 3, topics: 3 } },
},
{
taskKey: "competitor-monitor",
taskName: "竞品监测",
enabled: true,
cronExpr: "0 9 * * 1",
description: "每周一9:00监测5家竞品AI导航站、3个GitHub仓库、5个Reddit子版块工具数据快照,周环比检测",
config: { competitors: ["taaft", "futurepedia", "aitoolsupdate", "toolify", "ai雷达"] },
},
{
taskKey: "trend-alert",
taskName: "行业趋势告警",
enabled: true,
cronExpr: "0 10 * * *",
description: "每天10:00扫HackerNews API AI话题,提取爆炸性标题+排名前10,入库趋势告警",
config: { sources: ["HackerNews"], minScore: 10, topN: 10 },
},
{
taskKey: "enrich-tool-data",
taskName: "工具数据补全",
enabled: true,
cronExpr: "0 4 * * 0",
description: "每周日04:00自动补全缺失的Logo/描述等工具元数据,通过LLM搜索补齐信息后更新数据库",
config: { batchSize: 20, maxPerRun: 100 },
},
];
async function main() {
+28 -2
View File
@@ -11,7 +11,7 @@ const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&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", timeout: 120000, maxRetries: 2 });
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com", timeout: 120000, maxRetries: 2 });
const EXTRACT_PROMPT = `你是一个AI工具信息提取助手。从以下科技新闻内容中,提取出现的AI工具/产品信息。只提取明确提到的AI软件工具、AI平台、AI产品,不要提取开源项目库(GitHub仓库)、不要提取硬件、芯片。每个工具返回以下信息(如果信息不足则跳过该工具):
@@ -108,6 +108,27 @@ async function main() {
...(await prisma.pendingTool.findMany({ select: { websiteUrl: true } })).map((t) => t.websiteUrl || ""),
]);
// ---- 预筛:只对"标题即宣告新 AI 工具/产品"的新闻调用 AI,避免逐条调用(40 次/天 → ≤15 次/天)----
const NEG_TITLE_RE = /财报|股价|裁员|诉讼|处罚|召回|人事任命|评级|监管|关税|事故|禁令|上市申请|营收|亏损|融资|涨停|跌停|关停|下线|停运|放弃|炒作|愚弄|辟谣|传言/;
const ACTION_RE = /发布|上线|推出|开源|开放|内测|公测|首发|新增|更新|升级|亮相|登场|宣布/;
// 强 AI 主体词:命中即认为"可能是新 AI 工具/产品"(\bai\b 兼顾 "AI工具" 且不误命中 email/available)
const AI_STRONG_RE = /\bai\b|大模型|多模态|生成式|智能体|\bagent|copilot|\bllm\b|\bgpt\b|chatgpt|openai|anthropic|deepseek|claude|gemini|midjourney|\bsora\b|豆包|通义|文心|kimi|即梦|可灵|扣子|智谱/;
// 泛 AI 词:单独出现不足以判定,需标题配套动作词
const AI_WEAK_RE = /模型|助手|生成|对话|语音|图像|视频|编程|插件|工具|平台|\bapi\b|算法|推理/;
// 开源项目版本更新(如 "KnowForge 2026.0.5 发布")不是"新 AI 工具",prompt 已明确排除
const CHANGELOG_RE = /\d+\.\d+/;
const likelyTool = (t) => {
const title = (t.title || "").trim();
const titleLow = title.toLowerCase();
if (NEG_TITLE_RE.test(title) && !ACTION_RE.test(title)) return false; // 财经/负面类
if (CHANGELOG_RE.test(title) && /发布|更新|新增|升级/.test(title) && !AI_STRONG_RE.test(titleLow)) return false; // 版本更新
if (AI_STRONG_RE.test(titleLow)) return true; // 标题含强 AI 主体词
return ACTION_RE.test(title) && AI_WEAK_RE.test(titleLow); // 标题含"动作词 + 泛 AI 词"
};
const MAX_AI_CALLS = 15; // 单次运行 AI 调用上限(成本保护)
let aiCalls = 0;
let prefiltered = 0;
const categoryMap = {};
const categories = await prisma.category.findMany({ select: { id: true, name: true, slug: true } });
for (const c of categories) {
@@ -132,8 +153,11 @@ async function main() {
console.log(` 获取 ${news.length} 条`);
for (const item of news) {
// 预筛:非"疑似新工具"的新闻直接跳过,不消耗 AI 调用
if (!likelyTool(item) || aiCalls >= MAX_AI_CALLS) { prefiltered++; continue; }
const text = `${item.title} ${item.content}`;
try {
aiCalls++;
const completion = await withRetry(async () => {
return await openai.chat.completions.create({
model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro",
@@ -191,11 +215,13 @@ async function main() {
const result = {
totalNews,
prefiltered,
aiCalls,
foundTools,
importedTools,
duration: `${(duration / 1000).toFixed(1)}s`,
};
console.log(`\n🏁 [Task1] 完成: 新闻${totalNews}条, 发现${foundTools}个, 入库${importedTools}个, 耗时${result.duration}`);
console.log(`\n🏁 [Task1] 完成: 新闻${totalNews}条, 预筛跳过${prefiltered}条, AI调用${aiCalls}次, 发现${foundTools}个, 入库${importedTools}个, 耗时${result.duration}`);
if (errors.length > 0) console.log(` 错误: ${errors.slice(0, 3).join("; ")}`);
await prisma.taskLog.create({
+23 -6
View File
@@ -93,6 +93,12 @@ async function main() {
console.log(`\n 已发布工具巡检完成: ${checked}个, 网站失效${websiteFails}个, Logo异常${logoFails}个`);
checked = 0;
// 自动发布判定:只要求"网站可达";Logo 仅作提示、不阻断发布
// 原因:checkLogo 用 HEAD 探测,很多站点/CDN 不支持 HEAD(返回 405/403),而前端 <img> 用 GET 渲染,
// HEAD 失败 ≠ 坏链。原先要求 websiteOk && logoOk 同时成立,导致 292 个待审核工具 0 产出。
const skipReasons = { noWebsiteUrl: 0, websiteUnreachable: 0, slugConflict: 0, publishError: 0 };
let logoBadHint = 0;
for (const pt of pendingTools) {
try {
const websiteOk = pt.websiteUrl ? await checkWebsite(pt.websiteUrl) : false;
@@ -103,10 +109,17 @@ async function main() {
data: { checkWebsiteOk: websiteOk, checkLogoOk: logoOk, checkDescOk: true, checkCategoryOk: true },
});
if (websiteOk && logoOk) {
const slug = pt.slug || pt.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-" + Date.now().toString(36);
if (!pt.websiteUrl) {
skipReasons.noWebsiteUrl++;
} else if (!websiteOk) {
skipReasons.websiteUnreachable++;
} else {
if (!logoOk) logoBadHint++;
const slug = pt.slug || pt.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
const existingSlug = await prisma.tool.findUnique({ where: { slug } });
if (!existingSlug) {
if (existingSlug) {
skipReasons.slugConflict++;
} else {
await prisma.tool.create({
data: {
name: pt.name,
@@ -121,16 +134,17 @@ async function main() {
tags: pt.tags,
status: "published",
websiteOk: true,
logoOk: true,
logoOk,
},
});
await prisma.pendingTool.delete({ where: { id: pt.id } });
autoPublished++;
console.log(` ✅ 自动发布: ${pt.name}`);
console.log(` ✅ 自动发布: ${pt.name}${logoOk ? "" : " (Logo 校验未通过,已放行)"}`);
}
}
checked++;
} catch (e) {
skipReasons.publishError++;
errors.push(`PendingTool ${pt.name}: ${e.message}`);
}
@@ -147,16 +161,19 @@ async function main() {
websiteFails,
logoFails,
autoPublished,
logoBadHint,
skipReasons,
duration: `${(duration / 1000).toFixed(1)}s`,
};
console.log(`\n🏁 [Task3] 完成: 巡检${tools.length + pendingTools.length}个, 自动发布${autoPublished}个, 网站失效${websiteFails}个`);
console.log(` 待审核未发布原因: 网站不可达${skipReasons.websiteUnreachable} / 无网址${skipReasons.noWebsiteUrl} / slug冲突${skipReasons.slugConflict} / 异常${skipReasons.publishError};其中 Logo 校验未通过${logoBadHint}个已放行`);
await prisma.taskLog.create({
data: {
taskKey: "task3-check-tools",
taskName: "工具巡检",
status: errors.length > 0 ? "completed" : "completed",
status: "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
+1 -1
View File
@@ -12,7 +12,7 @@ const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1" });
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com" });
const REVIEW_PROMPT = `你是AI技术资深评测师。请对以下AI开源项目进行五维评测,每个维度1-5分。
-94
View File
@@ -1,94 +0,0 @@
// 直接调用 bot stats 逻辑,不走 HTTP
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import "dotenv/config";
const base = process.env.DATABASE_URL.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
async function main() {
const days = 14;
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const sinceDate = new Date(since.getFullYear(), since.getMonth(), since.getDate());
const botUsers = await prisma.user.findMany({
where: { isBot: true },
select: { id: true, name: true, email: true, points: true },
});
const botConfigs = await prisma.botConfig.findMany({
select: { id: true, userId: true, primaryForums: true },
});
const configByUserId = new Map(botConfigs.map((c) => [c.userId, c]));
const dailyStats = await prisma.botDailyStat.groupBy({
by: ["botId"],
where: { date: { gte: sinceDate } },
_sum: {
topicCreated: true,
replySent: true,
repliesReceived: true,
likesReceived: true,
humanInteractions: true,
},
});
const statMap = new Map(
dailyStats.map((s) => [
s.botId,
{
topicCreated: s._sum.topicCreated || 0,
replySent: s._sum.replySent || 0,
likesReceived: s._sum.likesReceived || 0,
humanInteractions: s._sum.humanInteractions || 0,
},
])
);
const memCount = await prisma.botMemory.count();
console.log("--- Bot Stats Preview ---");
console.log("Total bots:", botUsers.length);
console.log("Total configs:", botConfigs.length);
console.log("BotDailyStat records:", dailyStats.length);
console.log("BotMemory records:", memCount);
console.log("Bots with stats:", statMap.size);
// Top 5 by health score
const top = botUsers
.map((u) => {
const cfg = configByUserId.get(u.id);
const d = statMap.get(cfg?.id) || { topicCreated: 0, replySent: 0, likesReceived: 0, humanInteractions: 0 };
return {
name: u.name,
health: d.topicCreated * 3 + d.replySent + d.likesReceived * 2 + d.humanInteractions * 5,
...d,
};
})
.sort((a, b) => b.health - a.health)
.slice(0, 5);
console.log("\n--- Top 5 ---");
for (const t of top) {
console.log(`${t.name.padEnd(20)} health=${String(t.health).padStart(4)} 话题=${t.topicCreated} 回复=${t.replySent} 赞=${t.likesReceived} 真人=${t.humanInteractions}`);
}
// Trends
const trendRaw = await prisma.botDailyStat.groupBy({
by: ["date"],
where: { date: { gte: sinceDate } },
_sum: { topicCreated: true, replySent: true, likesReceived: true, humanInteractions: true },
orderBy: { date: "asc" },
});
console.log("\n--- Trend (近14天) ---");
for (const t of trendRaw) {
console.log(`${t.date.toISOString().split("T")[0]} 话题=${t._sum.topicCreated} 回复=${t._sum.replySent} 赞=${t._sum.likesReceived}`);
}
await prisma.$disconnect();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+1 -1
View File
@@ -3,7 +3,7 @@ import "dotenv/config";
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
async function main() {
+1 -1
View File
@@ -3,7 +3,7 @@ import "dotenv/config";
const openai = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com/v1",
baseURL: "https://api.deepseek.com",
});
const REVIEW_PROMPT = `你是一个GitHub开源项目评测专家。请基于以下项目信息进行五维深度评测,并以JSON格式返回。务必只返回JSON,不要包含任何其他文字或Markdown格式。
-39
View File
@@ -1,39 +0,0 @@
import { Octokit } from "octokit";
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN || undefined,
});
(async () => {
console.log("🔍 搜索热门 AI 项目...");
const q = "ai agent stars:>1000 pushed:>2024-01-01";
const { data } = await octokit.rest.search.repos({
q,
sort: "stars",
order: "desc",
per_page: 3,
});
const repos = data.items;
console.log(`找到 ${repos.length} 个仓库:\n`);
for (const repo of repos) {
console.log(`- ${repo.full_name} (⭐ ${repo.stargazers_count})`);
}
if (repos.length > 0) {
const [owner, name] = repos[0].full_name.split("/");
try {
const { data: readmeData } = await octokit.rest.repos.getReadme({
owner,
repo: name,
});
const readme = Buffer.from(readmeData.content, "base64").toString("utf-8");
console.log(
`\n📖 ${repos[0].full_name} README 前200字符:\n${readme.substring(0, 200)}`
);
} catch {
console.log("\n📖 README 获取失败");
}
}
})();
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
curl -s 'https://www.zhuig.com/community' -o /tmp/c3.html
echo "HTML size: $(wc -c < /tmp/c3.html)"
echo "data-version=2026-06-03: $(grep -c 'data-version' /tmp/c3.html)"
echo "赚钱与副业(应该=0): $(grep -c '赚钱与副业' /tmp/c3.html)"
echo "电商零售(应该>0): $(grep -c '电商零售' /tmp/c3.html)"
echo "行业论坛: $(grep -c '行业论坛' /tmp/c3.html)"
echo "板块slug:"
grep -oE 'href="/community/[a-z-]+"' /tmp/c3.html | sort -u
-40
View File
@@ -1,40 +0,0 @@
import urllib.request, json, http.cookiejar, urllib.error
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
# Get CSRF token
csrf_resp = opener.open('http://localhost:8301/api/auth/csrf')
csrf = json.loads(csrf_resp.read())
print('CSRF token:', csrf['csrfToken'][:20] + '...')
# Login
data = urllib.parse.urlencode({
'csrfToken': csrf['csrfToken'],
'email': 'admin@zhuiguang.com',
'password': 'Admin123!'
}).encode()
req = urllib.request.Request(
'http://localhost:8301/api/auth/callback/credentials',
data=data,
method='POST'
)
try:
login_resp = opener.open(req)
print('Login status:', login_resp.status)
loc = login_resp.headers.get('location', 'none')
print('Location:', loc)
except urllib.error.HTTPError as e:
loc = e.headers.get('location', 'none')
print('Login redirect:', e.code, '->', loc)
if 'error=CredentialsSignin' in (loc or ''):
print('>>> ERROR: Invalid credentials!')
elif 'csrf=true' in (loc or ''):
print('>>> ERROR: CSRF token mismatch!')
# Session
sess_resp = opener.open('http://localhost:8301/api/auth/session')
sess = json.loads(sess_resp.read())
print('Session:', json.dumps(sess, indent=2))
-59
View File
@@ -1,59 +0,0 @@
import urllib.request, json, http.cookiejar, urllib.error, ssl
# Disable SSL verification for localhost testing
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cj),
urllib.request.HTTPSHandler(context=ctx)
)
# Get CSRF token
csrf_resp = opener.open('http://localhost:8301/api/auth/csrf')
csrf = json.loads(csrf_resp.read())
print('CSRF token:', csrf['csrfToken'][:20] + '...')
# Login (don't follow redirect)
data = urllib.parse.urlencode({
'csrfToken': csrf['csrfToken'],
'email': 'admin@zhuiguang.com',
'password': 'Admin123!'
}).encode()
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def http_error_302(self, req, fp, code, msg, headers):
return fp
no_redirect_opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cj),
NoRedirect
)
req = urllib.request.Request(
'http://localhost:8301/api/auth/callback/credentials',
data=data,
method='POST'
)
try:
resp = no_redirect_opener.open(req)
print('Status:', resp.status)
print('Location:', resp.headers.get('location', 'none'))
# Check cookies
for cookie in cj:
if 'session-token' in cookie.name or 'next-auth' in cookie.name:
print('Cookie:', cookie.name, '=', cookie.value[:30] + '...')
except urllib.error.HTTPError as e:
print('Error:', e.code, '->', e.headers.get('location', ''))
# Check session
sess_req = urllib.request.Request('http://localhost:8301/api/auth/session')
sess_resp = opener.open(sess_req)
sess = json.loads(sess_resp.read())
print('Session:', json.dumps(sess, indent=2))
if sess.get('user'):
print('>>> LOGIN SUCCESS! User:', sess['user'].get('email'))
-53
View File
@@ -1,53 +0,0 @@
import urllib.request, json, http.cookiejar
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
# Get CSRF token
csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read())
print('CSRF:', csrf['csrfToken'][:30])
# Dump cookies for debug
print('Cookies before login:')
for c in cj:
print(f' {c.name}={c.value[:30]}... domain={c.domain} path={c.path} secure={c.secure}')
# Login
data = urllib.parse.urlencode({
'csrfToken': csrf['csrfToken'],
'email': 'admin@zhuiguang.com',
'password': 'Admin123!'
}).encode()
print('Posting login with data:', data[:80])
req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST')
try:
resp = urllib.request.urlopen(req)
print('Status:', resp.status)
print('Location:', resp.headers.get('location'))
except urllib.error.HTTPError as e:
loc = e.headers.get('location', '')
print('Status:', e.code)
print('Location:', loc)
if 'error=CredentialsSignin' in loc:
print('FAIL: CredentialsSignin - check email/password')
elif 'csrf=true' in loc:
print('FAIL: CSRF token mismatch')
elif loc.startswith('https://www.zhuig.com'):
print('SUCCESS: Login redirect to dashboard')
print('\nCookies after login:')
for c in cj:
if 'next-auth' in c.name or 'session' in c.name:
print(f' {c.name}={c.value[:30]}...')
# Session
try:
sess = json.loads(opener.open('http://localhost:8301/api/auth/session').read())
if sess.get('user'):
print('SESSION OK - user:', sess['user'].get('email'))
else:
print('SESSION EMPTY')
except Exception as e:
print('Session error:', e)
-44
View File
@@ -1,44 +0,0 @@
import urllib.request, json, http.cookiejar
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
http_error_301 = http_error_302 = http_error_303 = http_error_307 = lambda self, req, fp, code, msg, headers: fp
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cj),
NoRedirectHandler
)
# Get CSRF
csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read())
print('CSRF:', csrf['csrfToken'][:30])
# Login
data = urllib.parse.urlencode({
'csrfToken': csrf['csrfToken'],
'email': 'admin@zhuiguang.com',
'password': 'Admin123!'
}).encode()
req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST')
resp = opener.open(req)
print('Status:', resp.status)
loc = resp.headers.get('location', 'NONE')
print('Location:', loc)
if 'error=CredentialsSignin' in (loc or ''):
print('>>> FAIL: Invalid credentials')
elif 'csrf=true' in (loc or ''):
print('>>> FAIL: CSRF token mismatch')
elif '/api/auth/signin' not in (loc or ''):
print('>>> SUCCESS: Login accepted, redirecting to caller page')
# Session
opener2 = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
sess = json.loads(opener2.open('http://localhost:8301/api/auth/session').read())
if sess.get('user'):
print('Session OK:', sess['user'].get('email'), '| role:', sess['user'].get('role'))
else:
print('Session EMPTY - user not logged in')
-41
View File
@@ -1,41 +0,0 @@
import urllib.request, json, http.cookiejar, http.client
# Enable debug to see request headers
http.client.HTTPConnection.debuglevel = 1
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
# Get CSRF
csrf = json.loads(opener.open('http://localhost:8301/api/auth/csrf').read())
print('\n=== Cookies after CSRF ===')
for c in cj:
print(f' {c.name}: val={c.value[:30]} domain={c.domain} path={c.path} secure={c.secure} httponly={c.has_nonstandard_attr("HttpOnly")}')
# Now login with debug to see if cookies are sent
print('\n=== Login POST headers ===')
data = urllib.parse.urlencode({
'csrfToken': csrf['csrfToken'],
'email': 'admin@zhuiguang.com',
'password': 'Admin123!'
}).encode()
req = urllib.request.Request('http://localhost:8301/api/auth/callback/credentials', data=data, method='POST')
# Check what cookies will be sent
print(f'Sending cookies:')
for c in cj:
print(f' {"Cookie: "}{c.name}={c.value[:30]}')
try:
resp = urllib.request.urlopen(req)
print(f'Status: {resp.status}')
print(f'Location: {resp.headers.get("location")}')
except urllib.error.HTTPError as e:
loc = e.headers.get('location', '')
print(f'Status: {e.code}')
print(f'Location: {loc}')
if 'csrf=true' in loc:
print('CSRF FAILED')
elif loc.startswith('https://www.zhuig.com'):
print('LOGIN SUCCESS - redirect to app')
+160
View File
@@ -0,0 +1,160 @@
/**
* 行业趋势监测脚本 — 每日 10:00 运行
* 从 HackerNews API 拉取热帖,筛选 AI 相关主题
*/
import "dotenv/config";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "..", "data", "trend-alerts");
const HN_TOP_STORIES = "https://hacker-news.firebaseio.com/v0/topstories.json";
const HN_ITEM = "https://hacker-news.firebaseio.com/v0/item";
// 主数据源:Algolia HN API(全球可达、单请求返回热帖含分数/评论,避免 firebaseio 从国内服务器超时)
const HN_ALGOLIA = "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=100";
// AI 相关关键词
const AI_KEYWORDS = [
"ai", "llm", "gpt", "machine learning", "deep learning",
"openai", "anthropic", "claude", "gemini", "copilot",
"agent", "prompt", "rag", "vector", "embedding",
"transformer", "LLM", "GPT", "AI", "chatgpt", "ChatGPT",
"diffusion", "stable diffusion", "langchain", "fine-tun",
"neural", "inference", "open source ai", "llama", "mistral",
];
function todayDateStr() {
return new Date().toISOString().slice(0, 10);
}
function log(message) {
const ts = new Date().toISOString();
console.log(`[${ts}] ${message}`);
}
async function fetchJson(url) {
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(12000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
lastErr = e;
if (attempt < 2) await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
}
}
throw lastErr;
}
function matchesAIKeywords(title) {
if (!title) return false;
const lowerTitle = title.toLowerCase();
for (const keyword of AI_KEYWORDS) {
if (lowerTitle.includes(keyword.toLowerCase())) {
return true;
}
}
return false;
}
async function main() {
log("行业趋势监测开始...");
// 确保数据目录存在
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// 主源:Algolia(单请求、全球可达);失败回退 firebaseio 两段式拉取
let items = [];
let source = "algolia";
try {
items = await fetchAlgoliaTop();
log(`[algolia] 获取到 ${items.length} 条热帖`);
} catch (e) {
source = "firebase";
log(`[algolia] 失败(${e.message}),回退 firebaseio...`);
items = await fetchFirebaseTop();
log(`[firebase] 获取到 ${items.length} 条热帖`);
}
// 筛选 AI 相关(字段已统一:title/url/score/commentCount/by/time)
const aiItems = items
.filter((item) => matchesAIKeywords(item.title))
.sort((a, b) => (b.score || 0) - (a.score || 0))
.slice(0, 5);
log(`筛选出 ${aiItems.length} 条 AI 相关热帖`);
const dateStr = todayDateStr();
const alertFile = path.join(DATA_DIR, `${dateStr}.json`);
const alert = {
date: dateStr,
generatedAt: new Date().toISOString(),
source,
items: aiItems,
summary: `从 ${items.length} 条热帖中筛选出 ${aiItems.length} 条 AI 相关话题`,
};
fs.writeFileSync(alertFile, JSON.stringify(alert, null, 2), "utf-8");
log(`趋势报告已保存: ${alertFile}`);
for (const item of aiItems) {
log(` 🏷 ${item.title} (${item.score} pts, ${item.commentCount} comments)`);
}
log("行业趋势监测完成");
}
async function fetchAlgoliaTop() {
const data = await fetchJson(HN_ALGOLIA);
return (data.hits || [])
.filter((h) => h.title)
.map((h) => ({
id: h.objectID,
title: h.title,
url: h.url || `https://news.ycombinator.com/item?id=${h.objectID}`,
score: h.points || 0,
commentCount: h.num_comments || 0,
by: h.author || "",
time: h.created_at || "",
}));
}
async function fetchFirebaseTop() {
const storyIds = await fetchJson(HN_TOP_STORIES);
log(`[firebase] 获取到 ${storyIds.length} 条热帖 ID`);
const top100Ids = storyIds.slice(0, 100);
const items = [];
for (let i = 0; i < top100Ids.length; i += 10) {
const batch = top100Ids.slice(i, i + 10);
const results = await Promise.all(
batch.map((id) => fetchJson(`${HN_ITEM}/${id}.json`).catch(() => null))
);
for (const item of results) {
if (!item) continue;
items.push({
id: item.id,
title: item.title,
url: item.url || `https://news.ycombinator.com/item?id=${item.id}`,
score: item.score || 0,
commentCount: item.descendants || 0,
by: item.by || "",
time: item.time ? new Date(item.time * 1000).toISOString() : "",
});
}
if (i + 10 < top100Ids.length) {
await new Promise((r) => setTimeout(r, 200));
}
}
return items;
}
main().catch((err) => {
console.error("行业趋势监测失败:", err);
process.exit(1);
});
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env node
/**
* update-knowledge.mjs — 开发完成后自动记录与迭代脚本
*
* 捕获三类信息并沉淀知识库(幂等,可重复运行):
* 1. 能力特征 → .trae/knowledge/capabilities.json (代码结构 / 依赖 / 任务 / 角色 全貌快照)
* 2. 开发过程 → .trae/knowledge/dev_process.jsonl (按提交追加,不覆盖历史)
* 3. 模型成果 → .trae/knowledge/artifacts.jsonl (本次交付物:迁移 / 脚本 / 提交统计)
* 并把上述事实增量 upsert 进知识图谱:
* - .trae/knowledge_graph.json (渲染版,权威)
* - .trae/knowledge_graph.jsonl (MCP Knowledge Graph Memory 读取的源文件)
*
* 知识目录解析顺序:
* 1. <repo>/.trae (所有 .trae 内容均在代码仓库内,跟着 git 走)
*
* 用法:
* node scripts/update-knowledge.mjs # 正常写入
* node scripts/update-knowledge.mjs --dry-run # 只预览,不写文件
* node scripts/update-knowledge.mjs --quiet # 静默(post-commit 钩子用)
*
* 设计约束:
* - 永不删除实体:同名实体整条替换(其 observations 是派生数据),未出现的旧实体原样保留
* - 任何扫描失败只告警不中断,最终仍写出文件
*/
import fs from "fs";
import path from "path";
import { execFileSync } from "child_process";
import { fileURLToPath } from "url";
import { createLogger } from "./lib/logger.mjs";
const log = createLogger("update-knowledge");
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DRY_RUN = process.argv.includes("--dry-run");
const QUIET = process.argv.includes("--quiet");
const PROJECT_NAME = "追光AI";
const CAP_ENTITY = "追光AI:能力快照";
const TODAY = new Date().toISOString().slice(0, 10);
const NOW = new Date().toISOString().slice(0, 19);
const DEV_RECORD_ENTITY = `开发记录:${TODAY}`;
/* ------------------------------------------------------------------ 路径 */
const TRAE_DIR = path.join(REPO_ROOT, ".trae");
const KNOW_DIR = path.join(TRAE_DIR, "knowledge");
const KG_JSON = path.join(TRAE_DIR, "knowledge_graph.json");
const KG_JSONL = path.join(TRAE_DIR, "knowledge_graph.jsonl");
const CAP_FILE = path.join(KNOW_DIR, "capabilities.json");
const PROC_FILE = path.join(KNOW_DIR, "dev_process.jsonl");
const ART_FILE = path.join(KNOW_DIR, "artifacts.jsonl");
/* ------------------------------------------------------------- 基础工具 */
function readJson(file, fallback = null) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch (err) {
if (fs.existsSync(file)) log.warn("读取 JSON 失败", { file, error: err.message });
return fallback;
}
}
function writeJson(file, data) {
if (DRY_RUN) return;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n", "utf8");
}
function appendJsonl(file, rows) {
if (DRY_RUN || rows.length === 0) return;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, rows.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf8");
}
function readJsonl(file) {
if (!fs.existsSync(file)) return [];
return fs
.readFileSync(file, "utf8")
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
try {
return JSON.parse(l);
} catch {
return null;
}
})
.filter(Boolean);
}
function walk(dir, filter) {
const out = [];
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full, filter));
else if (filter(full)) out.push(full);
}
return out;
}
function rel(p) {
return path.relative(REPO_ROOT, p).replace(/\\/g, "/");
}
function git(args) {
try {
return execFileSync("git", args, {
cwd: REPO_ROOT,
encoding: "utf8",
timeout: 15000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return "";
}
}
/* ------------------------------------------------------ 1. 能力特征扫描 */
function scanCapabilities() {
const apiRoutes = walk(path.join(REPO_ROOT, "src", "app", "api"), (f) => f.endsWith("route.ts"));
const pages = walk(path.join(REPO_ROOT, "src", "app"), (f) => f.endsWith("page.tsx"));
const components = walk(path.join(REPO_ROOT, "src", "components"), (f) => f.endsWith(".tsx"));
const hooks = walk(path.join(REPO_ROOT, "src", "hooks"), (f) => /\.(ts|tsx)$/.test(f));
const libFiles = walk(path.join(REPO_ROOT, "src", "lib"), (f) => /\.(ts|tsx)$/.test(f));
const scripts = walk(path.join(REPO_ROOT, "scripts"), (f) => /\.(mjs|sh)$/.test(f));
const migrations = fs.existsSync(path.join(REPO_ROOT, "prisma", "migrations"))
? fs
.readdirSync(path.join(REPO_ROOT, "prisma", "migrations"), { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort()
: [];
const schema = fs.existsSync(path.join(REPO_ROOT, "prisma", "schema.prisma"))
? fs.readFileSync(path.join(REPO_ROOT, "prisma", "schema.prisma"), "utf8")
: "";
const models = [...schema.matchAll(/^model\s+(\w+)/gm)].map((m) => m[1]);
const crontabPath = path.join(REPO_ROOT, "crontab.txt");
const crontabLines = fs.existsSync(crontabPath) ? fs.readFileSync(crontabPath, "utf8").split("\n") : [];
const activeCron = crontabLines.filter((l) => /^\s*\d/.test(l));
const activeCronKeys = activeCron
.map((l) => (l.match(/scripts\/([\w-]+)\.mjs/) || [])[1])
.filter(Boolean);
const botFile = path.join(REPO_ROOT, "data", "bot-characters.json");
const botData = readJson(botFile, null);
const botCharacters = Array.isArray(botData?.characters) ? botData.characters.map((c) => c.key) : [];
const pkg = readJson(path.join(REPO_ROOT, "package.json"), {}) || {};
return {
scanned_at: NOW,
version: pkg.version || "unknown",
api_routes: apiRoutes.length,
pages: pages.length,
components: components.length,
hooks: hooks.length,
lib_modules: libFiles.length,
scripts: scripts.length,
prisma_models: models.length,
prisma_model_names: models,
migrations: migrations.length,
latest_migrations: migrations.slice(-5),
cron_active_tasks: activeCron.length,
cron_active_scripts: activeCronKeys,
bot_characters: botCharacters,
tech_stack: {
dependencies: Object.keys(pkg.dependencies || {}).length,
dev_dependencies: Object.keys(pkg.devDependencies || {}).length,
next: (pkg.dependencies || {}).next || "-",
prisma: (pkg.dependencies || {}).prisma || "-",
react: (pkg.dependencies || {}).react || "-",
},
};
}
/* ------------------------------------------------------ 2. 开发过程提取 */
function collectDevProcess() {
const raw = git(["log", "-n", "5", "--pretty=format:%H%x1f%cI%x1f%s"]);
if (!raw) return [];
const rows = [];
for (const line of raw.split("\n")) {
const [hash, date, subject] = line.split("\x1f");
if (!hash) continue;
const files = git(["diff-tree", "--no-commit-id", "--name-only", "-r", "-M", hash])
.split("\n")
.filter(Boolean);
const shortstat = git(["show", "--shortstat", "--oneline", "--format=", hash]).trim();
rows.push({
ts: NOW,
kind: "commit",
hash: hash.slice(0, 8),
committed_at: date,
subject,
files_changed: files.length,
areas: [...new Set(files.map((f) => f.split("/").slice(0, 2).join("/")))].slice(0, 12),
shortstat,
});
}
return rows;
}
/* ------------------------------------------------------ 3. 模型成果捕获 */
function collectArtifacts(cap, commits) {
const head = commits[0] || {};
const artifacts = {
ts: NOW,
date: TODAY,
kind: "delivery",
head_commit: head.hash || "-",
head_subject: head.subject || "-",
files_changed: head.files_changed ?? 0,
shortstat: head.shortstat || "",
capabilities_delta: {
api_routes: cap.api_routes,
pages: cap.pages,
components: cap.components,
prisma_models: cap.prisma_models,
migrations: cap.migrations,
scripts: cap.scripts,
cron_active_tasks: cap.cron_active_tasks,
},
latest_migrations: cap.latest_migrations,
version: cap.version,
recorded_by: "scripts/update-knowledge.mjs",
};
return artifacts;
}
/* --------------------------------------------------- 知识图谱 upsert */
function buildEntities(cap, commits, artifacts) {
const capObs = [
`版本: ${cap.version}`,
`代码规模: API 路由 ${cap.api_routes} / 页面 ${cap.pages} / 组件 ${cap.components} / hooks ${cap.hooks} / lib ${cap.lib_modules}`,
`数据层: Prisma 模型 ${cap.prisma_models} 个、迁移 ${cap.migrations} 个(最新 ${cap.latest_migrations.join("、") || "无"})`,
`脚本: ${cap.scripts} 个(含定时任务与运维脚本)`,
`定时任务: 启用 ${cap.cron_active_tasks} 个 —— ${cap.cron_active_scripts.join("、")}`,
`数字人角色: ${cap.bot_characters.length} 个(${cap.bot_characters.join("、") || "无"})`,
`依赖: dependencies ${cap.tech_stack.dependencies} / devDependencies ${cap.tech_stack.dev_dependencies}(next ${cap.tech_stack.next}、prisma ${cap.tech_stack.prisma}、react ${cap.tech_stack.react})`,
`快照时间: ${cap.scanned_at}(由 scripts/update-knowledge.mjs 自动生成,勿手工编辑)`,
];
const devObs = [
`记录时间: ${NOW}`,
`本次交付: ${artifacts.head_commit} ${artifacts.head_subject}(改动文件 ${artifacts.files_changed} 个${artifacts.shortstat ? "、" + artifacts.shortstat : ""})`,
`能力基线: API ${cap.api_routes} / 页面 ${cap.pages} / 组件 ${cap.components} / 模型 ${cap.prisma_models} / 迁移 ${cap.migrations} / 脚本 ${cap.scripts} / 定时任务 ${cap.cron_active_tasks}`,
...commits.slice(0, 5).map((c) => `${c.hash} ${c.committed_at?.slice(0, 10) || "-"} ${c.subject}`),
"维护: 运行 npm run knowledge:update 刷新本记录",
];
return [
{ type: "entity", entityType: "Capability", name: CAP_ENTITY, observations: capObs },
{ type: "entity", entityType: "fact", name: DEV_RECORD_ENTITY, observations: devObs },
];
}
function upsertKnowledgeGraph(entities, relations) {
const kg = readJson(KG_JSON, null) || {
project: PROJECT_NAME,
version: "V2.1.2",
updated_at: NOW,
entities: [],
relations: [],
};
const fresh = new Map(entities.map((e) => [e.name, e]));
const kept = (kg.entities || []).filter((e) => e.name && !fresh.has(e.name));
const relSeen = new Set();
const mergedRelations = [];
for (const r of [...(kg.relations || []), ...relations]) {
if (!r?.from || !r?.to) continue;
const key = `${r.relationType}|${r.from}|${r.to}`;
if (relSeen.has(key)) continue;
relSeen.add(key);
mergedRelations.push(r);
}
const mergedEntities = [...kept, ...entities];
const nextKg = {
project: kg.project || PROJECT_NAME,
version: kg.version || "V2.1.2",
updated_at: NOW,
entities: mergedEntities,
relations: mergedRelations,
};
writeJson(KG_JSON, nextKg);
// 同步 JSONL(MCP 记忆服务读取的源文件):同名实体整条替换,其余原样保留
const oldLines = readJsonl(KG_JSONL);
const keptLines = oldLines.filter((o) => o.type === "entity" && o.name && !fresh.has(o.name));
const jsonlRows = [...keptLines, ...entities, ...mergedRelations];
if (!DRY_RUN) {
fs.mkdirSync(path.dirname(KG_JSONL), { recursive: true });
fs.writeFileSync(KG_JSONL, jsonlRows.map((o) => JSON.stringify(o)).join("\n") + "\n", "utf8");
}
return { entities: mergedEntities.length, relations: mergedRelations.length, jsonl: jsonlRows.length };
}
/* ------------------------------------------------------------------ main */
function main() {
const cap = scanCapabilities();
const commits = collectDevProcess();
const artifacts = collectArtifacts(cap, commits);
writeJson(CAP_FILE, cap);
// 模型成果:按 (日期 + head 提交) 去重,避免同日多次运行产生重复记录
const existingArtifacts = readJsonl(ART_FILE);
const alreadyRecorded = existingArtifacts.some(
(a) => a.date === artifacts.date && a.head_commit === artifacts.head_commit
);
if (!alreadyRecorded) appendJsonl(ART_FILE, [artifacts]);
// 开发过程:按提交 hash 去重(追加,不覆盖历史)
const existingProc = readJsonl(PROC_FILE);
const seenHashes = new Set(existingProc.map((r) => r.hash));
const newCommits = commits.filter((c) => !seenHashes.has(c.hash));
appendJsonl(PROC_FILE, newCommits);
const entities = buildEntities(cap, commits, artifacts);
const relations = [
{ type: "relation", relationType: "HAS_CAPABILITY", from: PROJECT_NAME, to: CAP_ENTITY },
{ type: "relation", relationType: "HAS_DEV_RECORD", from: PROJECT_NAME, to: DEV_RECORD_ENTITY },
];
const stats = upsertKnowledgeGraph(entities, relations);
if (!QUIET) {
log.info("知识库已更新", {
mode: DRY_RUN ? "dry-run" : "write",
trae_dir: TRAE_DIR,
capabilities: {
api_routes: cap.api_routes,
pages: cap.pages,
components: cap.components,
models: cap.prisma_models,
migrations: cap.migrations,
cron_active: cap.cron_active_tasks,
},
new_dev_records: newCommits.length,
artifacts_written: !alreadyRecorded,
graph: stats,
});
if (DRY_RUN) {
log.info("dry-run:未写入任何文件(含 capabilities/artifacts/knowledge_graph)");
}
}
}
try {
main();
} catch (err) {
log.error("更新知识库失败", { error: err.message, stack: err.stack });
process.exitCode = 1;
}
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
# =============================================================================
# 追光AI 部署验证脚本 (Docker 版)
# - 检查 Docker 容器状态
# - 验证 APP/API 端点响应
# - 检查 CRON 容器健康状态
# - 用法: bash scripts/verify-deploy-docker.sh
# =============================================================================
set -e
APP_PORT="${APP_PORT:-8301}"
APP_URL="http://127.0.0.1:${APP_PORT}"
PASS=0
FAIL=0
check() {
local label="$1"
shift
if "$@" >/dev/null 2>&1; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
echo "========================================"
echo " 追光AI 部署验证 ($(date '+%Y-%m-%d %H:%M:%S'))"
echo "========================================"
echo ""
# ---- 1) Docker 容器状态 ----
echo "=== 1) Docker 容器状态 ==="
docker compose ps 2>/dev/null || docker ps --filter "name=zhuiguang" --format "table {{.Names}}\t{{.Status}}"
echo ""
check "APP 容器运行中" docker compose ps app 2>/dev/null | grep -q "Up" || docker ps --filter "name=zhuiguang.*app" --filter "status=running" | grep -q .
check "CRON 容器运行中" docker compose ps cron 2>/dev/null | grep -q "Up" || docker ps --filter "name=zhuiguang.*cron" --filter "status=running" | grep -q .
# ---- 2) APP 健康检查 ----
echo ""
echo "=== 2) APP 端点验证 ==="
# 主页
HTTP_MAIN=$(curl -s -o /dev/null -m 5 -w "%{http_code}" "${APP_URL}/" 2>/dev/null || echo "000")
if [ "$HTTP_MAIN" = "200" ]; then
echo " ✅ 主页 (/) HTTP=$HTTP_MAIN"
PASS=$((PASS + 1))
else
echo " ❌ 主页 (/) HTTP=$HTTP_MAIN"
FAIL=$((FAIL + 1))
fi
# 关键 API 端点
for endpoint in \
"api/forum/categories:论坛分类" \
"api/tools:工具列表" \
"api/skills:技能列表" \
"api/admin/overview:管理概览" \
"api/rss:订阅源"
do
path="${endpoint%%:*}"
label="${endpoint##*:}"
code=$(curl -s -o /dev/null -m 5 -w "%{http_code}" "${APP_URL}/${path}" 2>/dev/null || echo "000")
if [ "$code" = "200" ]; then
echo " ✅ ${label} (/${path}) HTTP=$code"
PASS=$((PASS + 1))
else
echo " ❌ ${label} (/${path}) HTTP=$code"
FAIL=$((FAIL + 1))
fi
done
# ---- 3) 管理后台 API (需认证) ----
echo ""
echo "=== 3) 管理后台 API 状态 ==="
for endpoint in \
"api/admin/bot-quality:Bot质量" \
"api/admin/bots/stats?days=7:Bot统计"
do
path="${endpoint%%:*}"
label="${endpoint##*:}"
code=$(curl -s -o /dev/null -m 5 -w "%{http_code}" "${APP_URL}/${path}" 2>/dev/null || echo "000")
if [ "$code" = "200" ] || [ "$code" = "401" ]; then
echo " ✅ ${label} (/${path}) HTTP=$code"
PASS=$((PASS + 1))
else
echo " ❌ ${label} (/${path}) HTTP=$code"
FAIL=$((FAIL + 1))
fi
done
# ---- 4) 备份状态 ----
echo ""
echo "=== 4) 备份状态 ==="
BACKUP_DIR="/home/ubuntu/zhuiguang-ai-backup/dockers"
if [ -d "$BACKUP_DIR" ]; then
LATEST=$(ls -t "$BACKUP_DIR" 2>/dev/null | head -1)
if [ -n "$LATEST" ]; then
echo " ✅ 最新备份: $LATEST"
PASS=$((PASS + 1))
else
echo " ⚠️ 备份目录为空"
fi
else
echo " ⚠️ 备份目录不存在: $BACKUP_DIR"
fi
# ---- 汇总 ----
echo ""
echo "========================================"
echo " 结果: ${PASS} 通过 / ${FAIL} 失败"
if [ "$FAIL" -eq 0 ]; then
echo " 🟢 全部通过"
else
echo " 🔴 存在 ${FAIL} 项异常,请检查"
fi
echo "========================================"
exit $FAIL
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
echo "=== 1. pm2 进程状态 ==="
pm2 list
echo ""
echo "=== 2. 其他项目端口健康(不动的) ==="
for p in 8602 8720 8801 8848 9000 9848 7001; do
result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:$p/ 2>/dev/null)
echo " $p: HTTP=$result"
done
echo ""
echo "=== 3. 我们部署的项目 (zhuiguang-ai) ==="
result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:8301/)
echo " 8301 主页: HTTP=$result"
result=$(curl -s -o /dev/null -m 3 -w "%{http_code}" http://127.0.0.1:8301/api/forum/categories)
echo " 8301 API: HTTP=$result"
echo ""
echo "=== 4. 备份文件 ==="
ls -la /home/ubuntu/zhuiguang-ai/.backup-2026-06-03/
echo ""
echo "=== 5. API 返回的板块数 ==="
curl -s http://127.0.0.1:8301/api/forum/categories | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(' 顶层板块:', len(d['categories']))
print(' 子板块总数:', sum(len(c.get('children', [])) for c in d['categories']))
print(' 孙板块总数:', sum(len(gc.get('children', [])) for c in d['categories'] for gc in c.get('children', [])))
"
-25
View File
@@ -1,25 +0,0 @@
import re
import urllib.request
req = urllib.request.Request('https://www.zhuig.com/community', headers={'User-Agent': 'Mozilla/5.0', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache'})
html = urllib.request.urlopen(req, timeout=15).read().decode('utf-8')
# 找板块区域
idx_q = html.find('全部板块')
idx_z = html.find('最新话题')
section = html[idx_q:idx_z] if idx_z > 0 else html[idx_q:idx_q+15000]
# 统计 <a> 链接
a_count = len(re.findall(r'<a[^>]*href="/community/', section))
h3_count = len(re.findall(r'<h3[^>]*>[^<]+</h3>', section))
print(f'板块区域 <a> 链接数: {a_count}')
print(f'板块区域 <h3> 标签数: {h3_count}')
# 提取前10个
matches = list(re.finditer(r'<a[^>]*href="/community/([^"]+)"[^>]*>(.*?)</a>', section, re.DOTALL))[:15]
print('\n=== 板块区域前15个链接 ===')
for m in matches:
slug = m.group(1)
text = re.sub(r'<[^>]+>', ' ', m.group(2))
text = re.sub(r'\s+', ' ', text).strip()
print(f' /community/{slug:25s} | {text}')
+762
View File
@@ -0,0 +1,762 @@
/**
* weekly-recommend-email.mjs
* 每周一个性化推荐邮件
*
* 功能:
* 1. 分析每个用户的兴趣(收藏 + 浏览历史 + 社区活动)
* 2. 基于兴趣匹配推荐 AI 工具
* 3. 生成个性化 HTML 邮件
* 4. 输出到 data/emails/weekly-recommend-{date}.json
*
* 调度: 每周一上午 10:30 (crontab: 30 10 * * 1)
*/
import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import "dotenv/config";
import { withRetry } from "./lib/retry.mjs";
import { cacheGet, cacheSet, disconnectRedis } from "./lib/redis-cache.mjs";
// ─── Prisma 初始化 ────────────────────────────────────────────
const rawUrl = process.env.DATABASE_URL;
if (!rawUrl) {
console.error("DATABASE_URL not set");
process.exit(1);
}
const base = rawUrl.replace("mysql://", "mariadb://");
const sep = base.includes("?") ? "&" : "?";
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
const adapter = new PrismaMariaDb(connectionString);
const prisma = new PrismaClient({ adapter });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ─── 工具函数 ─────────────────────────────────────────────────
function getWeekNumber(d) {
const startOfYear = new Date(d.getFullYear(), 0, 1);
const diff = d - startOfYear;
const oneWeek = 7 * 24 * 60 * 60 * 1000;
return Math.ceil((diff / oneWeek + startOfYear.getDay() + 1) / 7);
}
function truncate(str, maxLen) {
if (!str) return "";
return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str;
}
function getBaseUrl() {
return process.env.NEXT_PUBLIC_BASE_URL || "https://zhuiguang.ai";
}
// ─── 兴趣分析 ─────────────────────────────────────────────────
/**
* 分析单个用户的兴趣偏好,返回按分数排序的类别列表。
*
* 数据来源:
* - Favorite 表(权重 3)→ 明确喜欢
* - BrowsingHistory 表(权重 1)→ 浏览过
* - ForumTopic 表(通过论坛板块名称与工具类别名称的语义匹配,权重 1)
*
* 返回格式: [{ categoryId: number, categoryName: string, categorySlug: string, score: number }]
*/
async function analyzeUserInterests(userId) {
const categoryScores = {};
function addScore(categoryId, categoryName, categorySlug, weight) {
if (!categoryId) return;
const key = categoryId;
if (!categoryScores[key]) {
categoryScores[key] = { categoryId, categoryName, categorySlug, score: 0, sources: new Set() };
}
categoryScores[key].score += weight;
}
// 1. 收藏的工具 → 类别映射(权重 3)
const favorites = await prisma.favorite.findMany({
where: { userId, toolId: { not: null } },
include: {
tool: {
select: {
categoryId: true,
category: { select: { id: true, name: true, slug: true } },
},
},
},
take: 200,
});
for (const fav of favorites) {
if (fav.tool?.category) {
addScore(fav.tool.category.id, fav.tool.category.name, fav.tool.category.slug, 3);
categoryScores[fav.tool.category.id].sources.add("favorite");
}
}
// 2. 浏览历史 → 类别映射(权重 1)
const history = await prisma.browsingHistory.findMany({
where: { userId, toolId: { not: null } },
include: {
tool: {
select: {
categoryId: true,
category: { select: { id: true, name: true, slug: true } },
},
},
},
take: 500,
});
for (const h of history) {
if (h.tool?.category) {
addScore(h.tool.category.id, h.tool.category.name, h.tool.category.slug, 1);
categoryScores[h.tool.category.id].sources.add("browsing");
}
}
// 3. 社区发帖 → 尝试通过论坛板块名称匹配工具类别
// ForumCategory 名称与 Category 名称可能有重叠,做模糊匹配
const forumTopics = await prisma.forumTopic.findMany({
where: { userId },
include: {
category: { select: { id: true, name: true, slug: true } },
},
take: 200,
});
// 收集所有工具类别,用于名称匹配
const allToolCategories = await prisma.category.findMany({
where: { parentId: null },
select: { id: true, name: true, slug: true },
});
const forumCategoryNames = new Set();
for (const ft of forumTopics) {
if (ft.category) {
forumCategoryNames.add(ft.category.name.toLowerCase());
}
}
// 简单的关键词匹配:论坛板块名包含工具类别名,或反之
for (const tc of allToolCategories) {
const tcLower = tc.name.toLowerCase();
for (const fcn of forumCategoryNames) {
if (fcn.includes(tcLower) || tcLower.includes(fcn)) {
addScore(tc.id, tc.name, tc.slug, 2);
categoryScores[tc.id].sources.add("forum");
break;
}
}
}
// 按分数降序排列
const sorted = Object.values(categoryScores)
.sort((a, b) => b.score - a.score)
.map(({ categoryId, categoryName, categorySlug, score, sources }) => ({
categoryId,
categoryName,
categorySlug,
score: Math.round(score),
sources: [...sources],
}));
return sorted;
}
// ─── 推荐引擎 ─────────────────────────────────────────────────
/**
* Section 1: "基于你的兴趣推荐"
* 从用户感兴趣的前 3 个类别中,挑选他们尚未收藏/浏览过的工具(最多 3 个)
*/
async function recommendFromInterests(userId, topCategories, excludeToolIds, limit = 3) {
if (topCategories.length === 0) return [];
const categoryIds = topCategories.slice(0, 3).map((c) => c.categoryId);
const tools = await prisma.tool.findMany({
where: {
categoryId: { in: categoryIds },
status: "published",
id: { notIn: excludeToolIds },
},
include: {
category: { select: { name: true, slug: true } },
},
orderBy: { viewCount: "desc" },
take: limit * 3, // fetch more to deduplicate across categories
});
// Deduplicate and pick distinct tools
const seen = new Set();
const result = [];
for (const t of tools) {
if (seen.has(t.id)) continue;
seen.add(t.id);
result.push(t);
if (result.length >= limit) break;
}
return result;
}
/**
* Section 2: "本周热门工具"
* 过去 30 天内发布的最热门工具(按 viewCount),排除用户已看过的
*/
async function recommendTrendingTools(excludeToolIds, limit = 3) {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const tools = await prisma.tool.findMany({
where: {
status: "published",
createdAt: { gte: thirtyDaysAgo },
id: { notIn: excludeToolIds },
},
include: {
category: { select: { name: true, slug: true } },
},
orderBy: { viewCount: "desc" },
take: limit,
});
return tools;
}
/**
* Section 3: "你可能错过的新工具"
* 用户感兴趣类别中最近 30 天新发布的工具(最多 2 个)
*/
async function recommendNewTools(userId, topCategories, excludeToolIds, limit = 2) {
if (topCategories.length === 0) return [];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const categoryIds = topCategories.slice(0, 3).map((c) => c.categoryId);
const tools = await prisma.tool.findMany({
where: {
categoryId: { in: categoryIds },
status: "published",
createdAt: { gte: thirtyDaysAgo },
id: { notIn: excludeToolIds },
},
include: {
category: { select: { name: true, slug: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
return tools;
}
// ─── 获取用户已交互的工具 ID 集合 ────────────────────────────
async function getUserInteractedToolIds(userId) {
const [favs, history] = await Promise.all([
prisma.favorite.findMany({
where: { userId, toolId: { not: null } },
select: { toolId: true },
}),
prisma.browsingHistory.findMany({
where: { userId, toolId: { not: null } },
select: { toolId: true },
}),
]);
return new Set([
...favs.map((f) => f.toolId).filter(Boolean),
...history.map((h) => h.toolId).filter(Boolean),
]);
}
// ─── HTML 邮件模板 ────────────────────────────────────────────
/**
* 生成工具卡片的 HTML 片段
*/
function renderToolCard(tool, baseUrl) {
const logoSrc = tool.logoUrl || `${baseUrl}/default-tool-logo.png`;
const detailUrl = `${baseUrl}/tools/${tool.slug}`;
const desc = truncate(tool.description, 80);
return `
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background-color:#ffffff;">
<tr>
<td style="padding:16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td width="48" style="vertical-align:top;padding-right:12px;">
<img src="${logoSrc}" width="48" height="48" alt="${tool.name}" style="border-radius:8px;display:block;object-fit:cover;background:#f3f4f6;" />
</td>
<td style="vertical-align:top;">
<p style="margin:0 0 4px 0;font-size:16px;font-weight:600;color:#111827;">
<a href="${detailUrl}" style="color:#111827;text-decoration:none;">${tool.name}</a>
</p>
<p style="margin:0 0 6px 0;font-size:13px;color:#6b7280;">${tool.category?.name || ""}</p>
<p style="margin:0 0 10px 0;font-size:14px;color:#374151;line-height:1.5;">${desc}</p>
<a href="${detailUrl}" style="display:inline-block;padding:6px 16px;font-size:13px;color:#ffffff;background-color:#3b82f6;border-radius:6px;text-decoration:none;">查看详情</a>
</td>
</tr>
</table>
</td>
</tr>
</table>`;
}
/**
* 生成完整 HTML 邮件
*/
function renderEmailHtml({ userName, weekNumber, dateStr, interestCategories, interestTools, trendingTools, newTools, unsubscribeUrl, baseUrl }) {
const displayName = userName || "AI 探索者";
const sortedCategories = (interestCategories || []).slice(0, 5);
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>追光AI 周报 | 为你精选的 AI 工具</title>
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans SC',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f3f4f6;">
<tr>
<td align="center" style="padding:24px 16px;">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background:linear-gradient(135deg,#3b82f6,#8b5cf6);padding:32px 24px;text-align:center;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.8);">追光AI · 第 ${weekNumber} 周</p>
<h1 style="margin:8px 0 0 0;font-size:24px;color:#ffffff;font-weight:700;">为你精选的 AI 工具</h1>
<p style="margin:8px 0 0 0;font-size:14px;color:rgba(255,255,255,0.9);">${dateStr}</p>
</td>
</tr>
<!-- Greeting -->
<tr>
<td style="padding:24px;">
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
${displayName},你好!
</p>
<p style="margin:8px 0 0 0;font-size:14px;color:#6b7280;line-height:1.6;">
根据你的收藏和浏览历史,我们为你精心挑选了以下 AI 工具。以下是本周的个性化推荐:
</p>
<!-- Interest Summary -->
${sortedCategories.length > 0 ? `
<p style="margin:16px 0 0 0;font-size:13px;color:#9ca3af;">
你的兴趣偏好:${sortedCategories.map((c) => c.categoryName).join("、")}
</p>
` : ""}
</td>
</tr>
<!-- Section 1: 基于你的兴趣推荐 -->
<tr>
<td style="padding:0 24px 8px 24px;">
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #3b82f6;padding-left:12px;">
基于你的兴趣推荐
</h2>
</td>
</tr>
<tr>
<td style="padding:8px 24px 16px 24px;">
${interestTools.length > 0
? interestTools.map((t) => renderToolCard(t, baseUrl)).join("")
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无匹配的推荐工具</p>`}
</td>
</tr>
<!-- Section 2: 本周热门工具 -->
<tr>
<td style="padding:0 24px 8px 24px;">
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #f59e0b;padding-left:12px;">
本周热门工具
</h2>
</td>
</tr>
<tr>
<td style="padding:8px 24px 16px 24px;">
${trendingTools.length > 0
? trendingTools.map((t) => renderToolCard(t, baseUrl)).join("")
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无热门工具</p>`}
</td>
</tr>
<!-- Section 3: 你可能错过的新工具 -->
<tr>
<td style="padding:0 24px 8px 24px;">
<h2 style="margin:0;font-size:18px;color:#111827;border-left:4px solid #10b981;padding-left:12px;">
你可能错过的新工具
</h2>
</td>
</tr>
<tr>
<td style="padding:8px 24px 16px 24px;">
${newTools.length > 0
? newTools.map((t) => renderToolCard(t, baseUrl)).join("")
: `<p style="margin:0;font-size:14px;color:#9ca3af;">暂无新工具</p>`}
</td>
</tr>
<!-- CTA -->
<tr>
<td style="padding:16px 24px;text-align:center;">
<a href="${baseUrl}/tools" style="display:inline-block;padding:12px 32px;font-size:15px;color:#ffffff;background-color:#3b82f6;border-radius:8px;text-decoration:none;font-weight:600;">
探索更多 AI 工具
</a>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding:0 24px;">
<div style="border-top:1px solid #e5e7eb;"></div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:20px 24px 32px 24px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.8;">
此邮件由追光AI自动生成<br>
如有疑问,请访问 <a href="${baseUrl}" style="color:#3b82f6;">${baseUrl.replace("https://", "")}</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
/**
* 生成纯文本邮件内容
*/
function renderTextContent({ userName, interestTools, trendingTools, newTools, baseUrl }) {
const displayName = userName || "AI 探索者";
let text = [
`追光AI 周报 | 为你精选的 AI 工具`,
``,
`${displayName},你好!`,
``,
`根据你的收藏和浏览历史,我们为你精心挑选了以下 AI 工具:`,
``,
];
if (interestTools.length > 0) {
text.push(`【基于你的兴趣推荐】`);
interestTools.forEach((t, i) => {
text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`);
text.push(` 查看: ${baseUrl}/tools/${t.slug}`);
});
text.push(``);
}
if (trendingTools.length > 0) {
text.push(`【本周热门工具】`);
trendingTools.forEach((t, i) => {
text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`);
text.push(` 查看: ${baseUrl}/tools/${t.slug}`);
});
text.push(``);
}
if (newTools.length > 0) {
text.push(`【你可能错过的新工具】`);
newTools.forEach((t, i) => {
text.push(` ${i + 1}. ${t.name} - ${truncate(t.description, 60)}`);
text.push(` 查看: ${baseUrl}/tools/${t.slug}`);
});
text.push(``);
}
text.push(`探索更多 AI 工具: ${baseUrl}/tools`);
text.push(``);
text.push(`此邮件由追光AI自动生成`);
return text.join("\n");
}
// ─── 主流程 ───────────────────────────────────────────────────
async function generateRecommendationsForUser(user) {
const userId = user.id;
// 1. 分析兴趣
let interestCategories = [];
try {
interestCategories = await analyzeUserInterests(userId);
} catch (err) {
console.warn(` ⚠ 用户 ${user.email} 兴趣分析失败: ${err.message}`);
interestCategories = [];
}
// 2. 获取已交互的工具 ID(用于排除)
const excludeIds = await getUserInteractedToolIds(userId);
const excludeIdArray = [...excludeIds].filter(Boolean);
// 3. Section 1: 基于兴趣推荐
let interestTools = [];
if (interestCategories.length > 0) {
interestTools = await recommendFromInterests(userId, interestCategories, excludeIdArray, 3);
}
// 如果兴趣推荐不够 3 个,用热门工具补充
if (interestTools.length < 3) {
const existingIds = new Set([...excludeIdArray, ...interestTools.map((t) => t.id)]);
const fillTools = await prisma.tool.findMany({
where: {
status: "published",
id: { notIn: [...existingIds] },
},
include: { category: { select: { name: true, slug: true } } },
orderBy: { viewCount: "desc" },
take: 3 - interestTools.length,
});
interestTools = [...interestTools, ...fillTools];
}
// 4. Section 2: 本周热门
const trendingIds = new Set([...excludeIdArray, ...interestTools.map((t) => t.id)]);
const trendingTools = await recommendTrendingTools([...trendingIds], 3);
// 5. Section 3: 新工具
const newIds = new Set([...trendingIds, ...trendingTools.map((t) => t.id)]);
const newTools = await recommendNewTools(userId, interestCategories, [...newIds], 2);
return {
interestCategories: interestCategories.slice(0, 5),
interestTools,
trendingTools,
newTools,
};
}
async function main() {
if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL");
const startTime = new Date();
const now = new Date();
const weekNumber = getWeekNumber(now);
const dateStr = now.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "Asia/Shanghai",
});
const dateISO = now.toISOString().split("T")[0];
const baseUrl = getBaseUrl();
const unsubscribeUrl = `${baseUrl}/settings/notifications`;
console.log(`📧 开始生成第 ${weekNumber} 周个性化推荐邮件 (${dateStr})...`);
console.log();
// 1. 查询目标用户:非 Bot、有邮箱、有活动(收藏/浏览/社区帖子)
console.log("👥 第一步:查询目标用户...");
const usersWithActivity = await prisma.user.findMany({
where: {
isBot: false,
email: { not: null, not: "" },
OR: [
{ favorites: { some: {} } },
{ browsingHistory: { some: {} } },
{ forumTopics: { some: {} } },
],
},
select: {
id: true,
email: true,
name: true,
},
});
console.log(` 找到 ${usersWithActivity.length} 位活跃用户`);
console.log();
if (usersWithActivity.length === 0) {
console.log("⚠️ 没有符合条件的用户,跳过推荐生成");
await prisma.taskLog.create({
data: {
taskKey: "weekly-recommend-email",
taskName: "每周推荐邮件",
status: "completed",
startedAt: startTime,
finishedAt: new Date(),
duration: new Date().getTime() - startTime.getTime(),
result: { userCount: 0, emailCount: 0 },
},
});
return;
}
// 2. 为每个用户生成推荐
console.log("🔍 第二步:为每个用户生成个性化推荐...");
const emailEntries = [];
let successCount = 0;
let skipCount = 0;
for (let i = 0; i < usersWithActivity.length; i++) {
const user = usersWithActivity[i];
const progress = `[${i + 1}/${usersWithActivity.length}]`;
console.log(` ${progress} 处理用户 ${user.email}...`);
try {
const recs = await generateRecommendationsForUser(user);
// 跳过没有任何推荐内容的用户
if (
recs.interestTools.length === 0 &&
recs.trendingTools.length === 0 &&
recs.newTools.length === 0
) {
console.log(` ⚠ 无可用推荐,跳过`);
skipCount++;
continue;
}
const htmlContent = renderEmailHtml({
userName: user.name,
weekNumber,
dateStr,
interestCategories: recs.interestCategories,
interestTools: recs.interestTools,
trendingTools: recs.trendingTools,
newTools: recs.newTools,
unsubscribeUrl,
baseUrl,
});
const textContent = renderTextContent({
userName: user.name,
interestTools: recs.interestTools,
trendingTools: recs.trendingTools,
newTools: recs.newTools,
baseUrl,
});
emailEntries.push({
userId: user.id,
email: user.email,
name: user.name,
subject: `追光AI 周报 | 为你精选的 AI 工具`,
htmlContent,
textContent,
interestCategories: recs.interestCategories.map((c) => ({
name: c.categoryName,
score: c.score,
sources: c.sources,
})),
sections: {
interest: recs.interestTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
trending: recs.trendingTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
new: recs.newTools.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
},
});
const sectionCounts = [
recs.interestTools.length > 0 ? `兴趣${recs.interestTools.length}` : "",
recs.trendingTools.length > 0 ? `热门${recs.trendingTools.length}` : "",
recs.newTools.length > 0 ? `新工具${recs.newTools.length}` : "",
].filter(Boolean).join("+");
console.log(` ✅ 推荐: ${sectionCounts} | 兴趣: ${recs.interestCategories.slice(0, 3).map((c) => c.categoryName).join(", ") || "无"}`);
successCount++;
} catch (err) {
console.error(` ❌ 处理失败: ${err.message}`);
skipCount++;
}
}
console.log();
console.log(` ✅ 成功生成 ${successCount} 封推荐邮件 (跳过 ${skipCount} 人)`);
// 3. 输出到 JSON 文件
console.log();
console.log("💾 第三步:写入 JSON 文件...");
const outputDir = path.resolve(__dirname, "..", "data", "emails");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputFile = path.join(outputDir, `weekly-recommend-${dateISO}.json`);
const outputData = {
generatedAt: startTime.toISOString(),
weekNumber,
dateStr,
dateISO,
totalUsers: usersWithActivity.length,
successCount,
skipCount,
entries: emailEntries,
};
fs.writeFileSync(outputFile, JSON.stringify(outputData, null, 2), "utf-8");
console.log(` 文件: ${outputFile}`);
console.log(` 大小: ${(fs.statSync(outputFile).size / 1024).toFixed(1)} KB`);
// 4. 记录 TaskLog
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
await prisma.taskLog.create({
data: {
taskKey: "weekly-recommend-email",
taskName: "每周推荐邮件",
status: "completed",
startedAt: startTime,
finishedAt: endTime,
duration,
result: {
userCount: usersWithActivity.length,
emailCount: successCount,
skipCount,
outputFile: `data/emails/weekly-recommend-${dateISO}.json`,
weekNumber,
},
},
});
console.log();
console.log(`🎉 每周推荐邮件生成完成!`);
console.log(` 周数: 第 ${weekNumber} 周`);
console.log(` 用户数: ${usersWithActivity.length}`);
console.log(` 成功: ${successCount}`);
console.log(` 跳过: ${skipCount}`);
console.log(` 耗时: ${(duration / 1000).toFixed(1)}s`);
return { userCount: usersWithActivity.length, successCount, skipCount };
}
// ─── Entry ─────────────────────────────────────────────────────
main()
.then(() => {
console.log("Done");
process.exit(0);
})
.catch(async (error) => {
console.error("❌ 每周推荐邮件生成失败:", error.message);
try {
await prisma.taskLog.create({
data: {
taskKey: "weekly-recommend-email",
taskName: "每周推荐邮件",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: error.message,
},
});
} catch {}
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await disconnectRedis();
});