diff --git a/write-server/diagnose-comment-notify.sh b/write-server/diagnose-comment-notify.sh new file mode 100644 index 00000000..446a94f7 --- /dev/null +++ b/write-server/diagnose-comment-notify.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# 诊断评论通知问题 + +echo "" +echo "===================================" +echo " 评论通知诊断" +echo "===================================" +echo "" + +# 读取配置 +if [ ! -f ".env" ]; then + echo "错误: .env 文件不存在" + exit 1 +fi + +ARTALK_SERVER=$(grep "^ARTALK_SERVER=" .env | cut -d'=' -f2) +ARTALK_SITE=$(grep "^ARTALK_SITE=" .env | cut -d'=' -f2) +ARTALK_ADMIN_EMAIL=$(grep "^ARTALK_ADMIN_EMAIL=" .env | cut -d'=' -f2) +ARTALK_ADMIN_PASSWORD=$(grep "^ARTALK_ADMIN_PASSWORD=" .env | cut -d'=' -f2) + +echo "1. 配置信息" +echo " 服务器: $ARTALK_SERVER" +echo " 站点: $ARTALK_SITE" +echo "" + +# 登录 +echo "2. 登录 Artalk..." +TOKEN=$(curl -s -X POST "$ARTALK_SERVER/api/v2/auth/email/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$ARTALK_ADMIN_EMAIL\",\"password\":\"$ARTALK_ADMIN_PASSWORD\"}" | \ + grep -o '"token":"[^"]*"' | cut -d'"' -f4) + +if [ -z "$TOKEN" ]; then + echo " ✗ 登录失败" + exit 1 +fi +echo " ✓ 登录成功" +echo "" + +# 获取最新评论 +echo "3. 获取最新 10 条评论..." +RESPONSE=$(curl -s "$ARTALK_SERVER/api/v2/comments?site_name=$ARTALK_SITE&page_key=/&limit=10&sort_by=date&sort_order=desc" \ + -H "Authorization: Bearer $TOKEN") + +COUNT=$(echo "$RESPONSE" | grep -o '"count":[0-9]*' | cut -d':' -f2) +echo " 首页评论数: $COUNT" + +if [ "$COUNT" -gt 0 ]; then + echo " 最新评论:" + echo "$RESPONSE" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for c in data.get('comments', [])[:3]: + print(f' ID {c[\"id\"]}: {c[\"nick\"]} - {c[\"content\"][:40]}...') +" 2>/dev/null || echo " (需要 python3 来解析 JSON)" +fi +echo "" + +# 获取最新评论(所有页面) +echo "4. 获取所有页面最新评论(前 5 条)..." +ALL_COMMENTS="[]" + +# 获取前 5 个页面的评论 +for page_key in "/" "/20260623235948.html" "/202606231801.html" "/20211101.html" "/20210903.html"; do + RESPONSE=$(curl -s "$ARTALK_SERVER/api/v2/comments?site_name=$ARTALK_SITE&page_key=$page_key&limit=2&sort_by=date&sort_order=desc" \ + -H "Authorization: Bearer $TOKEN") + COUNT=$(echo "$RESPONSE" | grep -o '"count":[0-9]*' | cut -d':' -f2) + if [ "$COUNT" -gt 0 ]; then + echo " ✓ $page_key: $COUNT 条评论" + fi +done +echo "" + +# 检查通知日志 +echo "5. 最近的通知日志..." +docker compose logs --tail 20 2>&1 | grep -E "\[comment-notify\]|fetched.*comments" | tail -5 +echo "" + +echo "===================================" +echo " 诊断完成" +echo "===================================" +echo "" +echo "如果评论存在但没有通知,可能原因:" +echo "1. 评论的文章不在最新 20 个页面中" +echo "2. 启动时已记录最新 ID,新评论 ID 必须更大" +echo "3. Artalk API 返回的评论顺序问题" +echo "" diff --git a/write-server/src/lib/bot/blog-monitor.ts b/write-server/src/lib/bot/blog-monitor.ts new file mode 100644 index 00000000..5ee78d6b --- /dev/null +++ b/write-server/src/lib/bot/blog-monitor.ts @@ -0,0 +1,263 @@ +import { sendMessage } from "./tg"; +import { ALLOWED_IDS } from "./config"; +import { getFeedsHealth } from "@/lib/rssapi"; +import { ARTALK_SERVER, ARTALK_SITE, ARTALK_ADMIN_EMAIL, ARTALK_ADMIN_PASSWORD } from "@/lib/config"; +import fs from "fs"; +import path from "path"; + +// 博客创建时间(2024-07-11) +const BLOG_CREATED_DATE = new Date("2024-07-11"); + +// 里程碑配置 +const MILESTONES = { + posts: [100, 150, 200, 250, 300, 500], + comments: [100, 500, 1000, 2000, 5000], +}; + +// 记录已通知的里程碑 +const notifiedMilestones = new Set(); + +// 获取 Artalk 管理员 token +async function getArtalkToken(): Promise { + if (!ARTALK_ADMIN_EMAIL || !ARTALK_ADMIN_PASSWORD) { + return null; + } + + try { + const res = await fetch(`${ARTALK_SERVER}/api/v2/auth/email/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: ARTALK_ADMIN_EMAIL, password: ARTALK_ADMIN_PASSWORD }), + }); + + if (!res.ok) return null; + const data = await res.json() as { token?: string }; + return data.token || null; + } catch { + return null; + } +} + +// 发送通知给所有允许的用户 +async function notifyAll(message: string): Promise { + const targets = ALLOWED_IDS.length > 0 ? ALLOWED_IDS : []; + for (const chatId of targets) { + try { + await sendMessage(chatId, message, { parse_mode: "HTML" }); + } catch (e) { + console.error("[blog-monitor] send notification failed:", e instanceof Error ? e.message : String(e)); + } + } +} + +// ============================================================ +// 功能 1: 友链状态监控 +// ============================================================ + +export async function checkLinksStatus(): Promise { + console.log("[blog-monitor] 🔗 checking links status..."); + + try { + // 直接调用 API 的健康检查端点 + const healthResult = await getFeedsHealth(); + + if (!healthResult || !healthResult.results) { + console.log("[blog-monitor] ❌ failed to fetch health status"); + return; + } + + const { total, alive, dead, results } = healthResult; + + // 分类链接状态 + const invalidLinks = results.filter((r) => !r.ok); + const slowLinks = results.filter((r) => r.ok && r.latency && r.latency > 3000); + + console.log(`[blog-monitor] ✅ links check completed: ${alive} valid, ${dead} invalid, ${slowLinks.length} slow`); + + // 只在有失效或缓慢链接时发送通知 + if (invalidLinks.length > 0 || slowLinks.length > 0) { + const msg = [ + `🔗 友链状态监控报告`, + ``, + `📊 总体情况:`, + `├─ 📁 总数:${total} 个`, + `├─ ✅ 有效:${alive} 个`, + `├─ ❌ 失效:${dead} 个`, + `└─ ⚠️ 缓慢:${slowLinks.length} 个(> 3 秒)`, + ``, + ]; + + if (invalidLinks.length > 0) { + msg.push(`🚨 失效链接:`); + invalidLinks.slice(0, 5).forEach((link, index) => { + const error = link.error || `HTTP ${link.status}`; + const prefix = index === invalidLinks.length - 1 || index === 4 ? "└─" : "├─"; + msg.push(`${prefix} 🔴 ${link.url}`); + msg.push(` └─ ${error}`); + }); + if (invalidLinks.length > 5) { + msg.push(` ⋯ 还有 ${invalidLinks.length - 5} 个`); + } + msg.push(``); + } + + if (slowLinks.length > 0) { + msg.push(`🐢 响应缓慢:`); + slowLinks.slice(0, 3).forEach((link, index) => { + const latency = ((link.latency || 0) / 1000).toFixed(1); + const prefix = index === slowLinks.length - 1 || index === 2 ? "└─" : "├─"; + msg.push(`${prefix} 🟡 ${link.url}`); + msg.push(` └─ ${latency}秒`); + }); + msg.push(``); + } + + msg.push(`💡 建议:尽快检查并修复失效链接,保持友链健康`); + + await notifyAll(msg.join("\n")); + console.log("[blog-monitor] 📤 links notification sent"); + } else { + console.log("[blog-monitor] 🎉 all links healthy"); + } + } catch (e) { + console.error("[blog-monitor] ❌ check links failed:", e instanceof Error ? e.message : String(e)); + } +} + +// ============================================================ +// 功能 2: 博客里程碑通知 +// ============================================================ + +export async function checkBlogMilestones(): Promise { + console.log("[blog-monitor] 🎯 checking blog milestones..."); + + try { + // 获取文章数量(通过读取 content/posts 目录) + const postsDir = path.join(process.cwd(), "..", "content", "posts"); + let postsCount = 0; + + if (fs.existsSync(postsDir)) { + const years = fs.readdirSync(postsDir).filter((f) => /^\d{4}$/.test(f)); + for (const year of years) { + const yearDir = path.join(postsDir, year); + if (fs.statSync(yearDir).isDirectory()) { + const posts = fs.readdirSync(yearDir).filter((f) => { + const fullPath = path.join(yearDir, f); + return fs.statSync(fullPath).isDirectory(); + }); + postsCount += posts.length; + } + } + } + + // 获取评论数量 + let commentsCount = 0; + const token = await getArtalkToken(); + + if (token) { + try { + const res = await fetch( + `${ARTALK_SERVER}/api/v2/comments?site_name=${encodeURIComponent(ARTALK_SITE)}&limit=1`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (res.ok) { + const data = await res.json() as { count?: number }; + commentsCount = data.count || 0; + } + } catch { + console.error("[blog-monitor] ❌ failed to fetch comments count"); + } + } + + console.log(`[blog-monitor] 📊 stats: ${postsCount} posts, ${commentsCount} comments`); + + // 检查文章里程碑 + for (const milestone of MILESTONES.posts) { + const key = `posts-${milestone}`; + if (postsCount >= milestone && !notifiedMilestones.has(key)) { + notifiedMilestones.add(key); + const msg = [ + `🎉🎊🥳 博客里程碑达成! 🥳🎊🎉`, + ``, + `📝 文章里程碑:`, + `├─ 📰 当前文章:${postsCount} 篇`, + `├─ 🎯 达成目标:${milestone} 篇`, + `└─ ⭐ 成就解锁!`, + ``, + `🏆 恭喜!这是一个了不起的成就!`, + ``, + `💡 建议:回顾一下最受欢迎的文章,继续保持创作热情!`, + ``, + `📈 继续加油,下一个目标等着你!`, + ]; + await notifyAll(msg.join("\n")); + console.log(`[blog-monitor] 🏆 milestone notification sent: ${milestone} posts`); + } + } + + // 检查评论里程碑 + for (const milestone of MILESTONES.comments) { + const key = `comments-${milestone}`; + if (commentsCount >= milestone && !notifiedMilestones.has(key)) { + notifiedMilestones.add(key); + const msg = [ + `💬🎉🎊 评论里程碑达成! 🎊🎉💬`, + ``, + `💬 评论里程碑:`, + `├─ 💭 当前评论:${commentsCount} 条`, + `├─ 🎯 达成目标:${milestone} 条`, + `└─ ⭐ 成就解锁!`, + ``, + `🏆 恭喜!读者互动非常活跃!`, + ``, + `💡 建议:感谢一下活跃的评论者,继续维护社区氛围!`, + ``, + `🤝 感谢每一位参与互动的读者!`, + ]; + await notifyAll(msg.join("\n")); + console.log(`[blog-monitor] 🏆 milestone notification sent: ${milestone} comments`); + } + } + } catch (e) { + console.error("[blog-monitor] ❌ check milestones failed:", e instanceof Error ? e.message : String(e)); + } +} + +// ============================================================ +// 启动所有监控任务 +// ============================================================ + +let monitorStarted = false; + +export function startBlogMonitor(): void { + if (monitorStarted) return; + monitorStarted = true; + + console.log("[blog-monitor] 🚀 starting blog monitor..."); + + // 启动时立即检查一次(延迟 30 秒,等待系统初始化) + setTimeout(async () => { + console.log("[blog-monitor] ⏰ running initial checks..."); + + // 并行执行所有检查 + await Promise.allSettled([ + checkLinksStatus(), + checkBlogMilestones(), + ]); + + console.log("[blog-monitor] ✅ initial checks completed"); + }, 30_000); + + // 定时任务: + + // 友链检查:每 6 小时 + setInterval(checkLinksStatus, 6 * 60 * 60 * 1000); + + // 里程碑检查:每 12 小时 + setInterval(checkBlogMilestones, 12 * 60 * 60 * 1000); + + console.log("[blog-monitor] 📅 monitor started with schedules:"); + console.log(" 🔗 Links: every 6 hours"); + console.log(" 🎯 Milestones: every 12 hours"); +} diff --git a/write-server/src/lib/bot/index.ts b/write-server/src/lib/bot/index.ts index a5b2de2e..bb16b8ab 100644 --- a/write-server/src/lib/bot/index.ts +++ b/write-server/src/lib/bot/index.ts @@ -1,5 +1,6 @@ import { startPolling } from "./poll"; import { startCommentNotifier } from "./notify"; +import { startBlogMonitor } from "./blog-monitor"; import { TG_TOKEN } from "./config"; const g = globalThis as Record; @@ -16,6 +17,7 @@ export async function startBot(): Promise { console.log("[bot] initializing..."); await startPolling(); startCommentNotifier(); + startBlogMonitor(); } export { TG_TOKEN } from "./config";