group-daily-report.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819
  1. /**
  2. * 群消息日报 — AI 分析版
  3. *
  4. * 用法: cd backend && npx tsx scripts/group-daily-report.ts [YYYY-MM-DD]
  5. * 输出: backend/scripts/group-report-{YYYY-MM-DD}.html
  6. *
  7. * 分析流程:
  8. * 1. 查询当天所有文本消息,按群分组
  9. * 2. 每个有消息的群 → 发送全部消息到 DeepSeek AI 分析
  10. * 3. AI 判定: 风险程度 / 客户情绪 / 主要话题 / 异常情况
  11. * 4. AI 不可用时降级为关键词规则引擎
  12. * 5. 生成 HTML 报告
  13. */
  14. import 'dotenv/config';
  15. import Parse from '../src/db/parse-client.js';
  16. import * as fs from 'fs';
  17. import * as path from 'path';
  18. import { fileURLToPath } from 'url';
  19. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  20. /* ======================== AI 配置 ======================== */
  21. const AI_KEY = process.env.DEEPSEEK_API_KEY || '';
  22. const AI_URL = process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions';
  23. const AI_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
  24. const AI_CONCURRENCY = 3; // 并发数
  25. const AI_ENABLED = AI_KEY && AI_KEY !== 'sk-xxx';
  26. const AI_SYSTEM_PROMPT = `你是一家全屋定制公司(拉迷家居)的客户群消息分析员。请分析以下群聊消息,判断该群当前状态。
  27. 风险类型参考(全屋定制行业):
  28. - 投诉抱怨:质量、工期、安装、服务态度等
  29. - 竞品对比:提及欧派、索菲亚、尚品宅配等竞品
  30. - 价格敏感:太贵、砍价、退款、预算超支等
  31. - 负面情绪:不满、威胁、换商家、维权、起诉等
  32. - 敏感话题:甲醛超标、欺诈、烂尾、跑路等
  33. 判断标准:
  34. - high: 明确投诉/维权/法律威胁,可能升级为纠纷
  35. - medium: 负面情绪或竞品讨论,需要关注
  36. - low: 轻微不满或一般性价格讨论
  37. - none: 无风险
  38. 请以 JSON 格式回复(不要包含其他任何内容,不要用 markdown 代码块包裹):
  39. {"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字"}`;
  40. /* ======================== 类型定义 ======================== */
  41. interface KeywordInfo {
  42. word: string;
  43. category: string;
  44. severity: 'high' | 'medium' | 'low';
  45. }
  46. interface MsgInfo {
  47. senderId: string;
  48. senderName: string;
  49. content: string;
  50. timestamp: Date;
  51. }
  52. interface AIAnalysisResult {
  53. hasRisk: boolean;
  54. severity: 'high' | 'medium' | 'low' | 'none';
  55. riskTitle: string;
  56. riskDescription: string;
  57. sentiment: 'positive' | 'neutral' | 'negative';
  58. mainTopics: string[];
  59. hasAbnormal: boolean;
  60. abnormalDescription: string;
  61. summary: string;
  62. }
  63. interface KeywordMatchDetail {
  64. word: string;
  65. category: string;
  66. severity: 'high' | 'medium' | 'low';
  67. count: number;
  68. samples: { senderName: string; content: string; time: string }[];
  69. }
  70. interface GroupReport {
  71. roomId: string;
  72. roomName: string;
  73. messageCount: number;
  74. activeMemberCount: number;
  75. topSpeakers: { name: string; count: number }[];
  76. lastMessageTime: string;
  77. /** AI 分析结果(主力) */
  78. aiResult: AIAnalysisResult | null;
  79. /** 关键词匹配结果(辅助参考) */
  80. keywordMatches: KeywordMatchDetail[];
  81. activityLevel: 'high' | 'normal' | 'low' | 'silent';
  82. riskLevel: 'emergency' | 'abnormal' | 'normal' | 'silent';
  83. /** 异常备注(关键词规则补充) */
  84. anomalyNotes: string[];
  85. }
  86. interface Summary {
  87. totalGroups: number;
  88. totalMessages: number;
  89. emergencyCount: number;
  90. abnormalCount: number;
  91. normalCount: number;
  92. totalActiveMembers: number;
  93. aiEnabled: boolean;
  94. }
  95. /* ======================== 数据查询 ======================== */
  96. async function fetchData(targetDate: string): Promise<{
  97. groups: any[];
  98. allMessages: any[];
  99. keywords: KeywordInfo[];
  100. }> {
  101. console.log(`[日报] 目标日期: ${targetDate}`);
  102. const dayStart = new Date(`${targetDate}T00:00:00+08:00`);
  103. const dayEnd = new Date(`${targetDate}T23:59:59+08:00`);
  104. const [groups, allMessages, keywords] = await Promise.all([
  105. (async () => {
  106. const q = new Parse.Query('GroupChat');
  107. q.notEqualTo('status', 'dismissed');
  108. q.select(['roomId', 'roomName']);
  109. q.limit(9999);
  110. const rows = await q.find({ useMasterKey: true }) as any[];
  111. console.log(`[日报] GroupChat: ${rows.length} 个活跃群`);
  112. return rows;
  113. })(),
  114. (async () => {
  115. const q = new Parse.Query('Message');
  116. q.containedIn('msgType', [0, 2]);
  117. q.notEqualTo('content', '');
  118. q.greaterThanOrEqualTo('timestamp', dayStart);
  119. q.lessThanOrEqualTo('timestamp', dayEnd);
  120. q.select(['roomId', 'senderId', 'senderName', 'content', 'timestamp']);
  121. q.limit(99999);
  122. q.ascending('timestamp');
  123. const rows = await q.find({ useMasterKey: true }) as any[];
  124. console.log(`[日报] Message: ${rows.length} 条文本消息`);
  125. return rows;
  126. })(),
  127. (async () => {
  128. const q = new Parse.Query('RiskKeyword');
  129. q.equalTo('enabled', true);
  130. q.select(['word', 'category', 'severity']);
  131. q.limit(9999);
  132. const rows = await q.find({ useMasterKey: true }) as any[];
  133. const kws = rows.map((r: any) => ({
  134. word: r.get('word') as string,
  135. category: r.get('category') as string,
  136. severity: r.get('severity') as string,
  137. }));
  138. console.log(`[日报] RiskKeyword: ${kws.length} 个关键词(降级备用)`);
  139. return kws;
  140. })(),
  141. ]);
  142. return { groups, allMessages, keywords };
  143. }
  144. /* ======================== AI 分析 ======================== */
  145. async function callAI(messages: MsgInfo[], groupName: string, retries = 2): Promise<AIAnalysisResult | null> {
  146. const conversation = messages
  147. .map((m) => {
  148. const ts = m.timestamp;
  149. const time = ts ? `${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}` : '';
  150. return `[${time}] ${m.senderName || '未知'}: ${m.content}`;
  151. })
  152. .join('\n');
  153. const userPrompt = `群名称: ${groupName}
  154. 消息数: ${messages.length} 条
  155. 参与人数: ${new Set(messages.map(m => m.senderId).filter(Boolean)).size} 人
  156. 以下是该群今日全部消息(按时间顺序):
  157. ---
  158. ${conversation}
  159. ---
  160. 请分析该群今日状态。`;
  161. for (let attempt = 0; attempt <= retries; attempt++) {
  162. try {
  163. const res = await fetch(AI_URL, {
  164. method: 'POST',
  165. headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_KEY}` },
  166. body: JSON.stringify({
  167. model: AI_MODEL,
  168. messages: [
  169. { role: 'system', content: AI_SYSTEM_PROMPT },
  170. { role: 'user', content: userPrompt },
  171. ],
  172. temperature: 0.3,
  173. max_tokens: 512,
  174. }),
  175. });
  176. if (!res.ok) {
  177. const errText = await res.text().catch(() => '');
  178. if (res.status === 429 && attempt < retries) {
  179. console.warn(` ⚠ AI 限流,等待 2s 重试...`);
  180. await sleep(2000);
  181. continue;
  182. }
  183. console.warn(` ⚠ AI API ${res.status}: ${errText.slice(0, 100)}`);
  184. return null;
  185. }
  186. const json = await res.json() as any;
  187. const content = json?.choices?.[0]?.message?.content || '';
  188. const parsed = parseAIResponse(content);
  189. if (!parsed && attempt < retries) {
  190. console.warn(` ⚠ AI 返回格式异常,重试...`);
  191. continue;
  192. }
  193. return parsed;
  194. } catch (err) {
  195. if (attempt < retries) {
  196. console.warn(` ⚠ AI 调用失败,重试...`);
  197. await sleep(1000);
  198. continue;
  199. }
  200. console.warn(` ⚠ AI 调用失败: ${(err as Error).message}`);
  201. return null;
  202. }
  203. }
  204. return null;
  205. }
  206. function parseAIResponse(content: string): AIAnalysisResult | null {
  207. try {
  208. // 尝试直接解析 JSON(可能在 markdown 代码块中)
  209. let jsonStr = content;
  210. const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
  211. if (codeBlockMatch) jsonStr = codeBlockMatch[1];
  212. else {
  213. const braceMatch = content.match(/\{[\s\S]*\}/);
  214. if (braceMatch) jsonStr = braceMatch[0];
  215. }
  216. const parsed = JSON.parse(jsonStr);
  217. return {
  218. hasRisk: !!parsed.hasRisk,
  219. severity: ['high', 'medium', 'low', 'none'].includes(parsed.severity) ? parsed.severity : 'none',
  220. riskTitle: parsed.riskTitle || '',
  221. riskDescription: parsed.riskDescription || '',
  222. sentiment: ['positive', 'neutral', 'negative'].includes(parsed.sentiment) ? parsed.sentiment : 'neutral',
  223. mainTopics: Array.isArray(parsed.mainTopics) ? parsed.mainTopics : [],
  224. hasAbnormal: !!parsed.hasAbnormal,
  225. abnormalDescription: parsed.abnormalDescription || '',
  226. summary: parsed.summary || '分析未返回摘要',
  227. };
  228. } catch {
  229. // 尝试宽松匹配
  230. const hasRisk = /"hasRisk"\s*:\s*true/i.test(content);
  231. const sevMatch = content.match(/"severity"\s*:\s*"(high|medium|low|none)"/);
  232. return {
  233. hasRisk,
  234. severity: (sevMatch?.[1] as any) || 'none',
  235. riskTitle: '',
  236. riskDescription: '',
  237. sentiment: 'neutral',
  238. mainTopics: [],
  239. hasAbnormal: false,
  240. abnormalDescription: '',
  241. summary: content.slice(0, 80),
  242. };
  243. }
  244. }
  245. function sleep(ms: number): Promise<void> {
  246. return new Promise(r => setTimeout(r, ms));
  247. }
  248. /** 并发池:最多 N 个 AI 请求同时进行 */
  249. async function runWithConcurrency<T, R>(
  250. items: T[],
  251. concurrency: number,
  252. fn: (item: T) => Promise<R>,
  253. ): Promise<R[]> {
  254. const results: R[] = new Array(items.length);
  255. let idx = 0;
  256. async function worker(): Promise<void> {
  257. while (idx < items.length) {
  258. const i = idx++;
  259. results[i] = await fn(items[i]);
  260. }
  261. }
  262. await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
  263. return results;
  264. }
  265. /* ======================== 关键词匹配(降级 + 辅助) ======================== */
  266. function matchKeywords(messages: MsgInfo[], keywords: KeywordInfo[]): KeywordMatchDetail[] {
  267. const matchMap = new Map<string, KeywordMatchDetail>();
  268. for (const msg of messages) {
  269. const content = msg.content;
  270. if (!content) continue;
  271. for (const kw of keywords) {
  272. if (content.includes(kw.word)) {
  273. const existing = matchMap.get(kw.word);
  274. const sample = {
  275. senderName: msg.senderName || '未知',
  276. content: content.length > 200 ? content.slice(0, 200) + '...' : content,
  277. time: msg.timestamp
  278. ? `${String(msg.timestamp.getHours()).padStart(2, '0')}:${String(msg.timestamp.getMinutes()).padStart(2, '0')}`
  279. : '',
  280. };
  281. if (existing) {
  282. existing.count++;
  283. if (existing.samples.length < 3) existing.samples.push(sample);
  284. } else {
  285. matchMap.set(kw.word, {
  286. word: kw.word,
  287. category: kw.category,
  288. severity: kw.severity as 'high' | 'medium' | 'low',
  289. count: 1,
  290. samples: [sample],
  291. });
  292. }
  293. }
  294. }
  295. }
  296. return [...matchMap.values()].sort((a, b) => b.count - a.count);
  297. }
  298. /* ======================== 分析引擎 ======================== */
  299. interface PendingGroup {
  300. roomId: string;
  301. roomName: string;
  302. messages: MsgInfo[];
  303. }
  304. async function analyzeGroups(
  305. groups: any[],
  306. allMessages: any[],
  307. keywords: KeywordInfo[],
  308. ): Promise<{ groupReports: GroupReport[]; summary: Summary }> {
  309. // 消息按 roomId 分组
  310. const msgsByRoom = new Map<string, MsgInfo[]>();
  311. for (const m of allMessages) {
  312. const roomId = m.get('roomId') as string;
  313. if (!roomId) continue;
  314. const msg: MsgInfo = {
  315. senderId: m.get('senderId') as string || '',
  316. senderName: m.get('senderName') as string || '',
  317. content: m.get('content') as string || '',
  318. timestamp: m.get('timestamp') as Date,
  319. };
  320. const arr = msgsByRoom.get(roomId);
  321. if (arr) arr.push(msg);
  322. else msgsByRoom.set(roomId, [msg]);
  323. }
  324. // 构建待分析群列表(仅保留有消息的群)
  325. const pendingGroups: PendingGroup[] = [];
  326. for (const g of groups) {
  327. const roomId = g.get('roomId') as string;
  328. const roomName = g.get('roomName') as string || `未知群(${roomId})`;
  329. const msgs = msgsByRoom.get(roomId);
  330. if (msgs && msgs.length > 0) {
  331. pendingGroups.push({ roomId, roomName, messages: msgs });
  332. }
  333. }
  334. console.log(`[日报] 有消息的群: ${pendingGroups.length} 个`);
  335. console.log(`[日报] AI 分析: ${AI_ENABLED ? '启用 (DeepSeek)' : '禁用,使用关键词规则'}`);
  336. if (AI_ENABLED) {
  337. console.log(`[日报] 并发数: ${AI_CONCURRENCY},预计耗时 ${Math.ceil(pendingGroups.length / AI_CONCURRENCY) * 3}s`);
  338. }
  339. // AI 分析(或降级)
  340. const aiResults = AI_ENABLED
  341. ? await runWithConcurrency(pendingGroups, AI_CONCURRENCY, async (pg) => {
  342. console.log(` → AI 分析: ${pg.roomName} (${pg.messages.length} 条消息)`);
  343. const result = await callAI(pg.messages, pg.roomName);
  344. const status = result
  345. ? (result.hasRisk ? `风险:${result.severity}` : '正常')
  346. : 'AI 失败';
  347. console.log(` ← ${pg.roomName}: ${status}`);
  348. return result;
  349. })
  350. : pendingGroups.map(() => null);
  351. // 构建群报告
  352. const groupReports: GroupReport[] = [];
  353. for (let i = 0; i < pendingGroups.length; i++) {
  354. const pg = pendingGroups[i];
  355. const aiResult = aiResults[i];
  356. const msgs = pg.messages;
  357. // 基础指标
  358. const messageCount = msgs.length;
  359. const senderIds = new Set(msgs.map(m => m.senderId).filter(Boolean));
  360. const activeMemberCount = senderIds.size;
  361. // Top 发言人
  362. const senderCount = new Map<string, { name: string; count: number }>();
  363. for (const m of msgs) {
  364. const key = m.senderId || '__unknown__';
  365. const entry = senderCount.get(key);
  366. if (entry) { entry.count++; }
  367. else { senderCount.set(key, { name: m.senderName || m.senderId || '未知', count: 1 }); }
  368. }
  369. const topSpeakers = [...senderCount.values()].sort((a, b) => b.count - a.count).slice(0, 3);
  370. // 单一用户主导
  371. let singleUserDominant = false;
  372. let dominantUserName = '';
  373. let dominantUserRatio = 0;
  374. if (messageCount >= 5 && topSpeakers.length > 0) {
  375. const ratio = topSpeakers[0].count / messageCount;
  376. if (ratio > 0.8) {
  377. singleUserDominant = true;
  378. dominantUserName = topSpeakers[0].name;
  379. dominantUserRatio = ratio;
  380. }
  381. }
  382. // 最后消息时间
  383. let lastMessageTime = '';
  384. const last = msgs[msgs.length - 1].timestamp;
  385. if (last) {
  386. lastMessageTime = `${String(last.getHours()).padStart(2, '0')}:${String(last.getMinutes()).padStart(2, '0')}`;
  387. }
  388. // 关键词匹配(辅助参考)
  389. const keywordMatches = matchKeywords(msgs, keywords);
  390. // 活跃度
  391. let activityLevel: GroupReport['activityLevel'];
  392. if (messageCount > 50) activityLevel = 'high';
  393. else if (messageCount >= 10) activityLevel = 'normal';
  394. else activityLevel = 'low';
  395. // 异常备注(从关键词匹配补充)
  396. const anomalyNotes: string[] = [];
  397. if (singleUserDominant) {
  398. anomalyNotes.push(`单一用户 "${dominantUserName}" 发送了 ${Math.round(dominantUserRatio * 100)}% 的消息`);
  399. }
  400. if (aiResult?.hasAbnormal && aiResult.abnormalDescription) {
  401. anomalyNotes.push(aiResult.abnormalDescription);
  402. }
  403. // 风险等级(AI 优先,关键词降级)
  404. let riskLevel: GroupReport['riskLevel'];
  405. if (aiResult && aiResult.hasRisk) {
  406. if (aiResult.severity === 'high') riskLevel = 'emergency';
  407. else if (aiResult.severity === 'medium') riskLevel = 'abnormal';
  408. else riskLevel = 'normal';
  409. } else if (aiResult && !aiResult.hasRisk) {
  410. riskLevel = anomalyNotes.length > 0 ? 'abnormal' : 'normal';
  411. } else {
  412. // AI 不可用时关键词降级
  413. const hasHigh = keywordMatches.some(k => k.severity === 'high');
  414. const hasMedium = keywordMatches.some(k => k.severity === 'medium');
  415. if (hasHigh) riskLevel = 'emergency';
  416. else if (hasMedium || singleUserDominant) riskLevel = 'abnormal';
  417. else riskLevel = 'normal';
  418. }
  419. groupReports.push({
  420. roomId: pg.roomId,
  421. roomName: pg.roomName,
  422. messageCount,
  423. activeMemberCount,
  424. topSpeakers,
  425. lastMessageTime,
  426. aiResult,
  427. keywordMatches,
  428. activityLevel,
  429. riskLevel,
  430. anomalyNotes,
  431. });
  432. }
  433. // 排序
  434. const order = { emergency: 0, abnormal: 1, normal: 2 };
  435. groupReports.sort((a, b) => {
  436. const oa = order[a.riskLevel];
  437. const ob = order[b.riskLevel];
  438. if (oa !== ob) return oa - ob;
  439. return b.messageCount - a.messageCount;
  440. });
  441. const summary: Summary = {
  442. totalGroups: groupReports.length,
  443. totalMessages: allMessages.length,
  444. emergencyCount: groupReports.filter(g => g.riskLevel === 'emergency').length,
  445. abnormalCount: groupReports.filter(g => g.riskLevel === 'abnormal').length,
  446. normalCount: groupReports.filter(g => g.riskLevel === 'normal').length,
  447. totalActiveMembers: new Set(allMessages.map((m: any) => m.get('senderId')).filter(Boolean)).size,
  448. aiEnabled: AI_ENABLED,
  449. };
  450. return { groupReports, summary };
  451. }
  452. /* ======================== HTML 生成 ======================== */
  453. function categoryLabel(cat: string): string {
  454. const map: Record<string, string> = {
  455. complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律',
  456. competitor: '竞品', price: '价格', negative: '负面', installation: '安装',
  457. };
  458. return map[cat] || cat;
  459. }
  460. function severityBadge(severity: string): string {
  461. const map: Record<string, string> = {
  462. high: '<span class="badge badge-high">高危</span>',
  463. medium: '<span class="badge badge-medium">中危</span>',
  464. low: '<span class="badge badge-low">低危</span>',
  465. };
  466. return map[severity] || '';
  467. }
  468. function riskLabel(level: string): string {
  469. const map: Record<string, string> = {
  470. emergency: '🔴 紧急', abnormal: '🟡 异常', normal: '🟢 正常', silent: '⚪ 静默',
  471. };
  472. return map[level] || level;
  473. }
  474. function activityLabel(level: string): string {
  475. const map: Record<string, string> = {
  476. high: '高活跃', normal: '正常', low: '低活跃', silent: '静默',
  477. };
  478. return map[level] || level;
  479. }
  480. function sentimentEmoji(s: string): string {
  481. const map: Record<string, string> = {
  482. positive: '😊 正面', neutral: '😐 中性', negative: '😟 负面',
  483. };
  484. return map[s] || s;
  485. }
  486. function generateHTML(reports: GroupReport[], summary: Summary, dateStr: string, genTime: string, stats: { queryMs: number; aiMs: number; totalMs: number }): string {
  487. const cards = reports.map(g => {
  488. const borderColor = { emergency: '#e74c3c', abnormal: '#f39c12', normal: '#27ae60' }[g.riskLevel];
  489. // ── AI 分析区块 ──
  490. let aiSection = '';
  491. if (g.aiResult) {
  492. const ar = g.aiResult;
  493. aiSection = `
  494. <div class="ai-block">
  495. <div class="ai-header">🤖 AI 分析</div>
  496. <div class="ai-summary">${escapeHtml(ar.summary)}</div>
  497. <div class="ai-details">
  498. <span class="ai-tag">${sentimentEmoji(ar.sentiment)}</span>
  499. ${ar.mainTopics.map(t => `<span class="ai-tag topic-tag">📌 ${escapeHtml(t)}</span>`).join('')}
  500. </div>
  501. ${ar.hasRisk ? `<div class="ai-risk">
  502. <span class="ai-risk-label">⚠ 风险判定: ${ar.severity === 'high' ? '高危' : ar.severity === 'medium' ? '中危' : '低危'}</span>
  503. ${ar.riskTitle ? `<div class="ai-risk-title">${escapeHtml(ar.riskTitle)}</div>` : ''}
  504. ${ar.riskDescription ? `<div class="ai-risk-desc">${escapeHtml(ar.riskDescription)}</div>` : ''}
  505. </div>` : ''}
  506. </div>`;
  507. } else {
  508. aiSection = `<div class="ai-block ai-fallback">🤖 AI 分析不可用,已降级为关键词规则引擎</div>`;
  509. }
  510. // ── 关键词匹配(辅助)──
  511. let keywordSection = '';
  512. if (g.keywordMatches.length > 0) {
  513. const items = g.keywordMatches.map(k => `
  514. <div class="kw-row">
  515. ${severityBadge(k.severity)}
  516. <span class="kw-word">${escapeHtml(k.word)}</span>
  517. <span class="kw-cat">[${categoryLabel(k.category)}]</span>
  518. <span class="kw-count">×${k.count}</span>
  519. ${k.samples.length > 0 ? `<div class="kw-samples">${k.samples.map(s =>
  520. `<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>`
  521. ).join('')}</div>` : ''}
  522. </div>`
  523. ).join('');
  524. keywordSection = `<div class="section"><div class="section-title">🔍 关键词命中 (${g.keywordMatches.length}) — 辅助参考</div>${items}</div>`;
  525. }
  526. // ── 异常备注 ──
  527. let anomalySection = '';
  528. if (g.anomalyNotes.length > 0) {
  529. anomalySection = `<div class="anomaly-notes">${g.anomalyNotes.map(n => `<div class="anomaly-note">⚠ ${escapeHtml(n)}</div>`).join('')}</div>`;
  530. }
  531. const speakers = g.topSpeakers.length > 0
  532. ? g.topSpeakers.map(s => `<span class="speaker">${escapeHtml(s.name)} <em>(${s.count}条)</em></span>`).join(' ')
  533. : '<span class="no-data">—</span>';
  534. return `
  535. <div class="card card-${g.riskLevel}" style="border-left: 4px solid ${borderColor};">
  536. <div class="card-header">
  537. <span class="group-name">${escapeHtml(g.roomName)}</span>
  538. <span class="risk-badge risk-${g.riskLevel}">${riskLabel(g.riskLevel)}</span>
  539. </div>
  540. <div class="card-body">
  541. <div class="metrics">
  542. <div class="metric"><div class="metric-value">${g.messageCount}</div><div class="metric-label">消息数</div></div>
  543. <div class="metric"><div class="metric-value">${g.activeMemberCount}</div><div class="metric-label">参与人数</div></div>
  544. <div class="metric"><div class="metric-value">${activityLabel(g.activityLevel)}</div><div class="metric-label">活跃度</div></div>
  545. <div class="metric"><div class="metric-value">${g.lastMessageTime || '—'}</div><div class="metric-label">最后消息</div></div>
  546. </div>
  547. ${aiSection}
  548. <div class="section"><div class="section-title">💬 Top 发言人</div><div class="speakers-row">${speakers}</div></div>
  549. ${keywordSection}
  550. ${anomalySection}
  551. </div>
  552. </div>`;
  553. }).join('\n');
  554. const aiStatus = summary.aiEnabled
  555. ? '<span style="color:#27ae60;">● AI 分析 (DeepSeek)</span>'
  556. : '<span style="color:#f39c12;">● 关键词规则引擎 (AI 未配置)</span>';
  557. return `<!DOCTYPE html>
  558. <html lang="zh-CN">
  559. <head>
  560. <meta charset="UTF-8">
  561. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  562. <title>拉迷群消息日报 — ${dateStr}</title>
  563. <style>
  564. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  565. body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f6fa; color: #2c3e50; line-height: 1.6; }
  566. .header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: #fff; padding: 28px 40px; }
  567. .header h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
  568. .header .subtitle { font-size: 14px; color: #8892b0; }
  569. .header .meta { font-size: 12px; color: #5a6785; margin-top: 2px; }
  570. .container { max-width: 1000px; margin: 0 auto; padding: 24px; }
  571. .summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
  572. .summary-card { background: #fff; border-radius: 10px; padding: 20px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
  573. .summary-card .s-value { font-size: 32px; font-weight: 700; line-height: 1.2; }
  574. .summary-card .s-label { font-size: 13px; color: #7f8c8d; margin-top: 4px; }
  575. .s-emergency .s-value { color: #e74c3c; } .s-abnormal .s-value { color: #f39c12; } .s-normal .s-value { color: #27ae60; }
  576. .s-groups .s-value { color: #2980b9; } .s-messages .s-value { color: #8e44ad; } .s-members .s-value { color: #16a085; }
  577. .cards { display: flex; flex-direction: column; gap: 16px; }
  578. .card { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
  579. .card-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fafbfc; border-bottom: 1px solid #eee; }
  580. .group-name { font-size: 16px; font-weight: 600; }
  581. .risk-badge { font-size: 13px; font-weight: 600; padding: 4px 12px; border-radius: 20px; }
  582. .risk-emergency { background: #fde8e8; color: #c0392b; }
  583. .risk-abnormal { background: #fef5e7; color: #d68910; }
  584. .risk-normal { background: #e8f8f5; color: #1e8449; }
  585. .card-body { padding: 16px 20px; }
  586. .metrics { display: flex; gap: 24px; margin-bottom: 14px; flex-wrap: wrap; }
  587. .metric { text-align: center; min-width: 60px; }
  588. .metric-value { font-size: 20px; font-weight: 700; }
  589. .metric-label { font-size: 12px; color: #95a5a6; margin-top: 2px; }
  590. /* AI 分析块 */
  591. .ai-block { background: #f0f4ff; border-radius: 8px; padding: 14px; margin-bottom: 14px; border: 1px solid #d4e0ff; }
  592. .ai-block.ai-fallback { background: #fff9e6; border-color: #ffe0a0; color: #856404; font-size: 13px; }
  593. .ai-header { font-size: 13px; font-weight: 700; color: #4a6fa5; margin-bottom: 6px; }
  594. .ai-summary { font-size: 14px; color: #2c3e50; margin-bottom: 8px; line-height: 1.5; }
  595. .ai-details { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
  596. .ai-tag { font-size: 12px; padding: 2px 8px; border-radius: 12px; background: #e8ecf4; color: #4a6fa5; }
  597. .topic-tag { background: #e8f8f5; color: #1e8449; }
  598. .ai-risk { background: #fff5f5; border: 1px solid #fecaca; border-radius: 6px; padding: 10px; margin-top: 8px; }
  599. .ai-risk-label { font-size: 13px; font-weight: 600; color: #c0392b; }
  600. .ai-risk-title { font-size: 14px; font-weight: 700; color: #e74c3c; margin-top: 4px; }
  601. .ai-risk-desc { font-size: 13px; color: #555; margin-top: 2px; }
  602. .section { margin-bottom: 12px; }
  603. .section-title { font-size: 13px; font-weight: 600; color: #7f8c8d; margin-bottom: 6px; }
  604. .speakers-row { display: flex; gap: 12px; flex-wrap: wrap; }
  605. .speaker { font-size: 13px; background: #f0f3f5; padding: 3px 10px; border-radius: 12px; }
  606. .speaker em { font-style: normal; color: #7f8c8d; font-size: 12px; }
  607. .no-data { color: #bdc3c7; font-size: 13px; }
  608. /* 关键词 */
  609. .kw-row { font-size: 13px; margin-bottom: 8px; }
  610. .kw-row > .badge, .kw-row > .kw-word, .kw-row > .kw-cat, .kw-row > .kw-count { display: inline; margin-right: 4px; }
  611. .kw-word { font-weight: 600; }
  612. .kw-cat { color: #7f8c8d; font-size: 12px; }
  613. .kw-count { color: #c0392b; font-weight: 600; }
  614. .kw-samples { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
  615. .kw-sample-msg { background: #fff; border-radius: 4px; padding: 6px 8px; font-size: 12px; border-left: 3px solid #e74c3c; }
  616. .kw-sample-sender { font-weight: 600; margin-right: 6px; }
  617. .kw-sample-time { color: #95a5a6; font-size: 11px; }
  618. .kw-sample-content { color: #555; margin-top: 2px; word-break: break-all; }
  619. .badge { font-size: 11px; font-weight: 700; padding: 2px 6px; border-radius: 4px; color: #fff; }
  620. .badge-high { background: #e74c3c; } .badge-medium { background: #f39c12; } .badge-low { background: #3498db; }
  621. .anomaly-notes { margin-top: 8px; }
  622. .anomaly-note { font-size: 13px; color: #d68910; background: #fef9e7; padding: 8px 12px; border-radius: 6px; margin-bottom: 4px; }
  623. @media (max-width: 768px) {
  624. .header { padding: 20px; } .container { padding: 12px; }
  625. .summary-row { grid-template-columns: repeat(3, 1fr); gap: 8px; }
  626. .summary-card { padding: 12px; } .summary-card .s-value { font-size: 24px; }
  627. .metrics { gap: 14px; }
  628. }
  629. @media print {
  630. body { background: #fff; }
  631. .card { box-shadow: none; break-inside: avoid; border: 1px solid #ddd; }
  632. .header { background: #1a1a2e !important; -webkit-print-color-adjust: exact; }
  633. }
  634. </style>
  635. </head>
  636. <body>
  637. <div class="header">
  638. <h1>拉迷群消息日报</h1>
  639. <div class="subtitle">报告日期: ${dateStr} — 生成时间: ${genTime} &nbsp;|&nbsp; ${aiStatus}</div>
  640. <div class="meta">查询: ${stats.queryMs}ms &nbsp;|&nbsp; AI 分析: ${stats.aiMs}ms &nbsp;|&nbsp; 总耗时: ${stats.totalMs}ms</div>
  641. </div>
  642. <div class="container">
  643. <div class="summary-row">
  644. <div class="summary-card s-groups"><div class="s-value">${summary.totalGroups}</div><div class="s-label">活跃群组</div></div>
  645. <div class="summary-card s-messages"><div class="s-value">${summary.totalMessages}</div><div class="s-label">当日消息</div></div>
  646. <div class="summary-card s-members"><div class="s-value">${summary.totalActiveMembers}</div><div class="s-label">参与用户</div></div>
  647. <div class="summary-card s-emergency"><div class="s-value">${summary.emergencyCount}</div><div class="s-label">🔴 紧急</div></div>
  648. <div class="summary-card s-abnormal"><div class="s-value">${summary.abnormalCount}</div><div class="s-label">🟡 异常</div></div>
  649. <div class="summary-card s-normal"><div class="s-value">${summary.normalCount}</div><div class="s-label">🟢 正常</div></div>
  650. </div>
  651. <div class="cards">${cards}</div>
  652. ${reports.length === 0 ? '<div style="text-align:center;padding:60px;color:#95a5a6;">今日无群消息</div>' : ''}
  653. </div>
  654. </body>
  655. </html>`;
  656. }
  657. function escapeHtml(s: string): string {
  658. return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  659. }
  660. /* ======================== 主流程 ======================== */
  661. function parseTargetDate(): string {
  662. const arg = process.argv[2];
  663. if (arg) {
  664. if (!/^\d{4}-\d{2}-\d{2}$/.test(arg)) {
  665. console.error('日期格式错误,请使用 YYYY-MM-DD,例如: 2026-06-15');
  666. process.exit(1);
  667. }
  668. return arg;
  669. }
  670. const now = new Date();
  671. return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
  672. }
  673. async function main(): Promise<void> {
  674. const t0 = Date.now();
  675. const targetDate = parseTargetDate();
  676. console.log('═══════════════════════════════════════');
  677. console.log(' 拉迷群消息日报 (AI 分析版)');
  678. console.log(` 日期: ${targetDate}`);
  679. console.log(` AI: ${AI_ENABLED ? `DeepSeek (${AI_MODEL})` : '禁用'}`);
  680. console.log('═══════════════════════════════════════\n');
  681. // 1. 查询
  682. const t1 = Date.now();
  683. const { groups, allMessages, keywords } = await fetchData(targetDate);
  684. const queryMs = Date.now() - t1;
  685. // 2. AI 分析
  686. const t2 = Date.now();
  687. console.log('\n[日报] 开始 AI 分析...');
  688. const { groupReports, summary } = await analyzeGroups(groups, allMessages, keywords);
  689. const aiMs = Date.now() - t2;
  690. // 3. 控制台摘要
  691. console.log('\n═══════════════════════════════════════');
  692. console.log(' 分析结果');
  693. console.log('═══════════════════════════════════════');
  694. console.log(` 有消息群数: ${summary.totalGroups}`);
  695. console.log(` 总消息: ${summary.totalMessages}`);
  696. console.log(` 参与用户: ${summary.totalActiveMembers}`);
  697. console.log(` 🔴 紧急: ${summary.emergencyCount} 🟡 异常: ${summary.abnormalCount} 🟢 正常: ${summary.normalCount}`);
  698. if (summary.emergencyCount > 0) {
  699. console.log('\n ⚠️ 紧急群:');
  700. for (const g of groupReports.filter(g => g.riskLevel === 'emergency')) {
  701. const ai = g.aiResult;
  702. console.log(` - ${g.roomName}: ${ai?.riskTitle || 'AI 分析异常'}`);
  703. }
  704. }
  705. if (summary.abnormalCount > 0) {
  706. console.log('\n ⚡ 异常群:');
  707. for (const g of groupReports.filter(g => g.riskLevel === 'abnormal')) {
  708. const parts = [...g.anomalyNotes];
  709. if (g.aiResult?.hasRisk) parts.push(`AI: ${g.aiResult.riskTitle}`);
  710. console.log(` - ${g.roomName}: ${parts.join(' | ')}`);
  711. }
  712. }
  713. // 4. 生成 HTML
  714. const totalMs = Date.now() - t0;
  715. const now = new Date();
  716. const genTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
  717. const html = generateHTML(groupReports, summary, targetDate, genTime, { queryMs, aiMs, totalMs });
  718. const outputPath = path.resolve(__dirname, `group-report-${targetDate}.html`);
  719. fs.writeFileSync(outputPath, html, 'utf-8');
  720. console.log(`\n✅ 报告已生成: ${outputPath}`);
  721. console.log(` 文件大小: ${(Buffer.byteLength(html, 'utf-8') / 1024).toFixed(1)} KB`);
  722. console.log(` 查询: ${queryMs}ms | AI: ${aiMs}ms | 总计: ${totalMs}ms\n`);
  723. }
  724. main().catch((err) => {
  725. console.error('[日报] 失败:', err);
  726. process.exit(1);
  727. });