新的提交

This commit is contained in:
cc
2026-01-10 13:01:37 +08:00
commit 01641834de
188 changed files with 34865 additions and 0 deletions

36
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build and Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Dependencies
run: npm install
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx electron-builder --publish always

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
dist
dist-electron
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Build output
out
release
# Database
*.db
*.db-shm
*.db-wal
# Environment
.env
.env.local
.env.production
# OS
Thumbs.db
# 忽略 Visual Studio 临时文件夹
.vs/
# 忽略 IntelliSense 缓存文件
*.ipch
*.aps
wcdb/

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
registry=https://registry.npmmirror.com
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

141
LICENSE Normal file
View File

@@ -0,0 +1,141 @@
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Section 1 Definitions.
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
i. NonCommercial means not intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
j. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
k. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
l. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
Section 2 Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
B. produce, reproduce, and Share Adapted Material for NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
3. Term. The term of this Public License is specified in Section 6(a).
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
5. Downstream recipients.
A. Offer from the Licensor Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. Additional offer from the Licensor Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter's License You apply.
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this Public License.
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used for other than NonCommercial purposes.
Section 3 License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified form), You must:
A. retain the following if it is supplied by the Licensor with the Licensed Material:
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of warranties;
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights and Sui Generis Database Rights apply to Your use of the Licensed Material.
Section 5 Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
Section 6 Term and Termination.
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
Section 7 Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
Section 8 Interpretation.
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon or waiver of any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.

96
README.md Normal file
View File

@@ -0,0 +1,96 @@
# WeFlow
WeFlow 是一个**完全本地**的微信聊天记录查看与分析工具,支持聊天检索、统计分析、群聊画像与年度报告。所有数据均在本地处理,不会上传到任何服务器。
---
<p align="center">
<img src="app.png" alt="WeFlow" width="90%">
</p>
---
<p align="center">
<a href="https://github.com/hicccc77/WeFlow/stargazers">
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square" alt="Stargazers">
</a>
<a href="https://github.com/hicccc77/WeFlow/network/members">
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
</a>
<a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
</a>
<a href="https://github.com/hicccc77/WeFlow/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/hicccc77/WeFlow?style=flat-square" alt="License">
</a>
</p>
## 主要功能
- 本地查看与搜索聊天记录
- 统计分析与群聊画像
- 年度报告与可视化概览
- 导出聊天记录为 HTML 等格式
- 本地解密与数据库管理
## 快速开始
若你只想使用成品版本,可前往 Release 下载并解压运行。
## 面向开发者
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
```bash
# 1. 克隆项目到本地
git clone https://github.com/hicccc77/WeFlow.git
cd WeFlow
# 2. 安装项目依赖
npm install
# 3. 运行应用(开发模式)
npm run dev
# 4. 打包可执行文件
npm run build
```
打包产物在 `release` 目录下。
## 技术栈
- **前端**: React 19 + TypeScript + Zustand
- **桌面**: Electron 39
- **构建**: Vite + electron-builder
- **数据库**: better-sqlite3 + WCDB DLL
- **样式**: SCSS + CSS Variables
## 项目结构
```
WeFlow/
├── electron/ # Electron 主进程
│ ├── main.ts # 主进程入口
│ ├── preload.ts # 预加载脚本
│ └── services/ # 后端服务
│ ├── chatService.ts # 聊天数据服务
│ ├── wcdbService.ts # 数据库服务
│ ├── decryptService.ts # 解密服务
│ └── ...
├── src/ # React 前端
│ ├── components/ # 通用组件
│ ├── pages/ # 页面组件
│ ├── stores/ # Zustand 状态管理
│ ├── services/ # 前端服务
│ └── types/ # TypeScript 类型定义
├── public/ # 静态资源
└── resources/ # 打包资源
```
## 注意事项
- 仅支持 Windows 系统
- 需要微信 4.x 版本
- 所有数据仅在本地处理,不会上传到任何服务器
- 请负责任地使用本工具,遵守相关法律法规

BIN
app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

View File

@@ -0,0 +1,43 @@
import { parentPort, workerData } from 'worker_threads'
import { wcdbService } from './services/wcdbService'
import { annualReportService } from './services/annualReportService'
interface WorkerConfig {
year: number
dbPath: string
decryptKey: string
myWxid: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
}
const config = workerData as WorkerConfig
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
async function run() {
const result = await annualReportService.generateReportWithConfig({
year: config.year,
dbPath: config.dbPath,
decryptKey: config.decryptKey,
wxid: config.myWxid,
onProgress: (status: string, progress: number) => {
parentPort?.postMessage({
type: 'annualReport:progress',
data: { status, progress }
})
}
})
parentPort?.postMessage({ type: 'annualReport:result', data: result })
}
run().catch((err) => {
parentPort?.postMessage({ type: 'annualReport:error', error: String(err) })
})

View File

