diff --git a/README.md b/README.md index 9b0f3be..27661a4 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,38 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结 是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。 +## 项目说明 + +本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。 + +在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。 + +## ✨ 功能特性 + +- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。 +- **全局搜索**: 快速搜索聊天内容。 +- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。 +- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容: + - **今日讨论热点**: 梳理群内主要话题,支持热度标签。 + - **实用信息与资源**: 提取分享的链接、资源等信息。 + - **重要消息汇总**: 标记并展示重要消息,带发送者头像。 + - **有趣对话或金句**: 收录群内的精彩对话。 + - **问题与解答**: 整理群内的问答内容。 + - **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。 + - **词云/关键词**: 可视化展示群聊关键词。 +- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。 +- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。 +- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。 + ## 📸 预览 - 预览图1 - 预览图2 - 预览图3 +### 群聊总结长图 + +总结图片 + +### 软件主页面 + +软件主页面 ## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases) @@ -18,22 +45,13 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结 3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往: `系统设置 -> 隐私与安全性 -> 仍要打开`。 -## ✨ 功能特性 - -- **聊天记录查看**: 浏览微信好友和群聊的聊天记录。 -- **全局搜索**: 快速搜索聊天内容。 -- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。 -- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。 -- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。 -- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。 - ## 🚀 快速开始 ### 使用前置要求 -- **微信版本**: 推荐使用微信 **4.0 以下**版本(4.0以上版本不支持数据库解密) +- **微信版本**: - 微信 <= 4.0: 可正常使用,获取数据库密码方式参考:[Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/) - - 微信 >= 4.0: 可正常使用 (正在迭代) ,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog) + - 微信 >= 4.0: 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog) - 如无法获取本地数据库密码,则无法使用当前项目 - Node.js (推荐 v16+) - pnpm@7 @@ -44,15 +62,15 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结 可选配置项,可在 `.env` 文件中设置: -| 变量名 | 说明 | 示例 | -|--------|------|------| -| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` | -| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` | -| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` | -| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` | -| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` | -| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` | -| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` | +| 变量名 | 说明 | 示例 | +| ----------------------- | --------------------------- | --------------------------- | +| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` | +| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` | +| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` | +| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` | +| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` | +| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` | +| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` | #### 图片解密密钥说明 @@ -62,6 +80,7 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结 - **AES Key**: 16字符字符串,用于 AES-128-ECB 解密 这两个密钥可以通过以下方式获取: + 1. 从 WeFlow/Chatlog 设置中导出 2. 使用内存扫描工具从微信进程中自动提取(待实现) diff --git a/public/example1.png b/public/example1.png index be3b950..8f4882b 100644 Binary files a/public/example1.png and b/public/example1.png differ diff --git a/public/example2.png b/public/example2.png index 69efd60..0a741f7 100644 Binary files a/public/example2.png and b/public/example2.png differ diff --git a/public/example3.png b/public/example3.png deleted file mode 100644 index 143a942..0000000 Binary files a/public/example3.png and /dev/null differ diff --git a/src/main/index.ts b/src/main/index.ts index 205bdf1..04cd9b1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -109,14 +109,22 @@ app.whenReady().then(() => { try { const trimmedKey = String(key || '').trim() console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`) - wechatDb = new WechatDb(key) - const wcdb4Client = wechatDb.getWcdb4Client() + const nextWechatDb = new WechatDb(key) + wechatDb?.close() + wechatDb = nextWechatDb + const wcdb4Client = nextWechatDb.getWcdb4Client() + let monitoring = false if (wcdb4Client) { voiceService = new VoiceService(wcdb4Client) stickerService = new StickerService(wcdb4Client) + monitoring = wcdb4Client.startMonitor((type, json) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json }) + } + }) } imageDecryptService = null - return { success: true } + return { success: true, monitoring } } catch (error) { console.error('Failed to init DB:', error) return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -453,3 +461,8 @@ app.on('window-all-closed', () => { app.quit() } }) + +app.on('before-quit', () => { + wechatDb?.close() + wechatDb = null +}) diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index e021b79..2aa8ef7 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -3,6 +3,7 @@ import path from 'path' import os from 'os' import crypto from 'crypto' import { createRequire } from 'module' +import { createConnection, Socket } from 'net' export interface Wcdb4Session { username: string @@ -132,6 +133,15 @@ export class Wcdb4Client { private wcdbGetEmoticonCdnUrl: | ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number) | null = null + private wcdbStartMonitorPipe: (() => number) | null = null + private wcdbStopMonitorPipe: (() => void) | null = null + private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null + private monitorPipeClient: Socket | null = null + private monitorCallback: ((type: string, json: string) => void) | null = null + private monitorConnectTimer: ReturnType | null = null + private monitorReconnectTimer: ReturnType | null = null + private monitorPipePath = '' + private monitorStarted = false constructor(key: string, accountRoot?: string) { this.key = key.replace(/^0x/i, '').trim() @@ -225,6 +235,7 @@ export class Wcdb4Client { } close(): void { + this.stopMonitor() if (!this.initialized || !this.wcdbShutdown) return try { @@ -241,6 +252,142 @@ export class Wcdb4Client { this.groupNicknameCache.clear() } + startMonitor(callback: (type: string, json: string) => void): boolean { + if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false + + this.stopMonitor() + this.monitorCallback = callback + + try { + const startResult = this.wcdbStartMonitorPipe() + if (startResult !== 0) { + this.monitorCallback = null + console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`) + return false + } + this.monitorStarted = true + + const outName: WcdbVoidOut = [null] + const nameResult = this.wcdbGetMonitorPipeName(outName) + if (nameResult !== 0 || !outName[0]) { + console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`) + this.stopMonitor() + return false + } + + try { + this.monitorPipePath = this.koffi.decode(outName[0], 'char', -1).trim() + } finally { + this.wcdbFreeString?.(outName[0]) + } + + if (!this.monitorPipePath) { + this.stopMonitor() + return false + } + + this.connectMonitorPipe() + return true + } catch (error) { + console.warn('[WCDB4] 启动数据库监听失败:', error) + this.stopMonitor() + return false + } + } + + stopMonitor(): void { + this.monitorCallback = null + + if (this.monitorConnectTimer) { + clearTimeout(this.monitorConnectTimer) + this.monitorConnectTimer = null + } + if (this.monitorReconnectTimer) { + clearTimeout(this.monitorReconnectTimer) + this.monitorReconnectTimer = null + } + if (this.monitorPipeClient) { + this.monitorPipeClient.destroy() + this.monitorPipeClient = null + } + if (this.monitorStarted && this.wcdbStopMonitorPipe) { + try { + this.wcdbStopMonitorPipe() + } catch { + // Native monitor cleanup is best-effort during reconnect or shutdown. + } + } + + this.monitorStarted = false + this.monitorPipePath = '' + } + + private connectMonitorPipe(): void { + if (!this.monitorCallback || !this.monitorPipePath || this.monitorConnectTimer) return + + this.monitorConnectTimer = setTimeout(() => { + this.monitorConnectTimer = null + if (!this.monitorCallback || !this.monitorPipePath || this.monitorPipeClient) return + + const socket = createConnection(this.monitorPipePath) + this.monitorPipeClient = socket + let buffer = '' + + socket.on('data', (data) => { + const normalizedChunk = data + .toString('utf8') + .split('\0') + .join('\n') + .replace(/}\s*{/g, '}\n{') + buffer += normalizedChunk + + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() || '' + for (const line of lines) this.emitMonitorPayload(line) + + const tail = buffer.trim() + if (tail.startsWith('{') && tail.endsWith('}')) { + try { + JSON.parse(tail) + this.emitMonitorPayload(tail) + buffer = '' + } catch { + // Keep the partial payload until the next socket chunk arrives. + } + } + }) + + socket.on('error', (error) => { + console.warn('[WCDB4] 数据库监听管道异常:', error.message) + }) + + socket.on('close', () => { + if (this.monitorPipeClient === socket) this.monitorPipeClient = null + this.scheduleMonitorReconnect() + }) + }, 100) + } + + private emitMonitorPayload(rawPayload: string): void { + const payload = rawPayload.trim() + if (!payload || !this.monitorCallback) return + + try { + const parsed = JSON.parse(payload) as { action?: string } + this.monitorCallback(parsed.action || 'update', payload) + } catch { + this.monitorCallback('update', payload) + } + } + + private scheduleMonitorReconnect(): void { + if (this.monitorReconnectTimer || !this.monitorCallback || !this.monitorPipePath) return + this.monitorReconnectTimer = setTimeout(() => { + this.monitorReconnectTimer = null + this.connectMonitorPipe() + }, 3000) + } + getSessions(): Wcdb4Session[] { if (this.cachedSessions) return this.cachedSessions if (!this.wcdbGetSessions) return [] @@ -768,6 +915,19 @@ export class Wcdb4Client { console.warn('[WCDB4] wcdb_get_emoticon_cdn_url symbol unavailable') this.wcdbGetEmoticonCdnUrl = null } + + try { + this.wcdbStartMonitorPipe = lib.func('int32 wcdb_start_monitor_pipe()') as () => number + this.wcdbStopMonitorPipe = lib.func('void wcdb_stop_monitor_pipe()') as () => void + this.wcdbGetMonitorPipeName = lib.func( + 'int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)' + ) as (outName: WcdbVoidOut) => number + } catch { + console.warn('[WCDB4] monitor pipe symbols unavailable') + this.wcdbStartMonitorPipe = null + this.wcdbStopMonitorPipe = null + this.wcdbGetMonitorPipeName = null + } } private initProtection(lib: KoffiLibrary, libDir: string): void { @@ -974,7 +1134,7 @@ export class Wcdb4Client { ]) return { - mesLocalID: localId || `${createTime}-${crypto.randomUUID()}`, + mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`, mesDes: isSend ? 0 : 1, messageType: messageType || '1', msgCreateTime: String(createTime), diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index 9e5ad7c..1baf3fe 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -364,6 +364,11 @@ export class WechatDb { return this.wcdb4Client } + public close(): void { + this.wcdb4Client?.close() + this.wcdb4Client = null + } + public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] { if (this.wcdb4Client) { const username = this.chatMd5ToUsername.get(userMd5) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 79dd4f3..31c806a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -34,7 +34,9 @@ declare global { interface Window { electron: ElectronAPI api: { - initDb: (key: string) => Promise + initDb: ( + key: string + ) => Promise getContacts: (filter?: string) => Promise getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise search: (keyword: string) => Promise @@ -72,6 +74,7 @@ declare global { }> pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }> clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> + onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 13cba50..eba7b65 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,6 +28,14 @@ const api = { autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'), clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'), + onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { type: string; json: string } + ): void => callback(payload) + ipcRenderer.on('wcdb-change', listener) + return () => ipcRenderer.removeListener('wcdb-change', listener) + }, onDbKeyStatus: (callback: (payload: { message: string }) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void => callback(payload) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index befbb1b..51655b1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,6 +4,22 @@ import ChatWindow from './components/ChatWindow' import { Contact, Message } from '../../shared/types' const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' +const MESSAGE_MONITOR_DEBOUNCE_MS = 250 + +const getMessageIdentity = (message: Message): string => { + if (message.localId) return `local:${message.localId}` + if (message.id) return `id:${message.id}` + return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}` +} + +const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { + if (left === right) return true + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index += 1) { + if (getMessageIdentity(left[index]) !== getMessageIdentity(right[index])) return false + } + return true +} function App(): React.ReactElement { const [isAuthenticated, setIsAuthenticated] = useState(false) @@ -19,6 +35,7 @@ function App(): React.ReactElement { const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal') const [showDbKey, setShowDbKey] = useState(false) const [showMacKeyFaq, setShowMacKeyFaq] = useState(false) + const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) React.useEffect(() => { let active = true @@ -54,6 +71,7 @@ function App(): React.ReactElement { const result = await window.api.initDb(keyToUse) const success = typeof result === 'boolean' ? result : result.success if (success) { + setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) setIsAuthenticated(true) loadContacts() } else { @@ -162,19 +180,52 @@ function App(): React.ReactElement { const handleDateRangeChange = (range: string): void => { setDateRange(range) - // 如果选择了联系人,则使用新范围重新加载消息 if (selectedContact) { - // 需要调用 handleSelectContact,但它需要一个联系人对象。 - // 由于状态更新是异步的,可能需要使用状态中的当前联系人, - // 此函数内部 'selectedContact' 可从闭包中获得。 - // 但是,需要确保 'dateRange' 已更新。 - // 实际上,就在这里使用新范围手动触发获取。 - const { startTime, endTime } = getDateRangeParams(range) window.api.getMessages(selectedContact.md5, startTime, endTime).then(setMessages) } } + React.useEffect(() => { + if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return + + let disposed = false + let refreshTimer: number | null = null + const contactMd5 = selectedContact.md5 + + const refreshCurrentConversation = async (): Promise => { + try { + const range = getDateRangeParams(dateRange) + const latestMessages = await window.api.getMessages( + contactMd5, + range.startTime, + range.endTime + ) + if (!disposed) { + setMessages((current) => + areMessagesEquivalent(current, latestMessages) ? current : latestMessages + ) + } + } catch (error) { + console.warn('[MessageMonitor] 刷新当前会话失败:', error) + } + } + + const unsubscribe = window.api.onWcdbChange(() => { + if (refreshTimer) window.clearTimeout(refreshTimer) + refreshTimer = window.setTimeout(() => { + refreshTimer = null + void refreshCurrentConversation() + }, MESSAGE_MONITOR_DEBOUNCE_MS) + }) + + return () => { + disposed = true + if (refreshTimer) window.clearTimeout(refreshTimer) + unsubscribe() + } + }, [dateRange, isAuthenticated, isNativeMonitorActive, selectedContact]) + const handleSearchContacts = (keyword: string): void => { if (!keyword) { setFilteredContacts(contacts)