| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819 |
- /**
- * 群消息日报 — 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<AIAnalysisResult | null> {
- 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<void> {
- return new Promise(r => setTimeout(r, ms));
- }
- /** 并发池:最多 N 个 AI 请求同时进行 */
- async function runWithConcurrency<T, R>(
- items: T[],
- concurrency: number,
- fn: (item: T) => Promise<R>,
- ): Promise<R[]> {
- const results: R[] = new Array(items.length);
- let idx = 0;
- async function worker(): Promise<void> {
- 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<string, KeywordMatchDetail>();
- 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<string, MsgInfo[]>();
- 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<string, { name: string; count: number }>();
- 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<string, string> = {
- complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律',
- competitor: '竞品', price: '价格', negative: '负面', installation: '安装',
- };
- return map[cat] || cat;
- }
- function severityBadge(severity: string): string {
- const map: Record<string, string> = {
- high: '<span class="badge badge-high">高危</span>',
- medium: '<span class="badge badge-medium">中危</span>',
- low: '<span class="badge badge-low">低危</span>',
- };
- return map[severity] || '';
- }
- function riskLabel(level: string): string {
- const map: Record<string, string> = {
- emergency: '🔴 紧急', abnormal: '🟡 异常', normal: '🟢 正常', silent: '⚪ 静默',
- };
- return map[level] || level;
- }
- function activityLabel(level: string): string {
- const map: Record<string, string> = {
- high: '高活跃', normal: '正常', low: '低活跃', silent: '静默',
- };
- return map[level] || level;
- }
- function sentimentEmoji(s: string): string {
- const map: Record<string, string> = {
- 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 = `
- <div class="ai-block">
- <div class="ai-header">🤖 AI 分析</div>
- <div class="ai-summary">${escapeHtml(ar.summary)}</div>
- <div class="ai-details">
- <span class="ai-tag">${sentimentEmoji(ar.sentiment)}</span>
- ${ar.mainTopics.map(t => `<span class="ai-tag topic-tag">📌 ${escapeHtml(t)}</span>`).join('')}
- </div>
- ${ar.hasRisk ? `<div class="ai-risk">
- <span class="ai-risk-label">⚠ 风险判定: ${ar.severity === 'high' ? '高危' : ar.severity === 'medium' ? '中危' : '低危'}</span>
- ${ar.riskTitle ? `<div class="ai-risk-title">${escapeHtml(ar.riskTitle)}</div>` : ''}
- ${ar.riskDescription ? `<div class="ai-risk-desc">${escapeHtml(ar.riskDescription)}</div>` : ''}
- </div>` : ''}
- </div>`;
- } else {
- aiSection = `<div class="ai-block ai-fallback">🤖 AI 分析不可用,已降级为关键词规则引擎</div>`;
- }
- // ── 关键词匹配(辅助)──
- let keywordSection = '';
- if (g.keywordMatches.length > 0) {
- const items = g.keywordMatches.map(k => `
- <div class="kw-row">
- ${severityBadge(k.severity)}
- <span class="kw-word">${escapeHtml(k.word)}</span>
- <span class="kw-cat">[${categoryLabel(k.category)}]</span>
- <span class="kw-count">×${k.count}</span>
- ${k.samples.length > 0 ? `<div class="kw-samples">${k.samples.map(s =>
- `<div class="kw-sample-msg"><span class="kw-sample-sender">${escapeHtml(s.senderName)}</span> <span class="kw-sample-time">${s.time}</span><div class="kw-sample-content">${escapeHtml(s.content)}</div></div>`
- ).join('')}</div>` : ''}
- </div>`
- ).join('');
- keywordSection = `<div class="section"><div class="section-title">🔍 关键词命中 (${g.keywordMatches.length}) — 辅助参考</div>${items}</div>`;
- }
- // ── 异常备注 ──
- let anomalySection = '';
- if (g.anomalyNotes.length > 0) {
- anomalySection = `<div class="anomaly-notes">${g.anomalyNotes.map(n => `<div class="anomaly-note">⚠ ${escapeHtml(n)}</div>`).join('')}</div>`;
- }
- const speakers = g.topSpeakers.length > 0
- ? g.topSpeakers.map(s => `<span class="speaker">${escapeHtml(s.name)} <em>(${s.count}条)</em></span>`).join(' ')
- : '<span class="no-data">—</span>';
- return `
- <div class="card card-${g.riskLevel}" style="border-left: 4px solid ${borderColor};">
- <div class="card-header">
- <span class="group-name">${escapeHtml(g.roomName)}</span>
- <span class="risk-badge risk-${g.riskLevel}">${riskLabel(g.riskLevel)}</span>
- </div>
- <div class="card-body">
- <div class="metrics">
- <div class="metric"><div class="metric-value">${g.messageCount}</div><div class="metric-label">消息数</div></div>
- <div class="metric"><div class="metric-value">${g.activeMemberCount}</div><div class="metric-label">参与人数</div></div>
- <div class="metric"><div class="metric-value">${activityLabel(g.activityLevel)}</div><div class="metric-label">活跃度</div></div>
- <div class="metric"><div class="metric-value">${g.lastMessageTime || '—'}</div><div class="metric-label">最后消息</div></div>
- </div>
- ${aiSection}
- <div class="section"><div class="section-title">💬 Top 发言人</div><div class="speakers-row">${speakers}</div></div>
- ${keywordSection}
- ${anomalySection}
- </div>
- </div>`;
- }).join('\n');
- const aiStatus = summary.aiEnabled
- ? '<span style="color:#27ae60;">● AI 分析 (DeepSeek)</span>'
- : '<span style="color:#f39c12;">● 关键词规则引擎 (AI 未配置)</span>';
- return `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>拉迷群消息日报 — ${dateStr}</title>
- <style>
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f6fa; color: #2c3e50; line-height: 1.6; }
- .header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: #fff; padding: 28px 40px; }
- .header h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
- .header .subtitle { font-size: 14px; color: #8892b0; }
- .header .meta { font-size: 12px; color: #5a6785; margin-top: 2px; }
- .container { max-width: 1000px; margin: 0 auto; padding: 24px; }
- .summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
- .summary-card { background: #fff; border-radius: 10px; padding: 20px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
- .summary-card .s-value { font-size: 32px; font-weight: 700; line-height: 1.2; }
- .summary-card .s-label { font-size: 13px; color: #7f8c8d; margin-top: 4px; }
- .s-emergency .s-value { color: #e74c3c; } .s-abnormal .s-value { color: #f39c12; } .s-normal .s-value { color: #27ae60; }
- .s-groups .s-value { color: #2980b9; } .s-messages .s-value { color: #8e44ad; } .s-members .s-value { color: #16a085; }
- .cards { display: flex; flex-direction: column; gap: 16px; }
- .card { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
- .card-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fafbfc; border-bottom: 1px solid #eee; }
- .group-name { font-size: 16px; font-weight: 600; }
- .risk-badge { font-size: 13px; font-weight: 600; padding: 4px 12px; border-radius: 20px; }
- .risk-emergency { background: #fde8e8; color: #c0392b; }
- .risk-abnormal { background: #fef5e7; color: #d68910; }
- .risk-normal { background: #e8f8f5; color: #1e8449; }
- .card-body { padding: 16px 20px; }
- .metrics { display: flex; gap: 24px; margin-bottom: 14px; flex-wrap: wrap; }
- .metric { text-align: center; min-width: 60px; }
- .metric-value { font-size: 20px; font-weight: 700; }
- .metric-label { font-size: 12px; color: #95a5a6; margin-top: 2px; }
- /* AI 分析块 */
- .ai-block { background: #f0f4ff; border-radius: 8px; padding: 14px; margin-bottom: 14px; border: 1px solid #d4e0ff; }
- .ai-block.ai-fallback { background: #fff9e6; border-color: #ffe0a0; color: #856404; font-size: 13px; }
- .ai-header { font-size: 13px; font-weight: 700; color: #4a6fa5; margin-bottom: 6px; }
- .ai-summary { font-size: 14px; color: #2c3e50; margin-bottom: 8px; line-height: 1.5; }
- .ai-details { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
- .ai-tag { font-size: 12px; padding: 2px 8px; border-radius: 12px; background: #e8ecf4; color: #4a6fa5; }
- .topic-tag { background: #e8f8f5; color: #1e8449; }
- .ai-risk { background: #fff5f5; border: 1px solid #fecaca; border-radius: 6px; padding: 10px; margin-top: 8px; }
- .ai-risk-label { font-size: 13px; font-weight: 600; color: #c0392b; }
- .ai-risk-title { font-size: 14px; font-weight: 700; color: #e74c3c; margin-top: 4px; }
- .ai-risk-desc { font-size: 13px; color: #555; margin-top: 2px; }
- .section { margin-bottom: 12px; }
- .section-title { font-size: 13px; font-weight: 600; color: #7f8c8d; margin-bottom: 6px; }
- .speakers-row { display: flex; gap: 12px; flex-wrap: wrap; }
- .speaker { font-size: 13px; background: #f0f3f5; padding: 3px 10px; border-radius: 12px; }
- .speaker em { font-style: normal; color: #7f8c8d; font-size: 12px; }
- .no-data { color: #bdc3c7; font-size: 13px; }
- /* 关键词 */
- .kw-row { font-size: 13px; margin-bottom: 8px; }
- .kw-row > .badge, .kw-row > .kw-word, .kw-row > .kw-cat, .kw-row > .kw-count { display: inline; margin-right: 4px; }
- .kw-word { font-weight: 600; }
- .kw-cat { color: #7f8c8d; font-size: 12px; }
- .kw-count { color: #c0392b; font-weight: 600; }
- .kw-samples { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
- .kw-sample-msg { background: #fff; border-radius: 4px; padding: 6px 8px; font-size: 12px; border-left: 3px solid #e74c3c; }
- .kw-sample-sender { font-weight: 600; margin-right: 6px; }
- .kw-sample-time { color: #95a5a6; font-size: 11px; }
- .kw-sample-content { color: #555; margin-top: 2px; word-break: break-all; }
- .badge { font-size: 11px; font-weight: 700; padding: 2px 6px; border-radius: 4px; color: #fff; }
- .badge-high { background: #e74c3c; } .badge-medium { background: #f39c12; } .badge-low { background: #3498db; }
- .anomaly-notes { margin-top: 8px; }
- .anomaly-note { font-size: 13px; color: #d68910; background: #fef9e7; padding: 8px 12px; border-radius: 6px; margin-bottom: 4px; }
- @media (max-width: 768px) {
- .header { padding: 20px; } .container { padding: 12px; }
- .summary-row { grid-template-columns: repeat(3, 1fr); gap: 8px; }
- .summary-card { padding: 12px; } .summary-card .s-value { font-size: 24px; }
- .metrics { gap: 14px; }
- }
- @media print {
- body { background: #fff; }
- .card { box-shadow: none; break-inside: avoid; border: 1px solid #ddd; }
- .header { background: #1a1a2e !important; -webkit-print-color-adjust: exact; }
- }
- </style>
- </head>
- <body>
- <div class="header">
- <h1>拉迷群消息日报</h1>
- <div class="subtitle">报告日期: ${dateStr} — 生成时间: ${genTime} | ${aiStatus}</div>
- <div class="meta">查询: ${stats.queryMs}ms | AI 分析: ${stats.aiMs}ms | 总耗时: ${stats.totalMs}ms</div>
- </div>
- <div class="container">
- <div class="summary-row">
- <div class="summary-card s-groups"><div class="s-value">${summary.totalGroups}</div><div class="s-label">活跃群组</div></div>
- <div class="summary-card s-messages"><div class="s-value">${summary.totalMessages}</div><div class="s-label">当日消息</div></div>
- <div class="summary-card s-members"><div class="s-value">${summary.totalActiveMembers}</div><div class="s-label">参与用户</div></div>
- <div class="summary-card s-emergency"><div class="s-value">${summary.emergencyCount}</div><div class="s-label">🔴 紧急</div></div>
- <div class="summary-card s-abnormal"><div class="s-value">${summary.abnormalCount}</div><div class="s-label">🟡 异常</div></div>
- <div class="summary-card s-normal"><div class="s-value">${summary.normalCount}</div><div class="s-label">🟢 正常</div></div>
- </div>
- <div class="cards">${cards}</div>
- ${reports.length === 0 ? '<div style="text-align:center;padding:60px;color:#95a5a6;">今日无群消息</div>' : ''}
- </div>
- </body>
- </html>`;
- }
- function escapeHtml(s: string): string {
- return s.replace(/&/g, '&').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<void> {
- 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);
- });
|