@@ -0,0 +1,156 @@
import { parentPort, workerData } from 'worker_threads'
import { readdirSync, statSync } from 'fs'
import { join } from 'path'
type WorkerPayload = {
root: string
datName: string
maxDepth: number
allowThumbnail: boolean
thumbOnly: boolean
}
type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean }
const payload = workerData as WorkerPayload
function looksLikeMd5(value: string): boolean {
return /^[a-fA-F0-9]{16,32}$/.test(value)
}
function hasXVariant(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
function hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
function isLikelyImageDatBase(baseLower: string): boolean {
return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower)
}
function normalizeDatBase(name: string): string {
let base = name.toLowerCase()
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
base = base.slice(0, -4)
}
while (/[._][a-z]$/.test(base)) {
base = base.slice(0, -2)
}
return base
}
function matchesDatName(fileName: string, datName: string): boolean {
const lower = fileName.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = normalizeDatBase(base)
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
if (normalizedBase === normalizedTarget) return true
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`)
if (pattern.test(lower)) return true
return lower.endsWith('.dat') && lower.includes(datName)
}
function scoreDatName(fileName: string): number {
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
return 2
}
function isThumbnailDat(fileName: string): boolean {
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
}
function walkForDat(
root: string,
datName: string,
maxDepth = 4,
allowThumbnail = true,
thumbOnly = false
): { path: string | null; matchedBases: string[] } {
const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
const candidates: Candidate[] = []
const matchedBases = new Set<string>()
while (stack.length) {
const current = stack.pop() as { dir: string; depth: number }
let entries: string[]
try {
entries = readdirSync(current.dir)
} catch {
continue
}
for (const entry of entries) {
const entryPath = join(current.dir, entry)
let stat
try {
stat = statSync(entryPath)
} catch {
continue
}
if (stat.isDirectory()) {
if (current.depth < maxDepth) {
stack.push({ dir: entryPath, depth: current.depth + 1 })
}
continue
}
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
const baseLower = lower.slice(0, -4)
if (!isLikelyImageDatBase(baseLower)) continue
if (!hasXVariant(baseLower)) continue
if (!matchesDatName(lower, datName)) continue
matchedBases.add(baseLower)
const isThumb = isThumbnailDat(lower)
if (!allowThumbnail && isThumb) continue
if (thumbOnly && !isThumb) continue
const score = scoreDatName(lower)
candidates.push({
score,
path: entryPath,
isThumb,
hasX: hasXVariant(baseLower)
})
}
}
if (!candidates.length) {
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
}
const withX = candidates.filter((item) => item.hasX)
const basePool = withX.length ? withX : candidates
const nonThumb = basePool.filter((item) => !item.isThumb)
const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool)
let best: { score: number; path: string } | null = null
for (const item of finalPool) {
if (!best || item.score > best.score) {
best = { score: item.score, path: item.path }
}
}
return { path: best?.path ?? null, matchedBases: Array.from(matchedBases).slice(0, 20) }
}
function run() {
const result = walkForDat(
payload.root,
payload.datName,
payload.maxDepth,
payload.allowThumbnail,
payload.thumbOnly
)
parentPort?.postMessage({
type: 'done',
path: result.path,
root: payload.root,
datName: payload.datName,
matchedBases: result.matchedBases
})
}
try {
run()
} catch (err) {
parentPort?.postMessage({ type: 'error', error: String(err) })
}

703
electron/main.ts Normal file
View File

@@ -0,0 +1,703 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
import { Worker } from 'worker_threads'
import { join } from 'path'
import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { ConfigService } from './services/config'
import { dbPathService } from './services/dbPathService'
import { wcdbService } from './services/wcdbService'
import { chatService } from './services/chatService'
import { imageDecryptService } from './services/imageDecryptService'
import { imagePreloadService } from './services/imagePreloadService'
import { analyticsService } from './services/analyticsService'
import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService'
import { KeyService } from './services/keyService'
// 配置自动更新
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === 'true' ||
process.env.AUTO_UPDATE_ENABLED === '1' ||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
// 单例服务
let configService: ConfigService | null = null
// 协议窗口实例
let agreementWindow: BrowserWindow | null = null
let onboardingWindow: BrowserWindow | null = null
const keyService = new KeyService()
let mainWindowReady = false
let shouldShowMain = true
function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const win = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1000,
minHeight: 700,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#1a1a1a',
height: 40
},
show: false
})
// 窗口准备好后显示
win.once('ready-to-show', () => {
mainWindowReady = true
if (autoShow || shouldShowMain) {
win.show()
}
})
// 开发环境加载 vite 服务器
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL)
// 开发环境下按 F12 或 Ctrl+Shift+I 打开开发者工具
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'))
}
return win
}
/**
* 创建用户协议窗口
*/
function createAgreementWindow() {
// 如果已存在,聚焦
if (agreementWindow && !agreementWindow.isDestroyed()) {
agreementWindow.focus()
return agreementWindow
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const isDark = nativeTheme.shouldUseDarkColors
agreementWindow = new BrowserWindow({
width: 700,
height: 600,
minWidth: 500,
minHeight: 400,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: isDark ? '#FFFFFF' : '#333333',
height: 32
},
show: false,
backgroundColor: isDark ? '#1A1A1A' : '#FFFFFF'
})
agreementWindow.once('ready-to-show', () => {
agreementWindow?.show()
})
if (process.env.VITE_DEV_SERVER_URL) {
agreementWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/agreement-window`)
} else {
agreementWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/agreement-window' })
}
agreementWindow.on('closed', () => {
agreementWindow = null
})
return agreementWindow
}
/**
* 创建首次引导窗口
*/
function createOnboardingWindow() {
if (onboardingWindow && !onboardingWindow.isDestroyed()) {
onboardingWindow.focus()
return onboardingWindow
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
onboardingWindow = new BrowserWindow({
width: 1100,
height: 720,
minWidth: 900,
minHeight: 600,
frame: false,
transparent: true,
backgroundColor: '#00000000',
hasShadow: false,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
show: false
})
onboardingWindow.once('ready-to-show', () => {
onboardingWindow?.show()
})
if (process.env.VITE_DEV_SERVER_URL) {
onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/onboarding-window`)
} else {
onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/onboarding-window' })
}
onboardingWindow.on('closed', () => {
onboardingWindow = null
})
return onboardingWindow
}
function showMainWindow() {
shouldShowMain = true
if (mainWindowReady) {
mainWindow?.show()
}
}
// 注册 IPC 处理器
function registerIpcHandlers() {
// 配置相关
ipcMain.handle('config:get', async (_, key: string) => {
return configService?.get(key as any)
})
ipcMain.handle('config:set', async (_, key: string, value: any) => {
return configService?.set(key as any, value)
})
ipcMain.handle('config:clear', async () => {
configService?.clear()
return true
})
// 文件对话框
ipcMain.handle('dialog:openFile', async (_, options) => {
const { dialog } = await import('electron')
return dialog.showOpenDialog(options)
})
ipcMain.handle('dialog:openDirectory', async (_, options) => {
const { dialog } = await import('electron')
return dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
...options
})
})
ipcMain.handle('dialog:saveFile', async (_, options) => {
const { dialog } = await import('electron')
return dialog.showSaveDialog(options)
})
ipcMain.handle('shell:openPath', async (_, path: string) => {
const { shell } = await import('electron')
return shell.openPath(path)
})
ipcMain.handle('shell:openExternal', async (_, url: string) => {
const { shell } = await import('electron')
return shell.openExternal(url)
})
ipcMain.handle('app:getDownloadsPath', async () => {
return app.getPath('downloads')
})
ipcMain.handle('app:getVersion', async () => {
return app.getVersion()
})
ipcMain.handle('log:getPath', async () => {
return join(app.getPath('userData'), 'logs', 'wcdb.log')
})
ipcMain.handle('log:read', async () => {
try {
const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
const content = await readFile(logPath, 'utf8')
return { success: true, content }
} catch (e) {
return { success: false, error: String(e) }
}
})
ipcMain.handle('app:checkForUpdates', async () => {
if (!AUTO_UPDATE_ENABLED) {
return { hasUpdate: false }
}
try {
const result = await autoUpdater.checkForUpdates()
if (result && result.updateInfo) {
const currentVersion = app.getVersion()
const latestVersion = result.updateInfo.version
if (latestVersion !== currentVersion) {
return {
hasUpdate: true,
version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes as string || ''
}
}
}
return { hasUpdate: false }
} catch (error) {
console.error('检查更新失败:', error)
return { hasUpdate: false }
}
})
ipcMain.handle('app:downloadAndInstall', async (event) => {
if (!AUTO_UPDATE_ENABLED) {
throw new Error('自动更新已暂时禁用')
}
const win = BrowserWindow.fromWebContents(event.sender)
// 监听下载进度
autoUpdater.on('download-progress', (progress) => {
win?.webContents.send('app:downloadProgress', progress.percent)
})
// 下载完成后自动安装
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall(false, true)
})
try {
await autoUpdater.downloadUpdate()
} catch (error) {
console.error('下载更新失败:', error)
throw error
}
})
// 窗口控制
ipcMain.on('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.on('window:maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win?.isMaximized()) {
win.unmaximize()
} else {
win?.maximize()
}
})
ipcMain.on('window:close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close()
})
// 更新窗口控件主题色
ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
try {
win.setTitleBarOverlay({
color: '#00000000',
symbolColor: options.symbolColor,
height: 40
})
} catch (error) {
console.warn('TitleBarOverlay not enabled for this window:', error)
}
}
})
// 数据库路径相关
ipcMain.handle('dbpath:autoDetect', async () => {
return dbPathService.autoDetect()
})
ipcMain.handle('dbpath:scanWxids', async (_, rootPath: string) => {
return dbPathService.scanWxids(rootPath)
})
ipcMain.handle('dbpath:getDefault', async () => {
return dbPathService.getDefaultPath()
})
// WCDB 数据库相关
ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => {
return wcdbService.testConnection(dbPath, hexKey, wxid)
})
ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => {
return wcdbService.open(dbPath, hexKey, wxid)
})
ipcMain.handle('wcdb:close', async () => {
wcdbService.close()
return true
})
// 聊天相关
ipcMain.handle('chat:connect', async () => {
return chatService.connect()
})
ipcMain.handle('chat:getSessions', async () => {
return chatService.getSessions()
})
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
return chatService.getMessages(sessionId, offset, limit)
})
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
return chatService.getLatestMessages(sessionId, limit)
})
ipcMain.handle('chat:getContact', async (_, username: string) => {
return chatService.getContact(username)
})
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
return chatService.getContactAvatar(username)
})
ipcMain.handle('chat:getMyAvatarUrl', async () => {
return chatService.getMyAvatarUrl()
})
ipcMain.handle('chat:downloadEmoji', async (_, cdnUrl: string, md5?: string) => {
return chatService.downloadEmoji(cdnUrl, md5)
})
ipcMain.handle('chat:close', async () => {
chatService.close()
return true
})
ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => {
return chatService.getSessionDetail(sessionId)
})
ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
return chatService.getImageData(sessionId, msgId)
})
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => {
return chatService.getVoiceData(sessionId, msgId)
})
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
return chatService.getMessageById(sessionId, localId)
})
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload)
})
ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => {
return imageDecryptService.resolveCachedImage(payload)
})
ipcMain.handle('image:preload', async (_, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => {
imagePreloadService.enqueue(payloads || [])
return true
})
// 导出相关
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
return exportService.exportSessions(sessionIds, outputDir, options)
})
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
})
// 数据分析相关
ipcMain.handle('analytics:getOverallStatistics', async () => {
return analyticsService.getOverallStatistics()
})
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
return analyticsService.getContactRankings(limit)
})
ipcMain.handle('analytics:getTimeDistribution', async () => {
return analyticsService.getTimeDistribution()
})
// 群聊分析相关
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
return groupAnalyticsService.getGroupChats()
})
ipcMain.handle('groupAnalytics:getGroupMembers', async (_, chatroomId: string) => {
return groupAnalyticsService.getGroupMembers(chatroomId)
})
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime)
})
ipcMain.handle('groupAnalytics:getGroupActiveHours', async (_, chatroomId: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupActiveHours(chatroomId, startTime, endTime)
})
ipcMain.handle('groupAnalytics:getGroupMediaStats', async (_, chatroomId: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
})
// 打开协议窗口
ipcMain.handle('window:openAgreementWindow', async () => {
createAgreementWindow()
return true
})
// 完成引导,关闭引导窗口并显示主窗口
ipcMain.handle('window:completeOnboarding', async () => {
try {
configService?.set('onboardingDone', true)
} catch (e) {
console.error('保存引导完成状态失败:', e)
}
if (onboardingWindow && !onboardingWindow.isDestroyed()) {
onboardingWindow.close()
}
showMainWindow()
return true
})
// 重新打开首次引导窗口,并隐藏主窗口
ipcMain.handle('window:openOnboardingWindow', async () => {
shouldShowMain = false
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.hide()
}
createOnboardingWindow()
return true
})
// 年度报告相关
ipcMain.handle('annualReport:getAvailableYears', async () => {
const cfg = configService || new ConfigService()
configService = cfg
return annualReportService.getAvailableYears({
dbPath: cfg.get('dbPath'),
decryptKey: cfg.get('decryptKey'),
wxid: cfg.get('myWxid')
})
})
ipcMain.handle('annualReport:generateReport', async (_, year: number) => {
const cfg = configService || new ConfigService()
configService = cfg
const dbPath = cfg.get('dbPath')
const decryptKey = cfg.get('decryptKey')
const wxid = cfg.get('myWxid')
const logEnabled = cfg.get('logEnabled')
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const userDataPath = app.getPath('userData')
const workerPath = join(__dirname, 'annualReportWorker.js')
return await new Promise((resolve) => {
const worker = new Worker(workerPath, {
workerData: { year, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled }
})
const cleanup = () => {
worker.removeAllListeners()
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'annualReport:progress') {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('annualReport:progress', msg.data)
}
}
return
}
if (msg && (msg.type === 'annualReport:result' || msg.type === 'done')) {
cleanup()
void worker.terminate()
resolve(msg.data ?? msg.result)
return
}
if (msg && (msg.type === 'annualReport:error' || msg.type === 'error')) {
cleanup()
void worker.terminate()
resolve({ success: false, error: msg.error || '年度报告生成失败' })
}
})
worker.on('error', (err) => {
cleanup()
resolve({ success: false, error: String(err) })
})
worker.on('exit', (code) => {
if (code !== 0) {
cleanup()
resolve({ success: false, error: `年度报告线程异常退出: ${code}` })
}
})
})
})
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
try {
const { baseDir, folderName, images } = payload
if (!baseDir || !folderName || !Array.isArray(images) || images.length === 0) {
return { success: false, error: '导出参数无效' }
}
let targetDir = join(baseDir, folderName)
if (existsSync(targetDir)) {
let idx = 2
while (existsSync(`${targetDir}_${idx}`)) idx++
targetDir = `${targetDir}_${idx}`
}
await mkdir(targetDir, { recursive: true })
for (const img of images) {
const dataUrl = img.dataUrl || ''
const commaIndex = dataUrl.indexOf(',')
if (commaIndex <= 0) continue
const base64 = dataUrl.slice(commaIndex + 1)
const buffer = Buffer.from(base64, 'base64')
const filePath = join(targetDir, img.name)
await writeFile(filePath, buffer)
}
return { success: true, dir: targetDir }
} catch (e) {
return { success: false, error: String(e) }
}
})
// 密钥获取
ipcMain.handle('key:autoGetDbKey', async (event) => {
return keyService.autoGetDbKey(60_000, (message, level) => {
event.sender.send('key:dbKeyStatus', { message, level })
})
})
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string) => {
return keyService.autoGetImageKey(manualDir, (message) => {
event.sender.send('key:imageKeyStatus', { message })
})
})
}
// 主窗口引用
let mainWindow: BrowserWindow | null = null
// 启动时自动检测更新
function checkForUpdatesOnStartup() {
if (!AUTO_UPDATE_ENABLED) return
// 开发环境不检测更新
if (process.env.VITE_DEV_SERVER_URL) return
// 延迟3秒检测等待窗口完全加载
setTimeout(async () => {
try {
const result = await autoUpdater.checkForUpdates()
if (result && result.updateInfo) {
const currentVersion = app.getVersion()
const latestVersion = result.updateInfo.version
if (latestVersion !== currentVersion && mainWindow) {
// 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion,
releaseNotes: result.updateInfo.releaseNotes || ''
})
}
}
} catch (error) {
console.error('启动时检查更新失败:', error)
}
}, 3000)
}
app.whenReady().then(() => {
configService = new ConfigService()
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const userDataPath = app.getPath('userData')
wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
registerIpcHandlers()
const onboardingDone = configService.get('onboardingDone')
shouldShowMain = onboardingDone === true
mainWindow = createWindow({ autoShow: shouldShowMain })
if (!onboardingDone) {
createOnboardingWindow()
}
// 启动时检测更新
checkForUpdatesOnStartup()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

165
electron/preload.ts Normal file
View File

@@ -0,0 +1,165 @@
import { contextBridge, ipcRenderer } from 'electron'
// 暴露给渲染进程的 API
contextBridge.exposeInMainWorld('electronAPI', {
// 配置
config: {
get: (key: string) => ipcRenderer.invoke('config:get', key),
set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value),
clear: () => ipcRenderer.invoke('config:clear')
},
// 对话框
dialog: {
openFile: (options: any) => ipcRenderer.invoke('dialog:openFile', options),
openDirectory: (options: any) => ipcRenderer.invoke('dialog:openDirectory', options),
saveFile: (options: any) => ipcRenderer.invoke('dialog:saveFile', options)
},
// Shell
shell: {
openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path),
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url)
},
// App
app: {
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
getVersion: () => ipcRenderer.invoke('app:getVersion'),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
onDownloadProgress: (callback: (progress: number) => void) => {
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
},
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
}
},
// 日志
log: {
getPath: () => ipcRenderer.invoke('log:getPath'),
read: () => ipcRenderer.invoke('log:read')
},
// 窗口控制
window: {
minimize: () => ipcRenderer.send('window:minimize'),
maximize: () => ipcRenderer.send('window:maximize'),
close: () => ipcRenderer.send('window:close'),
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options)
},
// 数据库路径
dbPath: {
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
},
// WCDB 数据库
wcdb: {
testConnection: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
open: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
close: () => ipcRenderer.invoke('wcdb:close')
},
// 密钥获取
key: {
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir),
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
},
onImageKeyStatus: (callback: (payload: { message: string }) => void) => {
ipcRenderer.on('key:imageKeyStatus', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('key:imageKeyStatus')
}
},
// 聊天
chat: {
connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
getMessages: (sessionId: string, offset?: number, limit?: number) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
getLatestMessages: (sessionId: string, limit?: number) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
close: () => ipcRenderer.invoke('chat:close'),
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
},
// 图片解密
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) =>
ipcRenderer.invoke('image:resolveCache', payload),
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
ipcRenderer.invoke('image:preload', payloads),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
},
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
}
},
// 数据分析
analytics: {
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('analytics:progress')
}
},
// 群聊分析
groupAnalytics: {
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime)
},
// 年度报告
annualReport: {
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
ipcRenderer.invoke('annualReport:exportImages', payload),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('annualReport:progress')
}
},
// 导出
export: {
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
}
})

View File

@@ -0,0 +1,490 @@
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
export interface ChatStatistics {
totalMessages: number
textMessages: number
imageMessages: number
voiceMessages: number
videoMessages: number
emojiMessages: number
otherMessages: number
sentMessages: number
receivedMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
activeDays: number
messageTypeCounts: Record<number, number>
}
export interface TimeDistribution {
hourlyDistribution: Record<number, number>
weekdayDistribution: Record<number, number>
monthlyDistribution: Record<string, number>
}
export interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime: number | null
}
class AnalyticsService {
private configService: ConfigService
private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null
private aggregateCache: { key: string; data: any; updatedAt: number } | null = null
private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null
constructor() {
this.configService = new ConfigService()
}
private cleanAccountDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
return trimmed
}
private isPrivateSession(username: string, cleanedWxid: string): boolean {
if (!username) return false
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
if (username.includes('@chatroom')) return false
if (username === 'filehelper') return false
if (username.startsWith('gh_')) return false
const excludeList = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
'@helper_folders', '@placeholder_foldgroup'
]
for (const prefix of excludeList) {
if (username.startsWith(prefix) || username === prefix) return false
}
if (username.includes('@kefu.openim') || username.includes('@openim')) return false
if (username.includes('service_')) return false
return true
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid }
}
private async getPrivateSessions(
cleanedWxid: string
): Promise<{ usernames: string[]; numericIds: string[] }> {
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) {
return { usernames: [], numericIds: [] }
}
const rows = sessionResult.sessions as Record<string, any>[]
const sample = rows[0]
void sample
const sessions = rows.map((row) => {
const username = row.username || row.user_name || row.userName || ''
const idValue =
row.id ??
row.session_id ??
row.sessionId ??
row.sid ??
row.local_id ??
row.user_id ??
row.userId ??
row.chatroom_id ??
row.chatroomId ??
null
return { username, idValue }
})
const usernames = sessions.map((s) => s.username)
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid))
const privateUsernames = privateSessions.map((s) => s.username)
const numericIds = privateSessions
.map((s) => s.idValue)
.filter((id) => typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id)))
.map((id) => String(id))
return { usernames: privateUsernames, numericIds }
}
private async iterateSessionMessages(
sessionId: string,
onRow: (row: Record<string, any>) => void,
beginTimestamp = 0,
endTimestamp = 0
): Promise<void> {
const cursorResult = await wcdbService.openMessageCursor(
sessionId,
500,
true,
beginTimestamp,
endTimestamp
)
if (!cursorResult.success || !cursorResult.cursor) return
try {
let hasMore = true
let batchCount = 0
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
onRow(row)
}
hasMore = batch.hasMore === true
// 每处理完一个批次,如果已经处理了较多数据,暂时让出执行权
batchCount++
if (batchCount % 10 === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
private setProgress(window: any, status: string, progress: number) {
if (window && !window.isDestroyed()) {
window.webContents.send('analytics:progress', { status, progress })
}
}
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
const sample = sessionIds.slice(0, 5).join(',')
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
}
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
const aggregate = {
total: 0,
sent: 0,
received: 0,
firstTime: 0,
lastTime: 0,
typeCounts: {} as Record<number, number>,
hourly: {} as Record<number, number>,
weekday: {} as Record<number, number>,
daily: {} as Record<string, number>,
monthly: {} as Record<string, number>,
sessions: {} as Record<string, { total: number; sent: number; received: number; lastTime: number }>,
idMap: {}
}
for (const sessionId of sessionIds) {
const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 }
await this.iterateSessionMessages(sessionId, (row) => {
const createTime = parseInt(row.create_time || row.createTime || row.create_time_ms || '0', 10)
if (!createTime) return
if (beginTimestamp > 0 && createTime < beginTimestamp) return
if (endTimestamp > 0 && createTime > endTimestamp) return
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
aggregate.total += 1
sessionStat.total += 1
aggregate.typeCounts[localType] = (aggregate.typeCounts[localType] || 0) + 1
if (isSend) {
aggregate.sent += 1
sessionStat.sent += 1
} else {
aggregate.received += 1
sessionStat.received += 1
}
if (aggregate.firstTime === 0 || createTime < aggregate.firstTime) {
aggregate.firstTime = createTime
}
if (createTime > aggregate.lastTime) {
aggregate.lastTime = createTime
}
if (createTime > sessionStat.lastTime) {
sessionStat.lastTime = createTime
}
const date = new Date(createTime * 1000)
const hour = date.getHours()
const weekday = date.getDay()
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`
aggregate.hourly[hour] = (aggregate.hourly[hour] || 0) + 1
aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1
aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1
aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1
}, beginTimestamp, endTimestamp)
if (sessionStat.total > 0) {
aggregate.sessions[sessionId] = sessionStat
}
}
return aggregate
}
private async getAggregateWithFallback(
sessionIds: string[],
beginTimestamp = 0,
endTimestamp = 0,
window?: any
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.aggregateCache.data, source: 'cache' }
}
}
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
return this.aggregatePromise.promise
}
const promise = (async () => {
const result = await wcdbService.getAggregateStats(sessionIds, beginTimestamp, endTimestamp)
if (result.success && result.data && result.data.total > 0) {
this.aggregateCache = { key: cacheKey, data: result.data, updatedAt: Date.now() }
return { success: true, data: result.data, source: 'dll' }
}
if (this.fallbackAggregateCache && this.fallbackAggregateCache.key === cacheKey) {
if (Date.now() - this.fallbackAggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.fallbackAggregateCache.data, source: 'cursor-cache' }
}
}
if (window) {
this.setProgress(window, '原生聚合为0使用游标统计...', 45)
}
const data = await this.computeAggregateByCursor(sessionIds, beginTimestamp, endTimestamp)
this.fallbackAggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
this.aggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
return { success: true, data, source: 'cursor' }
})()
this.aggregatePromise = { key: cacheKey, promise }
try {
return await promise
} finally {
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
this.aggregatePromise = null
}
}
}
private normalizeAggregateSessions(
sessions: Record<string, any> | undefined,
idMap: Record<string, string> | undefined
): Record<string, any> {
if (!sessions) return {}
if (!idMap) return sessions
const keys = Object.keys(sessions)
if (keys.length === 0) return sessions
const numericKeys = keys.every((k) => /^\d+$/.test(k))
if (!numericKeys) return sessions
const remapped: Record<string, any> = {}
for (const [id, stat] of Object.entries(sessions)) {
const username = idMap[id] || id
remapped[username] = stat
}
return remapped
}
private async logAggregateDiagnostics(sessionIds: string[]): Promise<void> {
const samples = sessionIds.slice(0, 5)
const results = await Promise.all(samples.map(async (sessionId) => {
const countResult = await wcdbService.getMessageCount(sessionId)
return { sessionId, success: countResult.success, count: countResult.count, error: countResult.error }
}))
void results
}
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const { BrowserWindow } = require('electron')
const win = BrowserWindow.getAllWindows()[0]
this.setProgress(win, '正在执行原生数据聚合...', 30)
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
this.setProgress(win, '同步分析结果...', 90)
const d = result.data
if (d.total === 0 && sessionInfo.usernames.length > 0) {
await this.logAggregateDiagnostics(sessionInfo.usernames)
}
const textTypes = [1, 244813135921]
let textMessages = 0
for (const t of textTypes) textMessages += (d.typeCounts[t] || 0)
const imageMessages = d.typeCounts[3] || 0
const voiceMessages = d.typeCounts[34] || 0
const videoMessages = d.typeCounts[43] || 0
const emojiMessages = d.typeCounts[47] || 0
const otherMessages = d.total - textMessages - imageMessages - voiceMessages - videoMessages - emojiMessages
// 估算活跃天数(按月分布估算或从日期列表中提取,由于 C++ 只返回了月份映射,
// 我们这里暂时返回月份数作为参考,或者如果需要精确天数,原生层需要返回 Set 大小)
// 为了性能,我们先用月份数,或者后续再优化 C++ 返回 activeDays 计数。
// 当前 C++ 逻辑中 gs.monthly.size() 就是活跃月份。
const activeMonths = Object.keys(d.monthly).length
return {
success: true,
data: {
totalMessages: d.total,
textMessages,
imageMessages,
voiceMessages,
videoMessages,
emojiMessages,
otherMessages: Math.max(0, otherMessages),
sentMessages: d.sent,
receivedMessages: d.received,
firstMessageTime: d.firstTime || null,
lastMessageTime: d.lastTime || null,
activeDays: activeMonths * 20, // 粗略估算,或改为返回活跃月份
messageTypeCounts: d.typeCounts
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
const d = result.data
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
const usernames = Object.keys(sessions)
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
])
const rankings: ContactRanking[] = usernames
.map((username) => {
const stat = sessions[username]
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
return {
username,
displayName,
avatarUrl,
messageCount: stat.total,
sentCount: stat.sent,
receivedCount: stat.received,
lastMessageTime: stat.lastTime || null
}
})
.sort((a, b) => b.messageCount - a.messageCount)
.slice(0, limit)
return { success: true, data: rankings }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getTimeDistribution(): Promise<{ success: boolean; data?: TimeDistribution; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionInfo.usernames.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
const d = result.data
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat
// 前端期望 1=Mon...7=Sun
const weekdayDistribution: Record<number, number> = {}
for (const [w, count] of Object.entries(d.weekday)) {
const sqliteW = parseInt(w, 10)
const jsW = sqliteW === 0 ? 7 : sqliteW
weekdayDistribution[jsW] = count as number
}
// 补全 24 小时
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) {
hourlyDistribution[i] = d.hourly[i] || 0
}
return {
success: true,
data: {
hourlyDistribution,
weekdayDistribution,
monthlyDistribution: d.monthly
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const analyticsService = new AnalyticsService()

View File

@@ -0,0 +1,928 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
export interface TopContact {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
}
export interface MonthlyTopFriend {
month: number
displayName: string
avatarUrl?: string
messageCount: number
}
export interface ChatPeakDay {
date: string
messageCount: number
topFriend?: string
topFriendCount?: number
}
export interface ActivityHeatmap {
data: number[][]
}
export interface AnnualReportData {
year: number
totalMessages: number
totalFriends: number
coreFriends: TopContact[]
monthlyTopFriends: MonthlyTopFriend[]
peakDay: ChatPeakDay | null
longestStreak: {
friendName: string
days: number
startDate: string
endDate: string
} | null
activityHeatmap: ActivityHeatmap
midnightKing: {
displayName: string
count: number
percentage: number
} | null
selfAvatarUrl?: string
mutualFriend: {
displayName: string
avatarUrl?: string
sentCount: number
receivedCount: number
ratio: number
} | null
socialInitiative: {
initiatedChats: number
receivedChats: number
initiativeRate: number
} | null
responseSpeed: {
avgResponseTime: number
fastestFriend: string
fastestTime: number
} | null
topPhrases: {
phrase: string
count: number
}[]
}
class AnnualReportService {
constructor() {
}
private broadcastProgress(status: string, progress: number) {
if (parentPort) {
parentPort.postMessage({
type: 'annualReport:progress',
data: { status, progress }
})
}
}
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
if (onProgress) {
onProgress(status, progress)
return
}
this.broadcastProgress(status, progress)
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
private async ensureConnectedWithConfig(
dbPath: string,
decryptKey: string,
wxid: string
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid, rawWxid: wxid }
}
private async getPrivateSessions(cleanedWxid: string): Promise<string[]> {
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) return []
const rows = sessionResult.sessions as Record<string, any>[]
const excludeList = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
'@helper_folders', '@placeholder_foldgroup'
]
return rows
.map((row) => row.username || row.user_name || row.userName || '')
.filter((username) => {
if (!username) return false
if (username.includes('@chatroom')) return false
if (username === 'filehelper') return false
if (username.startsWith('gh_')) return false
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
for (const prefix of excludeList) {
if (username.startsWith(prefix) || username === prefix) return false
}
if (username.includes('@kefu.openim') || username.includes('@openim')) return false
if (username.includes('service_')) return false
return true
})
}
private async getEdgeMessageTime(sessionId: string, ascending: boolean): Promise<number | null> {
const cursor = await wcdbService.openMessageCursor(sessionId, 1, ascending, 0, 0)
if (!cursor.success || !cursor.cursor) return null
try {
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
if (!batch.success || !batch.rows || batch.rows.length === 0) return null
const ts = parseInt(batch.rows[0].create_time || '0', 10)
return ts > 0 ? ts : null
} finally {
await wcdbService.closeMessageCursor(cursor.cursor)
}
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
private formatDateYmd(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
private async computeLongestStreak(
sessionIds: string[],
beginTimestamp: number,
endTimestamp: number,
onProgress?: (status: string, progress: number) => void,
progressStart: number = 0,
progressEnd: number = 0
): Promise<{ sessionId: string; days: number; start: Date | null; end: Date | null }> {
let bestSessionId = ''
let bestDays = 0
let bestStart: Date | null = null
let bestEnd: Date | null = null
let lastProgressAt = 0
let lastProgressSent = progressStart
const shouldReportProgress = onProgress && progressEnd > progressStart && sessionIds.length > 0
let apiTimeMs = 0
let jsTimeMs = 0
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const openStart = Date.now()
const cursor = await wcdbService.openMessageCursorLite(sessionId, 2000, true, beginTimestamp, endTimestamp)
apiTimeMs += Date.now() - openStart
if (!cursor.success || !cursor.cursor) continue
let lastDayIndex: number | null = null
let currentStreak = 0
let currentStart: Date | null = null
let maxStreak = 0
let maxStart: Date | null = null
let maxEnd: Date | null = null
try {
let hasMore = true
while (hasMore) {
const fetchStart = Date.now()
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
apiTimeMs += Date.now() - fetchStart
if (!batch.success || !batch.rows) break
const processStart = Date.now()
for (const row of batch.rows) {
const createTime = parseInt(row.create_time || '0', 10)
if (!createTime) continue
const dt = new Date(createTime * 1000)
const dayDate = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate())
const dayIndex = Math.floor(dayDate.getTime() / 86400000)
if (lastDayIndex !== null && dayIndex === lastDayIndex) continue
if (lastDayIndex !== null && dayIndex - lastDayIndex === 1) {
currentStreak++
} else {
currentStreak = 1
currentStart = dayDate
}
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStart = currentStart
maxEnd = dayDate
}
lastDayIndex = dayIndex
}
jsTimeMs += Date.now() - processStart
hasMore = batch.hasMore === true
await new Promise(resolve => setImmediate(resolve))
}
} finally {
const closeStart = Date.now()
await wcdbService.closeMessageCursor(cursor.cursor)
apiTimeMs += Date.now() - closeStart
}
if (maxStreak > bestDays) {
bestDays = maxStreak
bestSessionId = sessionId
bestStart = maxStart
bestEnd = maxEnd
}
if (shouldReportProgress) {
const now = Date.now()
if (now - lastProgressAt > 250) {
const ratio = Math.min(1, (i + 1) / sessionIds.length)
const progress = Math.floor(progressStart + ratio * (progressEnd - progressStart))
if (progress > lastProgressSent) {
lastProgressSent = progress
lastProgressAt = now
const label = `${i + 1}/${sessionIds.length}`
const timing = (apiTimeMs > 0 || jsTimeMs > 0)
? `, DB ${(apiTimeMs / 1000).toFixed(1)}s / JS ${(jsTimeMs / 1000).toFixed(1)}s`
: ''
onProgress?.(`计算连续聊天... (${label}${timing})`, progress)
}
}
}
}
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
}
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
try {
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const fastYears = await wcdbService.getAvailableYears(sessionIds)
if (fastYears.success && fastYears.data) {
return { success: true, data: fastYears.data }
}
const years = new Set<number>()
for (const sessionId of sessionIds) {
const first = await this.getEdgeMessageTime(sessionId, true)
const last = await this.getEdgeMessageTime(sessionId, false)
if (!first && !last) continue
const minYear = new Date((first || last || 0) * 1000).getFullYear()
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
for (let y = minYear; y <= maxYear; y++) {
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
}
}
const sortedYears = Array.from(years).sort((a, b) => b - a)
return { success: true, data: sortedYears }
} catch (e) {
return { success: false, error: String(e) }
}
}
async generateReportWithConfig(params: {
year: number
wxid: string
dbPath: string
decryptKey: string
onProgress?: (status: string, progress: number) => void
}): Promise<{ success: boolean; data?: AnnualReportData; error?: string }> {
try {
const { year, wxid, dbPath, decryptKey, onProgress } = params
this.reportProgress('正在连接数据库...', 5, onProgress)
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
const cleanedWxid = conn.cleanedWxid
const rawWxid = conn.rawWxid
const sessionIds = await this.getPrivateSessions(cleanedWxid)
if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话' }
}
this.reportProgress('加载会话列表...', 15, onProgress)
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
let totalMessages = 0
const contactStats = new Map<string, { sent: number; received: number }>()
const monthlyStats = new Map<string, Map<number, number>>()
const dailyStats = new Map<string, number>()
const dailyContactStats = new Map<string, Map<string, number>>()
const heatmapData: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
const midnightStats = new Map<string, number>()
let longestStreakSessionId = ''
let longestStreakDays = 0
let longestStreakStart: Date | null = null
let longestStreakEnd: Date | null = null
const conversationStarts = new Map<string, { initiated: number; received: number }>()
const responseTimeStats = new Map<string, number[]>()
const phraseCount = new Map<string, number>()
const lastMessageTime = new Map<string, { time: number; isSent: boolean }>()
const CONVERSATION_GAP = 3600
this.reportProgress('统计会话消息...', 20, onProgress)
const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime)
if (!result.success || !result.data) {
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
}
const d = result.data
totalMessages = d.total
this.reportProgress('汇总基础统计...', 25, onProgress)
const totalMessagesForProgress = totalMessages > 0 ? totalMessages : sessionIds.length
let processedMessages = 0
let lastProgressSent = 0
let lastProgressAt = 0
// 填充基础统计
for (const [sid, stat] of Object.entries(d.sessions)) {
const s = stat as any
contactStats.set(sid, { sent: s.sent, received: s.received })
const mMap = new Map<number, number>()
for (const [m, c] of Object.entries(s.monthly || {})) {
mMap.set(parseInt(m, 10), c as number)
}
monthlyStats.set(sid, mMap)
}
// 填充全局分布,并锁定峰值日期以减少逐日消息统计
let peakDayKey = ''
let peakDayCount = 0
for (const [day, count] of Object.entries(d.daily)) {
const c = count as number
dailyStats.set(day, c)
if (c > peakDayCount) {
peakDayCount = c
peakDayKey = day
}
}
let useSqlExtras = false
let responseStatsFromSql: Record<string, { avg?: number; fastest?: number; count?: number }> | null = null
let topPhrasesFromSql: { phrase: string; count: number }[] | null = null
let streakComputedInLoop = false
let peakDayBegin = 0
let peakDayEnd = 0
if (peakDayKey) {
const start = new Date(`${peakDayKey}T00:00:00`).getTime()
if (!Number.isNaN(start)) {
peakDayBegin = Math.floor(start / 1000)
peakDayEnd = peakDayBegin + 24 * 3600 - 1
}
}
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd)
if (extras.success && extras.data) {
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
const extrasData = extras.data as any
const heatmap = extrasData.heatmap as number[][] | undefined
if (Array.isArray(heatmap) && heatmap.length === 7) {
for (let w = 0; w < 7; w++) {
if (Array.isArray(heatmap[w])) {
for (let h = 0; h < 24; h++) {
heatmapData[w][h] = heatmap[w][h] || 0
}
}
}
}
this.reportProgress('加载扩展统计... (解析夜聊统计)', 33, onProgress)
const midnight = extrasData.midnight as Record<string, number> | undefined
if (midnight) {
for (const [sid, count] of Object.entries(midnight)) {
midnightStats.set(sid, count as number)
}
}
this.reportProgress('加载扩展统计... (解析对话发起)', 34, onProgress)
const conversation = extrasData.conversation as Record<string, { initiated: number; received: number }> | undefined
if (conversation) {
for (const [sid, stats] of Object.entries(conversation)) {
conversationStarts.set(sid, { initiated: stats.initiated || 0, received: stats.received || 0 })
}
}
this.reportProgress('加载扩展统计... (解析响应速度)', 35, onProgress)
responseStatsFromSql = extrasData.response || null
this.reportProgress('加载扩展统计... (解析峰值日)', 36, onProgress)
const peakDayCounts = extrasData.peakDay as Record<string, number> | undefined
if (peakDayKey && peakDayCounts) {
const dayMap = new Map<string, number>()
for (const [sid, count] of Object.entries(peakDayCounts)) {
dayMap.set(sid, count as number)
}
if (dayMap.size > 0) {
dailyContactStats.set(peakDayKey, dayMap)
}
}
this.reportProgress('加载扩展统计... (解析常用语)', 37, onProgress)
const sqlPhrases = extrasData.topPhrases as { phrase: string; count: number }[] | undefined
if (Array.isArray(sqlPhrases) && sqlPhrases.length > 0) {
topPhrasesFromSql = sqlPhrases
}
const streak = extrasData.streak as { sessionId?: string; days?: number; startDate?: string; endDate?: string } | undefined
if (streak && streak.sessionId && streak.days && streak.days > 0) {
longestStreakSessionId = streak.sessionId
longestStreakDays = streak.days
longestStreakStart = streak.startDate ? new Date(`${streak.startDate}T00:00:00`) : null
longestStreakEnd = streak.endDate ? new Date(`${streak.endDate}T00:00:00`) : null
if (longestStreakStart && !Number.isNaN(longestStreakStart.getTime()) &&
longestStreakEnd && !Number.isNaN(longestStreakEnd.getTime())) {
streakComputedInLoop = true
}
}
useSqlExtras = true
this.reportProgress('加载扩展统计... (完成)', 40, onProgress)
} else if (!extras.success) {
const reason = extras.error ? ` (${extras.error})` : ''
this.reportProgress(`扩展统计失败,转入完整分析...${reason}`, 30, onProgress)
}
if (!useSqlExtras) {
// 注意:原生层目前未返回交叉维度 heatmapData[weekday][hour]
// 这里的 heatmapData 仍然需要通过下面的遍历来精确填充。
// 考虑到 Annual Report 需要一些复杂的序列特征(响应速度、对话发起)和文本特征(常用语),
// 我们仍然保留一次轻量级循环,但因为有了原生统计,我们可以分步进行,或者如果数据量极大则跳过某些步骤。
// 为保持功能完整,我们进行深度集成的轻量遍历:
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime)
if (!cursor.success || !cursor.cursor) continue
let lastDayIndex: number | null = null
let currentStreak = 0
let currentStart: Date | null = null
let maxStreak = 0
let maxStart: Date | null = null
let maxEnd: Date | null = null
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
const createTime = parseInt(row.create_time || '0', 10)
if (!createTime) continue
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSent = parseInt(isSendRaw, 10) === 1
const localType = parseInt(row.local_type || row.type || '1', 10)
// 响应速度 & 对话发起
if (!conversationStarts.has(sessionId)) {
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
}
const convStats = conversationStarts.get(sessionId)!
const lastMsg = lastMessageTime.get(sessionId)
if (!lastMsg || (createTime - lastMsg.time) > CONVERSATION_GAP) {
if (isSent) convStats.initiated++
else convStats.received++
} else if (lastMsg.isSent !== isSent) {
if (isSent && !lastMsg.isSent) {
const responseTime = createTime - lastMsg.time
if (responseTime > 0 && responseTime < 86400) {
if (!responseTimeStats.has(sessionId)) responseTimeStats.set(sessionId, [])
responseTimeStats.get(sessionId)!.push(responseTime)
}
}
}
lastMessageTime.set(sessionId, { time: createTime, isSent })
// 常用语
if ((localType === 1 || localType === 244813135921) && isSent) {
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const text = String(content).trim()
if (text.length >= 2 && text.length <= 20 &&
!text.includes('http') && !text.includes('<') &&
!text.startsWith('[') && !text.startsWith('<?xml')) {
phraseCount.set(text, (phraseCount.get(text) || 0) + 1)
}
}
// 交叉维度补全
const dt = new Date(createTime * 1000)
const weekdayIndex = dt.getDay() === 0 ? 6 : dt.getDay() - 1
heatmapData[weekdayIndex][dt.getHours()]++
const dayDate = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate())
const dayIndex = Math.floor(dayDate.getTime() / 86400000)
if (lastDayIndex === null || dayIndex !== lastDayIndex) {
if (lastDayIndex !== null && dayIndex - lastDayIndex === 1) {
currentStreak++
} else {
currentStreak = 1
currentStart = dayDate
}
if (currentStreak > maxStreak) {
maxStreak = currentStreak
maxStart = currentStart
maxEnd = dayDate
}
lastDayIndex = dayIndex
}
if (dt.getHours() >= 0 && dt.getHours() < 6) {
midnightStats.set(sessionId, (midnightStats.get(sessionId) || 0) + 1)
}
if (peakDayKey) {
const dayKey = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
if (dayKey === peakDayKey) {
if (!dailyContactStats.has(dayKey)) dailyContactStats.set(dayKey, new Map())
const dayContactMap = dailyContactStats.get(dayKey)!
dayContactMap.set(sessionId, (dayContactMap.get(sessionId) || 0) + 1)
}
}
if (totalMessagesForProgress > 0) {
processedMessages++
}
}
hasMore = batch.hasMore === true
const now = Date.now()
if (now - lastProgressAt > 200) {
let progress = 30
if (totalMessagesForProgress > 0) {
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
progress = 30 + Math.floor(ratio * 50)
} else {
const ratio = Math.min(1, (i + 1) / sessionIds.length)
progress = 30 + Math.floor(ratio * 50)
}
if (progress > lastProgressSent) {
lastProgressSent = progress
lastProgressAt = now
let label = `${i + 1}/${sessionIds.length}`
if (totalMessagesForProgress > 0) {
const done = Math.min(processedMessages, totalMessagesForProgress)
label = `${done}/${totalMessagesForProgress}`
}
this.reportProgress(`分析聊天记录... (${label})`, progress, onProgress)
}
}
await new Promise(resolve => setImmediate(resolve))
}
} finally {
await wcdbService.closeMessageCursor(cursor.cursor)
}
if (maxStreak > longestStreakDays) {
longestStreakDays = maxStreak
longestStreakSessionId = sessionId
longestStreakStart = maxStart
longestStreakEnd = maxEnd
}
}
streakComputedInLoop = true
}
if (!streakComputedInLoop) {
this.reportProgress('计算连续聊天...', 45, onProgress)
const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75)
if (streakResult.days > longestStreakDays) {
longestStreakDays = streakResult.days
longestStreakSessionId = streakResult.sessionId
longestStreakStart = streakResult.start
longestStreakEnd = streakResult.end
}
}
this.reportProgress('整理联系人信息...', 85, onProgress)
const contactIds = Array.from(contactStats.keys())
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(contactIds),
wcdbService.getAvatarUrls(contactIds)
])
const contactInfoMap = new Map<string, { displayName: string; avatarUrl?: string }>()
for (const sessionId of contactIds) {
contactInfoMap.set(sessionId, {
displayName: displayNames.success && displayNames.map ? (displayNames.map[sessionId] || sessionId) : sessionId,
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[sessionId] : undefined
})
}
const selfAvatarResult = await wcdbService.getAvatarUrls([rawWxid, cleanedWxid])
const selfAvatarUrl = selfAvatarResult.success && selfAvatarResult.map
? (selfAvatarResult.map[rawWxid] || selfAvatarResult.map[cleanedWxid])
: undefined
const coreFriends: TopContact[] = Array.from(contactStats.entries())
.map(([sessionId, stats]) => {
const info = contactInfoMap.get(sessionId)
return {
username: sessionId,
displayName: info?.displayName || sessionId,
avatarUrl: info?.avatarUrl,
messageCount: stats.sent + stats.received,
sentCount: stats.sent,
receivedCount: stats.received
}
})
.sort((a, b) => b.messageCount - a.messageCount)
.slice(0, 3)
const monthlyTopFriends: MonthlyTopFriend[] = []
for (let month = 1; month <= 12; month++) {
let maxCount = 0
let topSessionId = ''
for (const [sessionId, monthMap] of monthlyStats.entries()) {
const count = monthMap.get(month) || 0
if (count > maxCount) {
maxCount = count
topSessionId = sessionId
}
}
const info = contactInfoMap.get(topSessionId)
monthlyTopFriends.push({
month,
displayName: info?.displayName || (topSessionId ? topSessionId : '暂无'),
avatarUrl: info?.avatarUrl,
messageCount: maxCount
})
}
let peakDay: ChatPeakDay | null = null
let maxDayCount = 0
for (const [day, count] of dailyStats.entries()) {
if (count > maxDayCount) {
maxDayCount = count
const dayContactMap = dailyContactStats.get(day)
let topFriend = ''
let topFriendCount = 0
if (dayContactMap) {
for (const [sessionId, c] of dayContactMap.entries()) {
if (c > topFriendCount) {
topFriendCount = c
topFriend = contactInfoMap.get(sessionId)?.displayName || sessionId
}
}
}
peakDay = { date: day, messageCount: count, topFriend, topFriendCount }
}
}
let midnightKing: AnnualReportData['midnightKing'] = null
const totalMidnight = Array.from(midnightStats.values()).reduce((a, b) => a + b, 0)
if (totalMidnight > 0) {
let maxMidnight = 0
let midnightSessionId = ''
for (const [sessionId, count] of midnightStats.entries()) {
if (count > maxMidnight) {
maxMidnight = count
midnightSessionId = sessionId
}
}
const info = contactInfoMap.get(midnightSessionId)
midnightKing = {
displayName: info?.displayName || midnightSessionId,
count: maxMidnight,
percentage: Math.round((maxMidnight / totalMidnight) * 1000) / 10
}
}
let longestStreak: AnnualReportData['longestStreak'] = null
if (longestStreakSessionId && longestStreakDays > 0 && longestStreakStart && longestStreakEnd) {
const info = contactInfoMap.get(longestStreakSessionId)
longestStreak = {
friendName: info?.displayName || longestStreakSessionId,
days: longestStreakDays,
startDate: this.formatDateYmd(longestStreakStart),
endDate: this.formatDateYmd(longestStreakEnd)
}
}
let mutualFriend: AnnualReportData['mutualFriend'] = null
let bestRatioDiff = Infinity
for (const [sessionId, stats] of contactStats.entries()) {
if (stats.sent >= 50 && stats.received >= 50) {
const ratio = stats.sent / stats.received
const ratioDiff = Math.abs(ratio - 1)
if (ratioDiff < bestRatioDiff) {
bestRatioDiff = ratioDiff
const info = contactInfoMap.get(sessionId)
mutualFriend = {
displayName: info?.displayName || sessionId,
avatarUrl: info?.avatarUrl,
sentCount: stats.sent,
receivedCount: stats.received,
ratio: Math.round(ratio * 100) / 100
}
}
}
}
let socialInitiative: AnnualReportData['socialInitiative'] = null
let totalInitiated = 0
let totalReceived = 0
for (const stats of conversationStarts.values()) {
totalInitiated += stats.initiated
totalReceived += stats.received
}
const totalConversations = totalInitiated + totalReceived
if (totalConversations > 0) {
socialInitiative = {
initiatedChats: totalInitiated,
receivedChats: totalReceived,
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10
}
}
this.reportProgress('生成报告...', 95, onProgress)
let responseSpeed: AnnualReportData['responseSpeed'] = null
if (responseStatsFromSql && Object.keys(responseStatsFromSql).length > 0) {
let totalSum = 0
let totalCount = 0
let fastestFriendId = ''
let fastestAvgTime = Infinity
for (const [sessionId, stats] of Object.entries(responseStatsFromSql)) {
const count = stats.count || 0
const avg = stats.avg || 0
if (count <= 0 || avg <= 0) continue
totalSum += avg * count
totalCount += count
if (avg < fastestAvgTime) {
fastestAvgTime = avg
fastestFriendId = sessionId
}
}
if (totalCount > 0) {
const avgResponseTime = totalSum / totalCount
const fastestInfo = contactInfoMap.get(fastestFriendId)
responseSpeed = {
avgResponseTime: Math.round(avgResponseTime),
fastestFriend: fastestInfo?.displayName || fastestFriendId,
fastestTime: Math.round(fastestAvgTime)
}
}
} else {
const allResponseTimes: number[] = []
let fastestFriendId = ''
let fastestAvgTime = Infinity
for (const [sessionId, times] of responseTimeStats.entries()) {
if (times.length >= 10) {
allResponseTimes.push(...times)
const avgTime = times.reduce((a, b) => a + b, 0) / times.length
if (avgTime < fastestAvgTime) {
fastestAvgTime = avgTime
fastestFriendId = sessionId
}
}
}
if (allResponseTimes.length > 0) {
const avgResponseTime = allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length
const fastestInfo = contactInfoMap.get(fastestFriendId)
responseSpeed = {
avgResponseTime: Math.round(avgResponseTime),
fastestFriend: fastestInfo?.displayName || fastestFriendId,
fastestTime: Math.round(fastestAvgTime)
}
}
}
const topPhrases = topPhrasesFromSql && topPhrasesFromSql.length > 0
? topPhrasesFromSql
: Array.from(phraseCount.entries())
.filter(([_, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 32)
.map(([phrase, count]) => ({ phrase, count }))
const reportData: AnnualReportData = {
year,
totalMessages,
totalFriends: contactStats.size,
coreFriends,
monthlyTopFriends,
peakDay,
longestStreak,
activityHeatmap: { data: heatmapData },
midnightKing,
selfAvatarUrl,
mutualFriend,
socialInitiative,
responseSpeed,
topPhrases
}
return { success: true, data: reportData }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const annualReportService = new AnnualReportService()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
import Store from 'electron-store'
interface ConfigSchema {
// 数据库相关
dbPath: string // 数据库根目录 (xwechat_files)
decryptKey: string // 解密密钥
myWxid: string // 当前用户 wxid
onboardingDone: boolean
imageXorKey: number
imageAesKey: string
// 缓存相关
cachePath: string
lastOpenedDb: string
lastSession: string
// 界面相关
theme: 'light' | 'dark' | 'system'
themeId: string
language: string
logEnabled: boolean
}
export class ConfigService {
private store: Store<ConfigSchema>
constructor() {
this.store = new Store<ConfigSchema>({
name: 'WeFlow-config',
defaults: {
dbPath: '',
decryptKey: '',
myWxid: '',
onboardingDone: false,
imageXorKey: 0,
imageAesKey: '',
cachePath: '',
lastOpenedDb: '',
lastSession: '',
theme: 'system',
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false
}
})
}
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
return this.store.get(key)
}
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
this.store.set(key, value)
}
getAll(): ConfigSchema {
return this.store.store
}
clear(): void {
this.store.clear()
}
}

View File

@@ -0,0 +1,159 @@
import { join, basename } from 'path'
import { existsSync, readdirSync, statSync } from 'fs'
import { homedir } from 'os'
export interface WxidInfo {
wxid: string
modifiedTime: number
}
export class DbPathService {
/**
* 自动检测微信数据库根目录
*/
async autoDetect(): Promise<{ success: boolean; path?: string; error?: string }> {
try {
const possiblePaths: string[] = []
const home = homedir()
// 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
// 旧版微信数据目录
possiblePaths.push(join(home, 'Documents', 'WeChat Files'))
for (const path of possiblePaths) {
if (existsSync(path)) {
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
continue
}
// 检查是否有有效的账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
}
}
return { success: false, error: '未能自动检测到微信数据库目录' }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 查找账号目录(包含 db_storage 或图片目录)
*/
findAccountDirs(rootPath: string): string[] {
const accounts: string[] = []
try {
const entries = readdirSync(rootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
} catch {
continue
}
if (stat.isDirectory()) {
if (!this.isPotentialAccountName(entry)) continue
// 检查是否有有效账号目录结构
if (this.isAccountDir(entryPath)) {
accounts.push(entry)
}
}
}
} catch {}
return accounts
}
private isAccountDir(entryPath: string): boolean {
return (
existsSync(join(entryPath, 'db_storage')) ||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
)
}
private isPotentialAccountName(name: string): boolean {
const lower = name.toLowerCase()
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) {
return false
}
return true
}
private getAccountModifiedTime(entryPath: string): number {
try {
const accountStat = statSync(entryPath)
let latest = accountStat.mtimeMs
const dbPath = join(entryPath, 'db_storage')
if (existsSync(dbPath)) {
const dbStat = statSync(dbPath)
latest = Math.max(latest, dbStat.mtimeMs)
}
const imagePath = join(entryPath, 'FileStorage', 'Image')
if (existsSync(imagePath)) {
const imageStat = statSync(imagePath)
latest = Math.max(latest, imageStat.mtimeMs)
}
const image2Path = join(entryPath, 'FileStorage', 'Image2')
if (existsSync(image2Path)) {
const image2Stat = statSync(image2Path)
latest = Math.max(latest, image2Stat.mtimeMs)
}
return latest
} catch {
return 0
}
}
/**
* 扫描 wxid 列表
*/
scanWxids(rootPath: string): WxidInfo[] {
const wxids: WxidInfo[] = []
try {
if (this.isAccountDir(rootPath)) {
const wxid = basename(rootPath)
const modifiedTime = this.getAccountModifiedTime(rootPath)
return [{ wxid, modifiedTime }]
}
const accounts = this.findAccountDirs(rootPath)
for (const account of accounts) {
const fullPath = join(rootPath, account)
const modifiedTime = this.getAccountModifiedTime(fullPath)
wxids.push({ wxid: account, modifiedTime })
}
} catch {}
return wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
}
/**
* 获取默认数据库路径
*/
getDefaultPath(): string {
const home = homedir()
return join(home, 'Documents', 'xwechat_files')
}
}
export const dbPathService = new DbPathService()

