mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
新的提交
This commit is contained in:
43
electron/annualReportWorker.ts
Normal file
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
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
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
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
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
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
2333
electron/services/chatService.ts
Normal file
File diff suppressed because it is too large
Load Diff
63
electron/services/config.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
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
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
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
1383
electron/services/imageDecryptService.ts
Normal file
File diff suppressed because it is too large
Load Diff
66
electron/services/imagePreloadService.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
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
1210
electron/services/wcdbService.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user