/** * 群消息日报 — AI 分析版 * * 用法: cd backend && npx tsx scripts/group-daily-report.ts [YYYY-MM-DD] * 输出: backend/scripts/group-report-{YYYY-MM-DD}.html * * 分析流程: * 1. 查询当天所有文本消息,按群分组 * 2. 每个有消息的群 → 发送全部消息到 DeepSeek AI 分析 * 3. AI 判定: 风险程度 / 客户情绪 / 主要话题 / 异常情况 * 4. AI 不可用时降级为关键词规则引擎 * 5. 生成 HTML 报告 */ import 'dotenv/config'; import Parse from '../src/db/parse-client.js'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /* ======================== AI 配置 ======================== */ const AI_KEY = process.env.DEEPSEEK_API_KEY || ''; const AI_URL = process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions'; const AI_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat'; const AI_CONCURRENCY = 3; // 并发数 const AI_ENABLED = AI_KEY && AI_KEY !== 'sk-xxx'; const AI_SYSTEM_PROMPT = `你是一家全屋定制公司(拉迷家居)的客户群消息分析员。请分析以下群聊消息,判断该群当前状态。 风险类型参考(全屋定制行业): - 投诉抱怨:质量、工期、安装、服务态度等 - 竞品对比:提及欧派、索菲亚、尚品宅配等竞品 - 价格敏感:太贵、砍价、退款、预算超支等 - 负面情绪:不满、威胁、换商家、维权、起诉等 - 敏感话题:甲醛超标、欺诈、烂尾、跑路等 判断标准: - high: 明确投诉/维权/法律威胁,可能升级为纠纷 - medium: 负面情绪或竞品讨论,需要关注 - low: 轻微不满或一般性价格讨论 - none: 无风险 请以 JSON 格式回复(不要包含其他任何内容,不要用 markdown 代码块包裹): {"hasRisk":true/false,"severity":"high"/"medium"/"low"/"none","riskTitle":"风险标题≤20字","riskDescription":"风险描述≤100字","sentiment":"positive"/"neutral"/"negative","mainTopics":["话题1","话题2"],"hasAbnormal":true/false,"abnormalDescription":"异常描述≤50字","summary":"一句话总结该群今日情况≤80字"}`; /* ======================== 类型定义 ======================== */ interface KeywordInfo { word: string; category: string; severity: 'high' | 'medium' | 'low'; } interface MsgInfo { senderId: string; senderName: string; content: string; timestamp: Date; } interface AIAnalysisResult { hasRisk: boolean; severity: 'high' | 'medium' | 'low' | 'none'; riskTitle: string; riskDescription: string; sentiment: 'positive' | 'neutral' | 'negative'; mainTopics: string[]; hasAbnormal: boolean; abnormalDescription: string; summary: string; } interface KeywordMatchDetail { word: string; category: string; severity: 'high' | 'medium' | 'low'; count: number; samples: { senderName: string; content: string; time: string }[]; } interface GroupReport { roomId: string; roomName: string; messageCount: number; activeMemberCount: number; topSpeakers: { name: string; count: number }[]; lastMessageTime: string; /** AI 分析结果(主力) */ aiResult: AIAnalysisResult | null; /** 关键词匹配结果(辅助参考) */ keywordMatches: KeywordMatchDetail[]; activityLevel: 'high' | 'normal' | 'low' | 'silent'; riskLevel: 'emergency' | 'abnormal' | 'normal' | 'silent'; /** 异常备注(关键词规则补充) */ anomalyNotes: string[]; } interface Summary { totalGroups: number; totalMessages: number; emergencyCount: number; abnormalCount: number; normalCount: number; totalActiveMembers: number; aiEnabled: boolean; } /* ======================== 数据查询 ======================== */ async function fetchData(targetDate: string): Promise<{ groups: any[]; allMessages: any[]; keywords: KeywordInfo[]; }> { console.log(`[日报] 目标日期: ${targetDate}`); const dayStart = new Date(`${targetDate}T00:00:00+08:00`); const dayEnd = new Date(`${targetDate}T23:59:59+08:00`); const [groups, allMessages, keywords] = await Promise.all([ (async () => { const q = new Parse.Query('GroupChat'); q.notEqualTo('status', 'dismissed'); q.select(['roomId', 'roomName']); q.limit(9999); const rows = await q.find({ useMasterKey: true }) as any[]; console.log(`[日报] GroupChat: ${rows.length} 个活跃群`); return rows; })(), (async () => { const q = new Parse.Query('Message'); q.containedIn('msgType', [0, 2]); q.notEqualTo('content', ''); q.greaterThanOrEqualTo('timestamp', dayStart); q.lessThanOrEqualTo('timestamp', dayEnd); q.select(['roomId', 'senderId', 'senderName', 'content', 'timestamp']); q.limit(99999); q.ascending('timestamp'); const rows = await q.find({ useMasterKey: true }) as any[]; console.log(`[日报] Message: ${rows.length} 条文本消息`); return rows; })(), (async () => { const q = new Parse.Query('RiskKeyword'); q.equalTo('enabled', true); q.select(['word', 'category', 'severity']); q.limit(9999); const rows = await q.find({ useMasterKey: true }) as any[]; const kws = rows.map((r: any) => ({ word: r.get('word') as string, category: r.get('category') as string, severity: r.get('severity') as string, })); console.log(`[日报] RiskKeyword: ${kws.length} 个关键词(降级备用)`); return kws; })(), ]); return { groups, allMessages, keywords }; } /* ======================== AI 分析 ======================== */ async function callAI(messages: MsgInfo[], groupName: string, retries = 2): Promise { const conversation = messages .map((m) => { const ts = m.timestamp; const time = ts ? `${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}` : ''; return `[${time}] ${m.senderName || '未知'}: ${m.content}`; }) .join('\n'); const userPrompt = `群名称: ${groupName} 消息数: ${messages.length} 条 参与人数: ${new Set(messages.map(m => m.senderId).filter(Boolean)).size} 人 以下是该群今日全部消息(按时间顺序): --- ${conversation} --- 请分析该群今日状态。`; for (let attempt = 0; attempt <= retries; attempt++) { try { const res = await fetch(AI_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_KEY}` }, body: JSON.stringify({ model: AI_MODEL, messages: [ { role: 'system', content: AI_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], temperature: 0.3, max_tokens: 512, }), }); if (!res.ok) { const errText = await res.text().catch(() => ''); if (res.status === 429 && attempt < retries) { console.warn(` ⚠ AI 限流,等待 2s 重试...`); await sleep(2000); continue; } console.warn(` ⚠ AI API ${res.status}: ${errText.slice(0, 100)}`); return null; } const json = await res.json() as any; const content = json?.choices?.[0]?.message?.content || ''; const parsed = parseAIResponse(content); if (!parsed && attempt < retries) { console.warn(` ⚠ AI 返回格式异常,重试...`); continue; } return parsed; } catch (err) { if (attempt < retries) { console.warn(` ⚠ AI 调用失败,重试...`); await sleep(1000); continue; } console.warn(` ⚠ AI 调用失败: ${(err as Error).message}`); return null; } } return null; } function parseAIResponse(content: string): AIAnalysisResult | null { try { // 尝试直接解析 JSON(可能在 markdown 代码块中) let jsonStr = content; const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/); if (codeBlockMatch) jsonStr = codeBlockMatch[1]; else { const braceMatch = content.match(/\{[\s\S]*\}/); if (braceMatch) jsonStr = braceMatch[0]; } const parsed = JSON.parse(jsonStr); return { hasRisk: !!parsed.hasRisk, severity: ['high', 'medium', 'low', 'none'].includes(parsed.severity) ? parsed.severity : 'none', riskTitle: parsed.riskTitle || '', riskDescription: parsed.riskDescription || '', sentiment: ['positive', 'neutral', 'negative'].includes(parsed.sentiment) ? parsed.sentiment : 'neutral', mainTopics: Array.isArray(parsed.mainTopics) ? parsed.mainTopics : [], hasAbnormal: !!parsed.hasAbnormal, abnormalDescription: parsed.abnormalDescription || '', summary: parsed.summary || '分析未返回摘要', }; } catch { // 尝试宽松匹配 const hasRisk = /"hasRisk"\s*:\s*true/i.test(content); const sevMatch = content.match(/"severity"\s*:\s*"(high|medium|low|none)"/); return { hasRisk, severity: (sevMatch?.[1] as any) || 'none', riskTitle: '', riskDescription: '', sentiment: 'neutral', mainTopics: [], hasAbnormal: false, abnormalDescription: '', summary: content.slice(0, 80), }; } } function sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } /** 并发池:最多 N 个 AI 请求同时进行 */ async function runWithConcurrency( items: T[], concurrency: number, fn: (item: T) => Promise, ): Promise { const results: R[] = new Array(items.length); let idx = 0; async function worker(): Promise { while (idx < items.length) { const i = idx++; results[i] = await fn(items[i]); } } await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); return results; } /* ======================== 关键词匹配(降级 + 辅助) ======================== */ function matchKeywords(messages: MsgInfo[], keywords: KeywordInfo[]): KeywordMatchDetail[] { const matchMap = new Map(); for (const msg of messages) { const content = msg.content; if (!content) continue; for (const kw of keywords) { if (content.includes(kw.word)) { const existing = matchMap.get(kw.word); const sample = { senderName: msg.senderName || '未知', content: content.length > 200 ? content.slice(0, 200) + '...' : content, time: msg.timestamp ? `${String(msg.timestamp.getHours()).padStart(2, '0')}:${String(msg.timestamp.getMinutes()).padStart(2, '0')}` : '', }; if (existing) { existing.count++; if (existing.samples.length < 3) existing.samples.push(sample); } else { matchMap.set(kw.word, { word: kw.word, category: kw.category, severity: kw.severity as 'high' | 'medium' | 'low', count: 1, samples: [sample], }); } } } } return [...matchMap.values()].sort((a, b) => b.count - a.count); } /* ======================== 分析引擎 ======================== */ interface PendingGroup { roomId: string; roomName: string; messages: MsgInfo[]; } async function analyzeGroups( groups: any[], allMessages: any[], keywords: KeywordInfo[], ): Promise<{ groupReports: GroupReport[]; summary: Summary }> { // 消息按 roomId 分组 const msgsByRoom = new Map(); for (const m of allMessages) { const roomId = m.get('roomId') as string; if (!roomId) continue; const msg: MsgInfo = { senderId: m.get('senderId') as string || '', senderName: m.get('senderName') as string || '', content: m.get('content') as string || '', timestamp: m.get('timestamp') as Date, }; const arr = msgsByRoom.get(roomId); if (arr) arr.push(msg); else msgsByRoom.set(roomId, [msg]); } // 构建待分析群列表(仅保留有消息的群) const pendingGroups: PendingGroup[] = []; for (const g of groups) { const roomId = g.get('roomId') as string; const roomName = g.get('roomName') as string || `未知群(${roomId})`; const msgs = msgsByRoom.get(roomId); if (msgs && msgs.length > 0) { pendingGroups.push({ roomId, roomName, messages: msgs }); } } console.log(`[日报] 有消息的群: ${pendingGroups.length} 个`); console.log(`[日报] AI 分析: ${AI_ENABLED ? '启用 (DeepSeek)' : '禁用,使用关键词规则'}`); if (AI_ENABLED) { console.log(`[日报] 并发数: ${AI_CONCURRENCY},预计耗时 ${Math.ceil(pendingGroups.length / AI_CONCURRENCY) * 3}s`); } // AI 分析(或降级) const aiResults = AI_ENABLED ? await runWithConcurrency(pendingGroups, AI_CONCURRENCY, async (pg) => { console.log(` → AI 分析: ${pg.roomName} (${pg.messages.length} 条消息)`); const result = await callAI(pg.messages, pg.roomName); const status = result ? (result.hasRisk ? `风险:${result.severity}` : '正常') : 'AI 失败'; console.log(` ← ${pg.roomName}: ${status}`); return result; }) : pendingGroups.map(() => null); // 构建群报告 const groupReports: GroupReport[] = []; for (let i = 0; i < pendingGroups.length; i++) { const pg = pendingGroups[i]; const aiResult = aiResults[i]; const msgs = pg.messages; // 基础指标 const messageCount = msgs.length; const senderIds = new Set(msgs.map(m => m.senderId).filter(Boolean)); const activeMemberCount = senderIds.size; // Top 发言人 const senderCount = new Map(); for (const m of msgs) { const key = m.senderId || '__unknown__'; const entry = senderCount.get(key); if (entry) { entry.count++; } else { senderCount.set(key, { name: m.senderName || m.senderId || '未知', count: 1 }); } } const topSpeakers = [...senderCount.values()].sort((a, b) => b.count - a.count).slice(0, 3); // 单一用户主导 let singleUserDominant = false; let dominantUserName = ''; let dominantUserRatio = 0; if (messageCount >= 5 && topSpeakers.length > 0) { const ratio = topSpeakers[0].count / messageCount; if (ratio > 0.8) { singleUserDominant = true; dominantUserName = topSpeakers[0].name; dominantUserRatio = ratio; } } // 最后消息时间 let lastMessageTime = ''; const last = msgs[msgs.length - 1].timestamp; if (last) { lastMessageTime = `${String(last.getHours()).padStart(2, '0')}:${String(last.getMinutes()).padStart(2, '0')}`; } // 关键词匹配(辅助参考) const keywordMatches = matchKeywords(msgs, keywords); // 活跃度 let activityLevel: GroupReport['activityLevel']; if (messageCount > 50) activityLevel = 'high'; else if (messageCount >= 10) activityLevel = 'normal'; else activityLevel = 'low'; // 异常备注(从关键词匹配补充) const anomalyNotes: string[] = []; if (singleUserDominant) { anomalyNotes.push(`单一用户 "${dominantUserName}" 发送了 ${Math.round(dominantUserRatio * 100)}% 的消息`); } if (aiResult?.hasAbnormal && aiResult.abnormalDescription) { anomalyNotes.push(aiResult.abnormalDescription); } // 风险等级(AI 优先,关键词降级) let riskLevel: GroupReport['riskLevel']; if (aiResult && aiResult.hasRisk) { if (aiResult.severity === 'high') riskLevel = 'emergency'; else if (aiResult.severity === 'medium') riskLevel = 'abnormal'; else riskLevel = 'normal'; } else if (aiResult && !aiResult.hasRisk) { riskLevel = anomalyNotes.length > 0 ? 'abnormal' : 'normal'; } else { // AI 不可用时关键词降级 const hasHigh = keywordMatches.some(k => k.severity === 'high'); const hasMedium = keywordMatches.some(k => k.severity === 'medium'); if (hasHigh) riskLevel = 'emergency'; else if (hasMedium || singleUserDominant) riskLevel = 'abnormal'; else riskLevel = 'normal'; } groupReports.push({ roomId: pg.roomId, roomName: pg.roomName, messageCount, activeMemberCount, topSpeakers, lastMessageTime, aiResult, keywordMatches, activityLevel, riskLevel, anomalyNotes, }); } // 排序 const order = { emergency: 0, abnormal: 1, normal: 2 }; groupReports.sort((a, b) => { const oa = order[a.riskLevel]; const ob = order[b.riskLevel]; if (oa !== ob) return oa - ob; return b.messageCount - a.messageCount; }); const summary: Summary = { totalGroups: groupReports.length, totalMessages: allMessages.length, emergencyCount: groupReports.filter(g => g.riskLevel === 'emergency').length, abnormalCount: groupReports.filter(g => g.riskLevel === 'abnormal').length, normalCount: groupReports.filter(g => g.riskLevel === 'normal').length, totalActiveMembers: new Set(allMessages.map((m: any) => m.get('senderId')).filter(Boolean)).size, aiEnabled: AI_ENABLED, }; return { groupReports, summary }; } /* ======================== HTML 生成 ======================== */ function categoryLabel(cat: string): string { const map: Record = { complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律', competitor: '竞品', price: '价格', negative: '负面', installation: '安装', }; return map[cat] || cat; } function severityBadge(severity: string): string { const map: Record = { high: '高危', medium: '中危', low: '低危', }; return map[severity] || ''; } function riskLabel(level: string): string { const map: Record = { emergency: '🔴 紧急', abnormal: '🟡 异常', normal: '🟢 正常', silent: '⚪ 静默', }; return map[level] || level; } function activityLabel(level: string): string { const map: Record = { high: '高活跃', normal: '正常', low: '低活跃', silent: '静默', }; return map[level] || level; } function sentimentEmoji(s: string): string { const map: Record = { positive: '😊 正面', neutral: '😐 中性', negative: '😟 负面', }; return map[s] || s; } function generateHTML(reports: GroupReport[], summary: Summary, dateStr: string, genTime: string, stats: { queryMs: number; aiMs: number; totalMs: number }): string { const cards = reports.map(g => { const borderColor = { emergency: '#e74c3c', abnormal: '#f39c12', normal: '#27ae60' }[g.riskLevel]; // ── AI 分析区块 ── let aiSection = ''; if (g.aiResult) { const ar = g.aiResult; aiSection = `
🤖 AI 分析
${escapeHtml(ar.summary)}
${sentimentEmoji(ar.sentiment)} ${ar.mainTopics.map(t => `📌 ${escapeHtml(t)}`).join('')}
${ar.hasRisk ? `
⚠ 风险判定: ${ar.severity === 'high' ? '高危' : ar.severity === 'medium' ? '中危' : '低危'} ${ar.riskTitle ? `
${escapeHtml(ar.riskTitle)}
` : ''} ${ar.riskDescription ? `
${escapeHtml(ar.riskDescription)}
` : ''}
` : ''}
`; } else { aiSection = `
🤖 AI 分析不可用,已降级为关键词规则引擎
`; } // ── 关键词匹配(辅助)── let keywordSection = ''; if (g.keywordMatches.length > 0) { const items = g.keywordMatches.map(k => `
${severityBadge(k.severity)} ${escapeHtml(k.word)} [${categoryLabel(k.category)}] ×${k.count} ${k.samples.length > 0 ? `
${k.samples.map(s => `
${escapeHtml(s.senderName)} ${s.time}
${escapeHtml(s.content)}
` ).join('')}
` : ''}
` ).join(''); keywordSection = `
🔍 关键词命中 (${g.keywordMatches.length}) — 辅助参考
${items}
`; } // ── 异常备注 ── let anomalySection = ''; if (g.anomalyNotes.length > 0) { anomalySection = `
${g.anomalyNotes.map(n => `
⚠ ${escapeHtml(n)}
`).join('')}
`; } const speakers = g.topSpeakers.length > 0 ? g.topSpeakers.map(s => `${escapeHtml(s.name)} (${s.count}条)`).join(' ') : ''; return `
${escapeHtml(g.roomName)} ${riskLabel(g.riskLevel)}
${g.messageCount}
消息数
${g.activeMemberCount}
参与人数
${activityLabel(g.activityLevel)}
活跃度
${g.lastMessageTime || '—'}
最后消息
${aiSection}
💬 Top 发言人
${speakers}
${keywordSection} ${anomalySection}
`; }).join('\n'); const aiStatus = summary.aiEnabled ? '● AI 分析 (DeepSeek)' : '● 关键词规则引擎 (AI 未配置)'; return ` 拉迷群消息日报 — ${dateStr}

拉迷群消息日报

报告日期: ${dateStr} — 生成时间: ${genTime}  |  ${aiStatus}
查询: ${stats.queryMs}ms  |  AI 分析: ${stats.aiMs}ms  |  总耗时: ${stats.totalMs}ms
${summary.totalGroups}
活跃群组
${summary.totalMessages}
当日消息
${summary.totalActiveMembers}
参与用户
${summary.emergencyCount}
🔴 紧急
${summary.abnormalCount}
🟡 异常
${summary.normalCount}
🟢 正常
${cards}
${reports.length === 0 ? '
今日无群消息
' : ''}
`; } function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /* ======================== 主流程 ======================== */ function parseTargetDate(): string { const arg = process.argv[2]; if (arg) { if (!/^\d{4}-\d{2}-\d{2}$/.test(arg)) { console.error('日期格式错误,请使用 YYYY-MM-DD,例如: 2026-06-15'); process.exit(1); } return arg; } const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; } async function main(): Promise { const t0 = Date.now(); const targetDate = parseTargetDate(); console.log('═══════════════════════════════════════'); console.log(' 拉迷群消息日报 (AI 分析版)'); console.log(` 日期: ${targetDate}`); console.log(` AI: ${AI_ENABLED ? `DeepSeek (${AI_MODEL})` : '禁用'}`); console.log('═══════════════════════════════════════\n'); // 1. 查询 const t1 = Date.now(); const { groups, allMessages, keywords } = await fetchData(targetDate); const queryMs = Date.now() - t1; // 2. AI 分析 const t2 = Date.now(); console.log('\n[日报] 开始 AI 分析...'); const { groupReports, summary } = await analyzeGroups(groups, allMessages, keywords); const aiMs = Date.now() - t2; // 3. 控制台摘要 console.log('\n═══════════════════════════════════════'); console.log(' 分析结果'); console.log('═══════════════════════════════════════'); console.log(` 有消息群数: ${summary.totalGroups}`); console.log(` 总消息: ${summary.totalMessages}`); console.log(` 参与用户: ${summary.totalActiveMembers}`); console.log(` 🔴 紧急: ${summary.emergencyCount} 🟡 异常: ${summary.abnormalCount} 🟢 正常: ${summary.normalCount}`); if (summary.emergencyCount > 0) { console.log('\n ⚠️ 紧急群:'); for (const g of groupReports.filter(g => g.riskLevel === 'emergency')) { const ai = g.aiResult; console.log(` - ${g.roomName}: ${ai?.riskTitle || 'AI 分析异常'}`); } } if (summary.abnormalCount > 0) { console.log('\n ⚡ 异常群:'); for (const g of groupReports.filter(g => g.riskLevel === 'abnormal')) { const parts = [...g.anomalyNotes]; if (g.aiResult?.hasRisk) parts.push(`AI: ${g.aiResult.riskTitle}`); console.log(` - ${g.roomName}: ${parts.join(' | ')}`); } } // 4. 生成 HTML const totalMs = Date.now() - t0; const now = new Date(); const genTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`; const html = generateHTML(groupReports, summary, targetDate, genTime, { queryMs, aiMs, totalMs }); const outputPath = path.resolve(__dirname, `group-report-${targetDate}.html`); fs.writeFileSync(outputPath, html, 'utf-8'); console.log(`\n✅ 报告已生成: ${outputPath}`); console.log(` 文件大小: ${(Buffer.byteLength(html, 'utf-8') / 1024).toFixed(1)} KB`); console.log(` 查询: ${queryMs}ms | AI: ${aiMs}ms | 总计: ${totalMs}ms\n`); } main().catch((err) => { console.error('[日报] 失败:', err); process.exit(1); });