View File

@@ -0,0 +1,626 @@
import * as fs from 'fs'
import * as path from 'path'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
// ChatLab 格式类型定义
interface ChatLabHeader {
version: string
exportedAt: number
generator: string
description?: string
}
interface ChatLabMeta {
name: string
platform: string
type: 'group' | 'private'
groupId?: string
}
interface ChatLabMember {
platformId: string
accountName: string
groupNickname?: string
avatar?: string
}
interface ChatLabMessage {
sender: string
accountName: string
groupNickname?: string
timestamp: number
type: number
content: string | null
}
interface ChatLabExport {
chatlab: ChatLabHeader
meta: ChatLabMeta
members: ChatLabMember[]
messages: ChatLabMessage[]
}
// 消息类型映射:微信 localType -> ChatLab type
const MESSAGE_TYPE_MAP: Record<number, number> = {
1: 0, // 文本 -> TEXT
3: 1, // 图片 -> IMAGE
34: 2, // 语音 -> VOICE
43: 3, // 视频 -> VIDEO
49: 7, // 链接/文件 -> LINK (需要进一步判断)
47: 5, // 表情包 -> EMOJI
48: 8, // 位置 -> LOCATION
42: 27, // 名片 -> CONTACT
50: 23, // 通话 -> CALL
10000: 80, // 系统消息 -> SYSTEM
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
dateRange?: { start: number; end: number } | null
exportMedia?: boolean
exportAvatars?: boolean
}
export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
}
class ExportService {
private configService: ConfigService
private contactCache: Map<string, { displayName: string; avatarUrl?: string }> = new Map()
constructor() {
this.configService = new ConfigService()
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid }
}
private async getContactInfo(username: string): Promise<{ displayName: string; avatarUrl?: string }> {
if (this.contactCache.has(username)) {
return this.contactCache.get(username)!
}
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames([username]),
wcdbService.getAvatarUrls([username])
])
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
const info = { displayName, avatarUrl }
this.contactCache.set(username, info)
return info
}
/**
* 转换微信消息类型到 ChatLab 类型
*/
private convertMessageType(localType: number, content: string): number {
if (localType === 49) {
const typeMatch = /<type>(\d+)<\/type>/i.exec(content)
if (typeMatch) {
const subType = parseInt(typeMatch[1])
switch (subType) {
case 6: return 4 // 文件 -> FILE
case 33:
case 36: return 24 // 小程序 -> SHARE
case 57: return 25 // 引用回复 -> REPLY
default: return 7 // 链接 -> LINK
}
}
}
return MESSAGE_TYPE_MAP[localType] ?? 99
}
/**
* 解码消息内容
*/
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
/**
* 解析消息内容为可读文本
*/
private parseMessageContent(content: string, localType: number): string | null {
if (!content) return null
switch (localType) {
case 1:
return this.stripSenderPrefix(content)
case 3: return '[图片]'
case 34: return '[语音消息]'
case 42: return '[名片]'
case 43: return '[视频]'
case 47: return '[动画表情]'
case 48: return '[位置]'
case 49: {
const title = this.extractXmlValue(content, 'title')
return title || '[链接]'
}
case 50: return '[通话]'
case 10000: return this.cleanSystemMessage(content)
default:
if (content.includes('<type>57</type>')) {
const title = this.extractXmlValue(content, 'title')
return title || '[引用消息]'
}
return this.stripSenderPrefix(content) || null
}
}
private stripSenderPrefix(content: string): string {
return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '')
}
private extractXmlValue(xml: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\/${tagName}>`, 'i')
const match = regex.exec(xml)
if (match) {
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private cleanSystemMessage(content: string): string {
return content
.replace(/<img[^>]*>/gi, '')
.replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim() || '[系统消息]'
}
/**
* 获取消息类型名称
*/
private getMessageTypeName(localType: number): string {
const typeNames: Record<number, string> = {
1: '文本消息',
3: '图片消息',
34: '语音消息',
42: '名片消息',
43: '视频消息',
47: '动画表情',
48: '位置消息',
49: '链接消息',
50: '通话消息',
10000: '系统消息'
}
return typeNames[localType] || '其他消息'
}
/**
* 格式化时间戳为可读字符串
*/
private formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
private async collectMessages(
sessionId: string,
cleanedMyWxid: string,
dateRange?: { start: number; end: number } | null
): Promise<{ rows: any[]; memberSet: Map<string, ChatLabMember>; firstTime: number | null; lastTime: number | null }> {
const rows: any[] = []
const memberSet = new Map<string, ChatLabMember>()
let firstTime: number | null = null
let lastTime: number | null = null
const cursor = await wcdbService.openMessageCursor(
sessionId,
500,
true,
dateRange?.start || 0,
dateRange?.end || 0
)
if (!cursor.success || !cursor.cursor) {
return { rows, memberSet, firstTime, lastTime }
}
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
const createTime = parseInt(row.create_time || '0', 10)
if (dateRange) {
if (createTime < dateRange.start || createTime > dateRange.end) continue
}
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = parseInt(row.local_type || row.type || '1', 10)
const senderUsername = row.sender_username || ''
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSend = parseInt(isSendRaw, 10) === 1
const actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
const memberInfo = await this.getContactInfo(actualSender)
if (!memberSet.has(actualSender)) {
memberSet.set(actualSender, {
platformId: actualSender,
accountName: memberInfo.displayName
})
}
rows.push({
createTime,
localType,
content,
senderUsername: actualSender,
isSend
})
if (firstTime === null || createTime < firstTime) firstTime = createTime
if (lastTime === null || createTime > lastTime) lastTime = createTime
}
hasMore = batch.hasMore === true
}
} finally {
await wcdbService.closeMessageCursor(cursor.cursor)
}
return { rows, memberSet, firstTime, lastTime }
}
/**
* 导出单个会话为 ChatLab 格式
*/
async exportSessionToChatLab(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows
allMessages.sort((a, b) => a.createTime - b.createTime)
onProgress?.({
current: 50,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
const memberInfo = collected.memberSet.get(msg.senderUsername) || {
platformId: msg.senderUsername,
accountName: msg.senderUsername
}
return {
sender: msg.senderUsername,
accountName: memberInfo.accountName,
timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content),
content: this.parseMessageContent(msg.content, msg.localType)
}
})
const chatLabExport: ChatLabExport = {
chatlab: {
version: '0.0.1',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
},
meta: {
name: sessionInfo.displayName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
...(isGroup && { groupId: sessionId })
},
members: Array.from(collected.memberSet.values()),
messages: chatLabMessages
}
onProgress?.({
current: 80,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing'
})
if (options.format === 'chatlab-jsonl') {
const lines: string[] = []
lines.push(JSON.stringify({
_type: 'header',
chatlab: chatLabExport.chatlab,
meta: chatLabExport.meta
}))
for (const member of chatLabExport.members) {
lines.push(JSON.stringify({ _type: 'member', ...member }))
}
for (const message of chatLabExport.messages) {
lines.push(JSON.stringify({ _type: 'message', ...message }))
}
fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8')
} else {
fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
}
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete'
})
return { success: true }
} catch (e) {
console.error('ExportService: 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 导出单个会话为详细 JSON 格式(原项目格式)
*/
async exportSessionToDetailedJson(
sessionId: string,
outputPath: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const cleanedMyWxid = conn.cleanedWxid
const isGroup = sessionId.includes('@chatroom')
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
onProgress?.({
current: 0,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'preparing'
})
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages: any[] = []
for (const msg of collected.rows) {
const senderInfo = await this.getContactInfo(msg.senderUsername)
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
const source = sourceMatch ? sourceMatch[0] : ''
allMessages.push({
localId: allMessages.length + 1,
createTime: msg.createTime,
formattedTime: this.formatTimestamp(msg.createTime),
type: this.getMessageTypeName(msg.localType),
localType: msg.localType,
content: this.parseMessageContent(msg.content, msg.localType),
isSend: msg.isSend ? 1 : 0,
senderUsername: msg.senderUsername,
senderDisplayName: senderInfo.displayName,
source,
senderAvatarKey: msg.senderUsername
})
}
allMessages.sort((a, b) => a.createTime - b.createTime)
onProgress?.({
current: 70,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'writing'
})
const detailedExport = {
session: {
wxid: sessionId,
nickname: sessionInfo.displayName,
remark: sessionInfo.displayName,
displayName: sessionInfo.displayName,
type: isGroup ? '群聊' : '私聊',
lastTimestamp: collected.lastTime,
messageCount: allMessages.length
},
messages: allMessages
}
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
onProgress?.({
current: 100,
total: 100,
currentSession: sessionInfo.displayName,
phase: 'complete'
})
return { success: true }
} catch (e) {
console.error('ExportService: 导出失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 批量导出多个会话
*/
async exportSessions(
sessionIds: string[],
outputDir: string,
options: ExportOptions,
onProgress?: (progress: ExportProgress) => void
): Promise<{ success: boolean; successCount: number; failCount: number; error?: string }> {
let successCount = 0
let failCount = 0
try {
const conn = await this.ensureConnected()
if (!conn.success) {
return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
}
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const sessionInfo = await this.getContactInfo(sessionId)
onProgress?.({
current: i + 1,
total: sessionIds.length,
currentSession: sessionInfo.displayName,
phase: 'exporting'
})
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
let ext = '.json'
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
const outputPath = path.join(outputDir, `${safeName}${ext}`)
let result: { success: boolean; error?: string }
if (options.format === 'json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options)
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
result = await this.exportSessionToChatLab(sessionId, outputPath, options)
} else {
result = { success: false, error: `不支持的格式: ${options.format}` }
}
if (result.success) {
successCount++
} else {
failCount++
console.error(`导出 ${sessionId} 失败:`, result.error)
}
}
onProgress?.({
current: sessionIds.length,
total: sessionIds.length,
currentSession: '',
phase: 'complete'
})
return { success: true, successCount, failCount }
} catch (e) {
return { success: false, successCount, failCount, error: String(e) }
}
}
}
export const exportService = new ExportService()

View File

@@ -0,0 +1,251 @@
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
export interface GroupChatInfo {
username: string
displayName: string
memberCount: number
avatarUrl?: string
}
export interface GroupMember {
username: string
displayName: string
avatarUrl?: string
}
export interface GroupMessageRank {
member: GroupMember
messageCount: number
}
export interface GroupActiveHours {
hourlyDistribution: Record<number, number>
}
export interface MediaTypeCount {
type: number
name: string
count: number
}
export interface GroupMediaStats {
typeCounts: MediaTypeCount[]
total: number
}
class GroupAnalyticsService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
private cleanAccountDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
}
return trimmed
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true }
}
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) {
return { success: false, error: sessionResult.error || '获取会话失败' }
}
const rows = sessionResult.sessions as Record<string, any>[]
const groupIds = rows
.map((row) => row.username || row.user_name || row.userName || '')
.filter((username) => username.includes('@chatroom'))
const [displayNames, avatarUrls, memberCounts] = await Promise.all([
wcdbService.getDisplayNames(groupIds),
wcdbService.getAvatarUrls(groupIds),
wcdbService.getGroupMemberCounts(groupIds)
])
const groups: GroupChatInfo[] = []
for (const groupId of groupIds) {
groups.push({
username: groupId,
displayName: displayNames.success && displayNames.map
? (displayNames.map[groupId] || groupId)
: groupId,
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
? memberCounts.map[groupId]
: 0,
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined
})
}
groups.sort((a, b) => b.memberCount - a.memberCount)
return { success: true, data: groups }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const result = await wcdbService.getGroupMembers(chatroomId)
if (!result.success || !result.members) {
return { success: false, error: result.error || '获取群成员失败' }
}
const members = result.members as { username: string; avatarUrl?: string }[]
const usernames = members.map((m) => m.username)
const displayNames = await wcdbService.getDisplayNames(usernames)
const data: GroupMember[] = members.map((m) => ({
username: m.username,
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username,
avatarUrl: m.avatarUrl
}))
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMessageRanking(chatroomId: string, limit: number = 20, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupMessageRank[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0)
if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' }
const d = result.data
const sessionData = d.sessions[chatroomId]
if (!sessionData || !sessionData.senders) return { success: true, data: [] }
const idMap = d.idMap || {}
const senderEntries = Object.entries(sessionData.senders as Record<string, number>)
const rankings: GroupMessageRank[] = senderEntries
.map(([id, count]) => {
const username = idMap[id] || id
return {
member: { username, displayName: username }, // Display name will be resolved below
messageCount: count
}
})
.sort((a, b) => b.messageCount - a.messageCount)
.slice(0, limit)
// 批量获取显示名称和头像
const usernames = rankings.map(r => r.member.username)
const [names, avatars] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
])
for (const rank of rankings) {
if (names.success && names.map && names.map[rank.member.username]) {
rank.member.displayName = names.map[rank.member.username]
}
if (avatars.success && avatars.map && avatars.map[rank.member.username]) {
rank.member.avatarUrl = avatars.map[rank.member.username]
}
}
return { success: true, data: rankings }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0)
if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' }
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) {
hourlyDistribution[i] = result.data.hourly[i] || 0
}
return { success: true, data: { hourlyDistribution } }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMediaStats(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupMediaStats; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const result = await wcdbService.getGroupStats(chatroomId, startTime || 0, endTime || 0)
if (!result.success || !result.data) return { success: false, error: result.error || '聚合失败' }
const typeCountsRaw = result.data.typeCounts as Record<string, number>
const mainTypes = [1, 3, 34, 43, 47, 49]
const typeNames: Record<number, string> = {
1: '文本', 3: '图片', 34: '语音', 43: '视频', 47: '表情包', 49: '链接/文件'
}
const countsMap = new Map<number, number>()
let othersCount = 0
for (const [typeStr, count] of Object.entries(typeCountsRaw)) {
const type = parseInt(typeStr, 10)
if (mainTypes.includes(type)) {
countsMap.set(type, (countsMap.get(type) || 0) + count)
} else {
othersCount += count
}
}
const mediaCounts: MediaTypeCount[] = mainTypes
.map(type => ({
type,
name: typeNames[type],
count: countsMap.get(type) || 0
}))
.filter(item => item.count > 0)
if (othersCount > 0) {
mediaCounts.push({ type: -1, name: '其他', count: othersCount })
}
mediaCounts.sort((a, b) => b.count - a.count)
const total = mediaCounts.reduce((sum, item) => sum + item.count, 0)
return { success: true, data: { typeCounts: mediaCounts, total } }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const groupAnalyticsService = new GroupAnalyticsService()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import { imageDecryptService } from './imageDecryptService'
type PreloadImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
}
type PreloadTask = PreloadImagePayload & {
key: string
}
export class ImagePreloadService {
private queue: PreloadTask[] = []
private pending = new Set<string>()
private active = 0
private readonly maxConcurrent = 2
enqueue(payloads: PreloadImagePayload[]): void {
if (!Array.isArray(payloads) || payloads.length === 0) return
for (const payload of payloads) {
const cacheKey = payload.imageMd5 || payload.imageDatName
if (!cacheKey) continue
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue
this.pending.add(key)
this.queue.push({ ...payload, key })
}
this.processQueue()
}
private processQueue(): void {
while (this.active < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift()
if (!task) return
this.active += 1
void this.handleTask(task).finally(() => {
this.active -= 1
this.pending.delete(task.key)
this.processQueue()
})
}
}
private async handleTask(task: PreloadTask): Promise<void> {
const cacheKey = task.imageMd5 || task.imageDatName
if (!cacheKey) return
try {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
})
if (cached.success) return
await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
})
} catch {
// ignore preload failures
}
}
}
export const imagePreloadService = new ImagePreloadService()

View File

@@ -0,0 +1,928 @@
import { app } from 'electron'
import { join, dirname, basename } from 'path'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import crypto from 'crypto'
const execFileAsync = promisify(execFile)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
export class KeyService {
private koffi: any = null
private lib: any = null
private initialized = false
private initHook: any = null
private pollKeyData: any = null
private getStatusMessage: any = null
private cleanupHook: any = null
private getLastErrorMsg: any = null
// Win32 APIs
private kernel32: any = null
private user32: any = null
private advapi32: any = null
// Kernel32
private OpenProcess: any = null
private CloseHandle: any = null
private VirtualQueryEx: any = null
private ReadProcessMemory: any = null
private MEMORY_BASIC_INFORMATION: any = null
private TerminateProcess: any = null
// User32
private EnumWindows: any = null
private GetWindowTextW: any = null
private GetWindowTextLengthW: any = null
private GetClassNameW: any = null
private GetWindowThreadProcessId: any = null
private IsWindowVisible: any = null
private EnumChildWindows: any = null
private WNDENUMPROC_PTR: any = null
// Advapi32
private RegOpenKeyExW: any = null
private RegQueryValueExW: any = null
private RegCloseKey: any = null
// Constants
private readonly PROCESS_ALL_ACCESS = 0x1F0FFF
private readonly PROCESS_TERMINATE = 0x0001
private readonly KEY_READ = 0x20019
private readonly HKEY_LOCAL_MACHINE = 0x80000002
private readonly HKEY_CURRENT_USER = 0x80000001
private readonly ERROR_SUCCESS = 0
private getDllPath(): string {
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
return join(resourcesPath, 'wx_key.dll')
}
private ensureLoaded(): boolean {
if (this.initialized) return true
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
this.lib = this.koffi.load(dllPath)
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
this.pollKeyData = this.lib.func('bool PollKeyData(_Out_ char *keyBuffer, int bufferSize)')
this.getStatusMessage = this.lib.func('bool GetStatusMessage(_Out_ char *msgBuffer, int bufferSize, _Out_ int *outLevel)')
this.cleanupHook = this.lib.func('bool CleanupHook()')
this.getLastErrorMsg = this.lib.func('const char* GetLastErrorMsg()')
this.initialized = true
return true
} catch (e) {
console.error('加载 wx_key.dll 失败:', e)
return false
}
}
private ensureWin32(): boolean {
return process.platform === 'win32'
}
private ensureKernel32(): boolean {
if (this.kernel32) return true
try {
this.koffi = require('koffi')
this.kernel32 = this.koffi.load('kernel32.dll')
const HANDLE = this.koffi.pointer('HANDLE', this.koffi.opaque())
this.MEMORY_BASIC_INFORMATION = this.koffi.struct('MEMORY_BASIC_INFORMATION', {
BaseAddress: 'uint64',
AllocationBase: 'uint64',
AllocationProtect: 'uint32',
RegionSize: 'uint64',
State: 'uint32',
Protect: 'uint32',
Type: 'uint32'
})
// Use explicit definitions to avoid parser issues
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
return true
} catch (e) {
console.error('初始化 kernel32 失败:', e)
return false
}
}
private decodeUtf8(buf: Buffer): string {
const nullIdx = buf.indexOf(0)
return buf.toString('utf8', 0, nullIdx > -1 ? nullIdx : undefined).trim()
}
private ensureUser32(): boolean {
if (this.user32) return true
try {
this.koffi = require('koffi')
this.user32 = this.koffi.load('user32.dll')
// Callbacks
// Define the prototype and its pointer type
const WNDENUMPROC = this.koffi.proto('bool __stdcall (void *hWnd, intptr_t lParam)')
this.WNDENUMPROC_PTR = this.koffi.pointer(WNDENUMPROC)
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
this.GetClassNameW = this.user32.func('GetClassNameW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowThreadProcessId = this.user32.func('GetWindowThreadProcessId', 'uint32', ['void*', this.koffi.out('uint32*')])
this.IsWindowVisible = this.user32.func('IsWindowVisible', 'bool', ['void*'])
return true
} catch (e) {
console.error('初始化 user32 失败:', e)
return false
}
}
private ensureAdvapi32(): boolean {
if (this.advapi32) return true
try {
this.koffi = require('koffi')
this.advapi32 = this.koffi.load('advapi32.dll')
// Types
// Use intptr_t for HKEY to match system architecture (64-bit safe)
const HKEY = this.koffi.alias('HKEY', 'intptr_t')
const HKEY_PTR = this.koffi.pointer(HKEY)
this.RegOpenKeyExW = this.advapi32.func('RegOpenKeyExW', 'long', [HKEY, 'uint16*', 'uint32', 'uint32', this.koffi.out(HKEY_PTR)])
this.RegQueryValueExW = this.advapi32.func('RegQueryValueExW', 'long', [HKEY, 'uint16*', 'uint32*', this.koffi.out('uint32*'), this.koffi.out('uint8*'), this.koffi.out('uint32*')])
this.RegCloseKey = this.advapi32.func('RegCloseKey', 'long', [HKEY])
return true
} catch (e) {
console.error('初始化 advapi32 失败:', e)
return false
}
}
private decodeCString(ptr: any): string {
try {
if (typeof ptr === 'string') return ptr
return this.koffi.decode(ptr, 'char', -1)
} catch {
return ''
}
}
// --- WeChat Process & Path Finding ---
// Helper to read simple registry string
private readRegistryString(rootKey: number, subKey: string, valueName: string): string | null {
if (!this.ensureAdvapi32()) return null
// Convert strings to UTF-16 buffers
const subKeyBuf = Buffer.from(subKey + '\0', 'ucs2')
const valueNameBuf = valueName ? Buffer.from(valueName + '\0', 'ucs2') : null
const phkResult = Buffer.alloc(8) // Pointer size (64-bit safe)
if (this.RegOpenKeyExW(rootKey, subKeyBuf, 0, this.KEY_READ, phkResult) !== this.ERROR_SUCCESS) {
return null
}
const hKey = this.koffi.decode(phkResult, 'uintptr_t')
try {
const lpcbData = Buffer.alloc(4)
lpcbData.writeUInt32LE(0, 0) // First call to get size? No, RegQueryValueExW expects initialized size or null to get size.
// Usually we call it twice or just provide a big buffer.
// Let's call twice.
let ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, null, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
const size = lpcbData.readUInt32LE(0)
if (size === 0) return null
const dataBuf = Buffer.alloc(size)
ret = this.RegQueryValueExW(hKey, valueNameBuf, null, null, dataBuf, lpcbData)
if (ret !== this.ERROR_SUCCESS) return null
// Read UTF-16 string (remove null terminator)
let str = dataBuf.toString('ucs2')
if (str.endsWith('\0')) str = str.slice(0, -1)
return str
} finally {
this.RegCloseKey(hKey)
}
}
private async findWeChatInstallPath(): Promise<string | null> {
// 1. Registry - Uninstall Keys
const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'
]
const roots = [this.HKEY_LOCAL_MACHINE, this.HKEY_CURRENT_USER]
// NOTE: Scanning subkeys in registry via Koffi is tedious (RegEnumKeyEx).
// Simplified strategy: Check common known registry keys first, then fallback to common paths.
// wx_key searches *all* subkeys of Uninstall, which is robust but complex to port quickly.
// Let's rely on specific Tencent keys first.
// 2. Tencent specific keys
const tencentKeys = [
'Software\\Tencent\\WeChat',
'Software\\WOW6432Node\\Tencent\\WeChat',
'Software\\Tencent\\Weixin',
]
for (const root of roots) {
for (const key of tencentKeys) {
const path = this.readRegistryString(root, key, 'InstallPath')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
if (path && existsSync(join(path, 'WeChat.exe'))) return join(path, 'WeChat.exe')
}
}
// 3. Uninstall key exact match (sometimes works)
for (const root of roots) {
for (const parent of uninstallKeys) {
// Try WeChat specific subkey
const path = this.readRegistryString(root, parent + '\\WeChat', 'InstallLocation')
if (path && existsSync(join(path, 'Weixin.exe'))) return join(path, 'Weixin.exe')
}
}
// 4. Common Paths
const drives = ['C', 'D', 'E', 'F']
const commonPaths = [
'Program Files\\Tencent\\WeChat\\WeChat.exe',
'Program Files (x86)\\Tencent\\WeChat\\WeChat.exe',
'Program Files\\Tencent\\Weixin\\Weixin.exe',
'Program Files (x86)\\Tencent\\Weixin\\Weixin.exe'
]
for (const drive of drives) {
for (const p of commonPaths) {
const full = join(drive + ':\\', p)
if (existsSync(full)) return full
}
}
return null
}
private async findPidByImageName(imageName: string): Promise<number | null> {
try {
const { stdout } = await execFileAsync('tasklist', ['/FI', `IMAGENAME eq ${imageName}`, '/FO', 'CSV', '/NH'])
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
for (const line of lines) {
if (line.startsWith('INFO:')) continue
const parts = line.split('","').map((p) => p.replace(/^"|"$/g, ''))
if (parts[0]?.toLowerCase() === imageName.toLowerCase()) {
const pid = Number(parts[1])
if (!Number.isNaN(pid)) return pid
}
}
return null
} catch (e) {
console.error(`获取进程失败 (${imageName}):`, e)
return null
}
}
private async findWeChatPid(): Promise<number | null> {
const names = ['Weixin.exe', 'WeChat.exe']
for (const name of names) {
const pid = await this.findPidByImageName(name)
if (pid) return pid
}
const fallbackPid = await this.waitForWeChatWindow(5000)
return fallbackPid ?? null
}
private async killWeChatProcesses() {
try {
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe'])
} catch (e) {
// Ignore if not found
}
await new Promise(r => setTimeout(r, 1000))
}
// --- Window Detection ---
private getWindowTitle(hWnd: any): string {
const len = this.GetWindowTextLengthW(hWnd)
if (len === 0) return ''
const buf = Buffer.alloc((len + 1) * 2)
this.GetWindowTextW(hWnd, buf, len + 1)
return buf.toString('ucs2', 0, len * 2)
}
private getClassName(hWnd: any): string {
const buf = Buffer.alloc(512)
const len = this.GetClassNameW(hWnd, buf, 256)
return buf.toString('ucs2', 0, len * 2)
}
private isWeChatWindowTitle(title: string): boolean {
const normalized = title.trim()
if (!normalized) return false
const lower = normalized.toLowerCase()
return normalized === '微信' || lower === 'wechat' || lower === 'weixin'
}
private async waitForWeChatWindow(timeoutMs = 25000): Promise<number | null> {
if (!this.ensureUser32()) return null
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
let foundPid: number | null = null
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true
const pidBuf = Buffer.alloc(4)
this.GetWindowThreadProcessId(hWnd, pidBuf)
const pid = pidBuf.readUInt32LE(0)
if (pid) {
foundPid = pid
return false
}
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
if (foundPid) return foundPid
await new Promise(r => setTimeout(r, 500))
}
return null
}
private collectChildWindowInfos(parent: any): Array<{ title: string; className: string }> {
const children: Array<{ title: string; className: string }> = []
const enumChildCallback = this.koffi.register((hChild: any, lp: any) => {
const title = this.getWindowTitle(hChild).trim()
const className = this.getClassName(hChild).trim()
children.push({ title, className })
return true
}, this.WNDENUMPROC_PTR)
this.EnumChildWindows(parent, enumChildCallback, 0)
this.koffi.unregister(enumChildCallback)
return children
}
private hasReadyComponents(children: Array<{ title: string; className: string }>): boolean {
if (children.length === 0) return false
const readyTexts = ['聊天', '登录', '账号']
const readyClassMarkers = ['WeChat', 'Weixin', 'TXGuiFoundation', 'Qt5', 'ChatList', 'MainWnd', 'BrowserWnd', 'ListView']
const readyChildCountThreshold = 14
let classMatchCount = 0
let titleMatchCount = 0
let hasValidClassName = false
for (const child of children) {
const normalizedTitle = child.title.replace(/\s+/g, '')
if (normalizedTitle) {
if (readyTexts.some(marker => normalizedTitle.includes(marker))) {
return true
}
titleMatchCount += 1
}
const className = child.className
if (className) {
if (readyClassMarkers.some(marker => className.includes(marker))) {
return true
}
if (className.length > 5) {
classMatchCount += 1
hasValidClassName = true
}
}
}
if (classMatchCount >= 3 || titleMatchCount >= 2) return true
if (children.length >= readyChildCountThreshold) return true
if (hasValidClassName && children.length >= 5) return true
return false
}
private async waitForWeChatWindowComponents(pid: number, timeoutMs = 15000): Promise<boolean> {
if (!this.ensureUser32()) return true
const startTime = Date.now()
while (Date.now() - startTime < timeoutMs) {
let ready = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true
const pidBuf = Buffer.alloc(4)
this.GetWindowThreadProcessId(hWnd, pidBuf)
const windowPid = pidBuf.readUInt32LE(0)
if (windowPid !== pid) return true
const children = this.collectChildWindowInfos(hWnd)
if (this.hasReadyComponents(children)) {
ready = true
return false
}
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
if (ready) return true
await new Promise(r => setTimeout(r, 500))
}
return true
}
// --- Main Methods ---
async autoGetDbKey(
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
if (!this.ensureKernel32()) return { success: false, error: 'Kernel32 Init Failed' }
const logs: string[] = []
// 1. Find Path
onStatus?.('正在定位微信安装路径...', 0)
let wechatPath = await this.findWeChatInstallPath()
if (!wechatPath) {
const err = '未找到微信安装路径请确认已安装PC微信'
onStatus?.(err, 2)
return { success: false, error: err }
}
// 2. Restart WeChat
onStatus?.('正在重启微信以进行获取...', 0)
await this.killWeChatProcesses()
// 3. Launch
onStatus?.('正在启动微信...', 0)
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' })
sub.unref()
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
onStatus?.('等待微信界面就绪...', 0)
const pid = await this.waitForWeChatWindow()
if (!pid) {
return { success: false, error: '启动微信失败或等待界面就绪超时' }
}
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
onStatus?.('正在检测微信界面组件...', 0)
await this.waitForWeChatWindowComponents(pid, 15000)
// 5. Inject
const ok = this.initHook(pid)
if (!ok) {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) {
return { success: false, error }
}
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
const status = this.getStatusMessage && this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)
? this.decodeUtf8(statusBuffer)
: ''
return { success: false, error: status || '初始化失败' }
}
const keyBuffer = Buffer.alloc(128)
const start = Date.now()
try {
while (Date.now() - start < timeoutMs) {
if (this.pollKeyData(keyBuffer, keyBuffer.length)) {
const key = this.decodeUtf8(keyBuffer)
if (key.length === 64) {
onStatus?.('密钥获取成功', 1)
return { success: true, key, logs }
}
}
for (let i = 0; i < 5; i++) {
const statusBuffer = Buffer.alloc(256)
const levelOut = [0]
if (!this.getStatusMessage(statusBuffer, statusBuffer.length, levelOut)) {
break
}
const msg = this.decodeUtf8(statusBuffer)
const level = levelOut[0] ?? 0
if (msg) {
logs.push(msg)
onStatus?.(msg, level)
}
}
await new Promise((resolve) => setTimeout(resolve, 120))
}
} finally {
try {
this.cleanupHook()
} catch { }
}
return { success: false, error: '获取密钥超时', logs }
}
// --- Image Key Stuff (Legacy but kept) ---
private isAccountDir(dirPath: string): boolean {
return (
existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2'))
)
}
private isPotentialAccountName(name: string): boolean {
const lower = name.toLowerCase()
if (lower.startsWith('all') || lower.startsWith('applet') || lower.startsWith('backup') || lower.startsWith('wmpf')) {
return false
}
if (lower.startsWith('wxid_')) return true
if (/^\d+$/.test(name) && name.length >= 6) return true
return name.length > 5
}
private listAccountDirs(rootDir: string): string[] {
try {
const entries = readdirSync(rootDir)
const high: string[] = []
const low: string[] = []
for (const entry of entries) {
const fullPath = join(rootDir, entry)
try {
if (!statSync(fullPath).isDirectory()) continue
} catch {
continue
}
if (!this.isPotentialAccountName(entry)) {
continue
}
if (this.isAccountDir(fullPath)) {
high.push(fullPath)
} else {
low.push(fullPath)
}
}
return high.length ? high.sort() : low.sort()
} catch {
return []
}
}
private normalizeExistingDir(inputPath: string): string | null {
const trimmed = inputPath.replace(/[\\\\/]+$/, '')
if (!existsSync(trimmed)) return null
try {
const stats = statSync(trimmed)
if (stats.isFile()) {
return dirname(trimmed)
}
} catch {
return null
}
return trimmed
}
private resolveAccountDirFromPath(inputPath: string): string | null {
const normalized = this.normalizeExistingDir(inputPath)
if (!normalized) return null
if (this.isAccountDir(normalized)) return normalized
const lower = normalized.toLowerCase()
if (lower.endsWith('db_storage') || lower.endsWith('filestorage') || lower.endsWith('image') || lower.endsWith('image2')) {
const parent = dirname(normalized)
if (this.isAccountDir(parent)) return parent
const grandParent = dirname(parent)
if (this.isAccountDir(grandParent)) return grandParent
}
const candidates = this.listAccountDirs(normalized)
if (candidates.length) return candidates[0]
return null
}
private resolveAccountDir(manualDir?: string): string | null {
if (manualDir) {
const resolved = this.resolveAccountDirFromPath(manualDir)
if (resolved) return resolved
}
const userProfile = process.env.USERPROFILE
if (!userProfile) return null
const roots = [
join(userProfile, 'Documents', 'xwechat_files'),
join(userProfile, 'Documents', 'WeChat Files')
]
for (const root of roots) {
if (!existsSync(root)) continue
const candidates = this.listAccountDirs(root)
if (candidates.length) return candidates[0]
}
return null
}
private findTemplateDatFiles(rootDir: string): string[] {
const files: string[] = []
const stack = [rootDir]
const maxFiles = 32
while (stack.length && files.length < maxFiles) {
const dir = stack.pop() as string
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
continue
}
for (const entry of entries) {
const fullPath = join(dir, entry)
let stats: any
try {
stats = statSync(fullPath)
} catch {
continue
}
if (stats.isDirectory()) {
stack.push(fullPath)
} else if (entry.endsWith('_t.dat')) {
files.push(fullPath)
if (files.length >= maxFiles) break
}
}
}
if (!files.length) return []
const dateReg = /(\d{4}-\d{2})/
files.sort((a, b) => {
const ma = a.match(dateReg)?.[1]
const mb = b.match(dateReg)?.[1]
if (ma && mb) return mb.localeCompare(ma)
return 0
})
return files.slice(0, 16)
}
private getXorKey(templateFiles: string[]): number | null {
const counts = new Map<string, number>()
for (const file of templateFiles) {
try {
const bytes = readFileSync(file)
if (bytes.length < 2) continue
const x = bytes[bytes.length - 2]
const y = bytes[bytes.length - 1]
const key = `${x}_${y}`
counts.set(key, (counts.get(key) ?? 0) + 1)
} catch { }
}
if (!counts.size) return null
let mostKey = ''
let mostCount = 0
for (const [key, count] of counts) {
if (count > mostCount) {
mostCount = count
mostKey = key
}
}
if (!mostKey) return null
const [xStr, yStr] = mostKey.split('_')
const x = Number(xStr)
const y = Number(yStr)
const xorKey = x ^ 0xFF
const check = y ^ 0xD9
return xorKey === check ? xorKey : null
}
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
for (const file of templateFiles) {
try {
const bytes = readFileSync(file)
if (bytes.length < 0x1f) continue
if (
bytes[0] === 0x07 &&
bytes[1] === 0x08 &&
bytes[2] === 0x56 &&
bytes[3] === 0x32 &&
bytes[4] === 0x08 &&
bytes[5] === 0x07
) {
return bytes.subarray(0x0f, 0x1f)
}
} catch { }
}
return null
}
private isAlphaNumAscii(byte: number): boolean {
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)
}
private isUtf16AsciiKey(buf: Buffer, start: number): boolean {
if (start + 64 > buf.length) return false
for (let j = 0; j < 32; j++) {
const charByte = buf[start + j * 2]
const nullByte = buf[start + j * 2 + 1]
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) {
return false
}
}
return true
}
private verifyKey(ciphertext: Buffer, keyBytes: Buffer): boolean {
try {
const key = keyBytes.subarray(0, 16)
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
} catch {
return false
}
}
private getMemoryRegions(hProcess: any): Array<[number, number]> {
const regions: Array<[number, number]> = []
const MEM_COMMIT = 0x1000
const MEM_PRIVATE = 0x20000
const MEM_MAPPED = 0x40000
const MEM_IMAGE = 0x1000000
const PAGE_NOACCESS = 0x01
const PAGE_GUARD = 0x100
let address = 0
const maxAddress = 0x7fffffffffff
while (address >= 0 && address < maxAddress) {
const info: any = {}
const result = this.VirtualQueryEx(hProcess, address, info, this.koffi.sizeof(this.MEMORY_BASIC_INFORMATION))
if (!result) break
const state = info.State
const protect = info.Protect
const type = info.Type
const regionSize = Number(info.RegionSize)
if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) {
regions.push([Number(info.BaseAddress), regionSize])
}
}
const nextAddress = address + regionSize
if (nextAddress <= address) break
address = nextAddress
}
return regions
}
private readProcessMemory(hProcess: any, address: number, size: number): Buffer | null {
const buffer = Buffer.alloc(size)
const bytesRead = [0]
const ok = this.ReadProcessMemory(hProcess, address, buffer, size, bytesRead)
if (!ok || bytesRead[0] === 0) return null
return buffer.subarray(0, bytesRead[0])
}
private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise<string | null> {
if (!this.ensureKernel32()) return null
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
if (!hProcess) return null
try {
const regions = this.getMemoryRegions(hProcess)
const chunkSize = 4 * 1024 * 1024
const overlap = 65
for (const [baseAddress, regionSize] of regions) {
if (regionSize > 100 * 1024 * 1024) continue
let offset = 0
let trailing: Buffer | null = null
while (offset < regionSize) {
const remaining = regionSize - offset
const currentChunkSize = remaining > chunkSize ? chunkSize : remaining
const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize)
if (!chunk || !chunk.length) {
offset += currentChunkSize
trailing = null
continue
}
let dataToScan: Buffer
if (trailing && trailing.length) {
dataToScan = Buffer.concat([trailing, chunk])
} else {
dataToScan = chunk
}
for (let i = 0; i < dataToScan.length - 34; i++) {
if (this.isAlphaNumAscii(dataToScan[i])) continue
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
valid = false
break
}
}
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
valid = false
}
if (valid) {
const keyBytes = dataToScan.subarray(i + 1, i + 33)
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
}
for (let i = 0; i < dataToScan.length - 65; i++) {
if (!this.isUtf16AsciiKey(dataToScan, i)) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) {
keyBytes[j] = dataToScan[i + j * 2]
}
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
const start = dataToScan.length - overlap
trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize
}
}
return null
} finally {
try {
this.CloseHandle(hProcess)
} catch { }
}
}
async autoGetImageKey(
manualDir?: string,
onProgress?: (message: string) => void
): Promise<ImageKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
if (!this.ensureKernel32()) return { success: false, error: '初始化系统 API 失败' }
onProgress?.('正在定位微信账号目录...')
const accountDir = this.resolveAccountDir(manualDir)
if (!accountDir) return { success: false, error: '未找到微信账号目录' }
onProgress?.('正在收集模板文件...')
const templateFiles = this.findTemplateDatFiles(accountDir)
if (!templateFiles.length) return { success: false, error: '未找到模板文件' }
onProgress?.('正在计算 XOR 密钥...')
const xorKey = this.getXorKey(templateFiles)
if (xorKey == null) return { success: false, error: '无法计算 XOR 密钥' }
onProgress?.('正在读取加密模板数据...')
const ciphertext = this.getCiphertextFromTemplate(templateFiles)
if (!ciphertext) return { success: false, error: '无法读取加密模板数据' }
const pid = await this.findWeChatPid()
if (!pid) return { success: false, error: '未检测到微信进程' }
onProgress?.('正在扫描内存获取 AES 密钥...')
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext)
if (!aesKey) {
return {
success: false,
error: '未能从内存中获取 AES 密钥,请打开朋友圈图片后重试'
}
}
return { success: true, xorKey, aesKey: aesKey.slice(0, 16) }
}
}

File diff suppressed because it is too large Load Diff

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WeFlow</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
installer.nsh Normal file
View File

@@ -0,0 +1,18 @@
; 高 DPI 支持
ManifestDPIAware true
!include "WordFunc.nsh"
!macro customInit
; 设置 DPI 感知
System::Call 'USER32::SetProcessDPIAware()'
!macroend
; 在安装开始前修正安装目录
!macro preInit
; 如果安装目录不以 WeFlow 结尾,自动追加
${WordFind} "$INSTDIR" "\" "-1" $R0
${If} $R0 != "WeFlow"
StrCpy $INSTDIR "$INSTDIR\WeFlow"
${EndIf}
!macroend

9332
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

95
package.json Normal file
View File

@@ -0,0 +1,95 @@
{
"name": "weflow",
"version": "1.0.0",
"description": "WeFlow - 微信聊天记录查看工具",
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "vite --mode electron",
"electron:build": "npm run build",
"postinstall": "electron-rebuild"
},
"dependencies": {
"better-sqlite3": "^12.5.0",
"echarts": "^5.5.1",
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"fzstd": "^0.1.1",
"html2canvas": "^1.4.1",
"jieba-wasm": "^2.2.0",
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^7.1.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@types/better-sqlite3": "^7.6.13",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^39.2.7",
"electron-builder": "^25.1.8",
"sass": "^1.83.0",
"sharp": "^0.34.5",
"typescript": "^5.6.3",
"vite": "^6.0.5",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6"
},
"build": {
"appId": "com.WeFlow.app",
"productName": "WeFlow",
"artifactName": "${productName}-${version}-Setup.${ext}",
"directories": {
"output": "release"
},
"win": {
"target": [
"nsis"
],
"icon": "public/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"unicode": true,
"installerLanguages": [
"zh_CN",
"en_US"
],
"language": "2052",
"displayLanguageSelector": false,
"include": "installer.nsh",
"installerIcon": "public/icon.ico",
"uninstallerIcon": "public/icon.ico",
"installerHeaderIcon": "public/icon.ico",
"perMachine": false,
"allowElevation": true,
"installerSidebar": null,
"uninstallerSidebar": null
},
"extraResources": [
{
"from": "resources/",
"to": "resources/"
},
{
"from": "public/icon.ico",
"to": "icon.ico"
}
],
"files": [
"dist/**/*",
"dist-electron/**/*"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
public/assets/face/666.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
public/assets/face/Emm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
public/assets/face/吐.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
public/assets/face/哇.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
public/assets/face/嘘.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
public/assets/face/囧.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/assets/face/困.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/assets/face/晕.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
public/assets/face/汗.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/assets/face/睡.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
public/assets/face/耶.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/assets/face/色.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Some files were not shown because too many files have changed in this diff Show More