新的提交
36
.github/workflows/release.yml
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 版本
|
||||||
|
- 所有数据仅在本地处理,不会上传到任何服务器
|
||||||
|
- 请负责任地使用本工具,遵守相关法律法规
|
||||||
43
electron/annualReportWorker.ts
Normal 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) })
|
||||||
|
})
|
||||||
156
electron/imageSearchWorker.ts
Normal 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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
490
electron/services/analyticsService.ts
Normal 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()
|
||||||
928
electron/services/annualReportService.ts
Normal 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()
|
||||||
2333
electron/services/chatService.ts
Normal file
63
electron/services/config.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
159
electron/services/dbPathService.ts
Normal 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()
|
||||||
626
electron/services/exportService.ts
Normal 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()
|
||||||
251
electron/services/groupAnalyticsService.ts
Normal 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()
|
||||||
1383
electron/services/imageDecryptService.ts
Normal file
66
electron/services/imagePreloadService.ts
Normal 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()
|
||||||
928
electron/services/keyService.ts
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
1210
electron/services/wcdbService.ts
Normal file
12
index.html
Normal 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
@@ -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
95
package.json
Normal 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/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/assets/animal/发抖.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/assets/animal/猪头.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/assets/animal/跳跳.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/assets/animal/转圈.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/assets/blessing/庆祝.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/assets/blessing/烟花.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/blessing/爆竹.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
public/assets/blessing/發.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/assets/blessing/礼物.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/assets/blessing/福.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/assets/blessing/红包.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/assets/face/666.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/assets/face/Emm.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/face/亲亲.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/assets/face/偷笑.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/assets/face/傲慢.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/assets/face/再见.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/assets/face/加油.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/assets/face/发呆.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/发怒.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/assets/face/可怜.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/assets/face/右哼哼.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/face/叹气.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/吃瓜.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/face/吐.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/assets/face/呲牙.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/face/咒骂.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/assets/face/哇.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/face/嘘.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/face/嘿哈.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/assets/face/囧.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/困.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/face/坏笑.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/assets/face/大哭.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/assets/face/天啊.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/face/失望.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/奸笑.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/assets/face/好的.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/assets/face/委屈.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/assets/face/害羞.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/face/尴尬.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/得意.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/face/微笑.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/face/快哭了.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/assets/face/恐惧.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/face/悠闲.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/assets/face/惊恐.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/face/惊讶.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/face/愉快.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/憨笑.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/face/打脸.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/face/抓狂.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/assets/face/抠鼻.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/assets/face/捂脸.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/face/撇嘴.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/face/擦汗.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/face/敲打.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/face/无语.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/face/旺柴.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/face/晕.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/assets/face/机智.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/assets/face/汗.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
public/assets/face/流泪.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/face/生病.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/assets/face/疑问.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/assets/face/白眼.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/皱眉.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/睡.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/assets/face/破涕为笑.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/face/社会社会.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/assets/face/笑脸.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/face/翻白眼.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/face/耶.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/assets/face/脸红.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/assets/face/色.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |