mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
Dev (#79)
* fix:尝试修复闪退的问题 * hhhhh * fix(chatService): 优化头像加载兜底机制:收集无 URL 的用户名,从 head_image.db 批量获取并转换为 base64 格式,更新头像缓存并添加错误处理,避免聊天界面头像缺失。(解决了部分,我电脑上有几个不显示) * 优化表诉 * 导出优化 * fix: 尝试修复运行库缺失的问题 * 优化表述 * feat: 实现朋友圈获取; 实现聊天页面跳转到指定日期 * fix:修复了头像加载失败的问题 * Bump version from 1.3.1 to 1.3.2 --------- Co-authored-by: Forrest <jin648862@gmail.com> Co-authored-by: cc <98377878+hicccc77@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { join, dirname } from 'path'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
@@ -17,6 +17,7 @@ import { exportService, ExportOptions, ExportProgress } from './services/exportS
|
||||
import { KeyService } from './services/keyService'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService } from './services/snsService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -614,8 +615,8 @@ function registerIpcHandlers() {
|
||||
return chatService.enrichSessionsContactInfo(usernames)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
|
||||
return chatService.getMessages(sessionId, offset, limit)
|
||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
|
||||
return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
|
||||
@@ -672,6 +673,10 @@ function registerIpcHandlers() {
|
||||
return chatService.getMessageById(sessionId, localId)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
})
|
||||
|
||||
// 私聊克隆
|
||||
|
||||
|
||||
@@ -977,6 +982,17 @@ app.whenReady().then(() => {
|
||||
createOnboardingWindow()
|
||||
}
|
||||
|
||||
// 解决朋友圈图片无法加载问题(添加 Referer)
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||
{
|
||||
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||
},
|
||||
(details, callback) => {
|
||||
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
}
|
||||
)
|
||||
|
||||
// 启动时检测更新
|
||||
checkForUpdatesOnStartup()
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||
@@ -207,5 +207,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
|
||||
}
|
||||
},
|
||||
|
||||
// 朋友圈
|
||||
sns: {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ const emojiDownloading: Map<string, Promise<string | null>> = new Map()
|
||||
class ChatService {
|
||||
private configService: ConfigService
|
||||
private connected = false
|
||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
|
||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean }> = new Map()
|
||||
private readonly messageBatchDefault = 50
|
||||
private avatarCache: Map<string, ContactCacheEntry>
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
@@ -326,7 +326,11 @@ class ChatService {
|
||||
// 检查缓存
|
||||
for (const username of usernames) {
|
||||
const cached = this.avatarCache.get(username)
|
||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||
// 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取
|
||||
// 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取
|
||||
const isValidAvatar = cached?.avatarUrl &&
|
||||
!cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式
|
||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) {
|
||||
result[username] = {
|
||||
displayName: cached.displayName,
|
||||
avatarUrl: cached.avatarUrl
|
||||
@@ -343,9 +347,17 @@ class ChatService {
|
||||
wcdbService.getAvatarUrls(missing)
|
||||
])
|
||||
|
||||
// 收集没有头像 URL 的用户名
|
||||
const missingAvatars: string[] = []
|
||||
|
||||
for (const username of missing) {
|
||||
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
|
||||
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
||||
let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
||||
|
||||
// 如果没有头像 URL,记录下来稍后从 head_image.db 获取
|
||||
if (!avatarUrl) {
|
||||
missingAvatars.push(username)
|
||||
}
|
||||
|
||||
const cacheEntry: ContactCacheEntry = {
|
||||
displayName: displayName || username,
|
||||
@@ -357,6 +369,23 @@ class ChatService {
|
||||
this.avatarCache.set(username, cacheEntry)
|
||||
updatedEntries[username] = cacheEntry
|
||||
}
|
||||
|
||||
// 从 head_image.db 获取缺失的头像
|
||||
if (missingAvatars.length > 0) {
|
||||
const headImageAvatars = await this.getAvatarsFromHeadImageDb(missingAvatars)
|
||||
for (const username of missingAvatars) {
|
||||
const avatarUrl = headImageAvatars[username]
|
||||
if (avatarUrl) {
|
||||
result[username].avatarUrl = avatarUrl
|
||||
const cached = this.avatarCache.get(username)
|
||||
if (cached) {
|
||||
cached.avatarUrl = avatarUrl
|
||||
updatedEntries[username] = cached
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updatedEntries).length > 0) {
|
||||
this.contactCacheService.setEntries(updatedEntries)
|
||||
}
|
||||
@@ -368,6 +397,81 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 head_image.db 批量获取头像(转换为 base64 data URL)
|
||||
*/
|
||||
private async getAvatarsFromHeadImageDb(usernames: string[]): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {}
|
||||
if (usernames.length === 0) return result
|
||||
|
||||
try {
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const wxid = this.configService.get('myWxid')
|
||||
if (!dbPath || !wxid) return result
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return result
|
||||
|
||||
// head_image.db 可能在不同位置
|
||||
const headImageDbPaths = [
|
||||
join(accountDir, 'db_storage', 'head_image', 'head_image.db'),
|
||||
join(accountDir, 'db_storage', 'head_image.db'),
|
||||
join(accountDir, 'head_image.db')
|
||||
]
|
||||
|
||||
let headImageDbPath: string | null = null
|
||||
for (const path of headImageDbPaths) {
|
||||
if (existsSync(path)) {
|
||||
headImageDbPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!headImageDbPath) return result
|
||||
|
||||
// 使用 wcdbService.execQuery 查询加密的 head_image.db
|
||||
for (const username of usernames) {
|
||||
try {
|
||||
const escapedUsername = username.replace(/'/g, "''")
|
||||
const queryResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
headImageDbPath,
|
||||
`SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1`
|
||||
)
|
||||
|
||||
if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0] as any
|
||||
if (row?.image_buffer) {
|
||||
let base64Data: string
|
||||
if (typeof row.image_buffer === 'string') {
|
||||
// WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64
|
||||
if (row.image_buffer.toLowerCase().startsWith('ffd8')) {
|
||||
const buffer = Buffer.from(row.image_buffer, 'hex')
|
||||
base64Data = buffer.toString('base64')
|
||||
} else {
|
||||
base64Data = row.image_buffer
|
||||
}
|
||||
} else if (Buffer.isBuffer(row.image_buffer)) {
|
||||
base64Data = row.image_buffer.toString('base64')
|
||||
} else if (Array.isArray(row.image_buffer)) {
|
||||
base64Data = Buffer.from(row.image_buffer).toString('base64')
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
result[username] = `data:image/jpeg;base64,${base64Data}`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 静默处理单个用户的错误
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('从 head_image.db 获取头像失败:', e)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 补充联系人信息(私有方法,保持向后兼容)
|
||||
*/
|
||||
@@ -396,7 +500,10 @@ class ChatService {
|
||||
async getMessages(
|
||||
sessionId: string,
|
||||
offset: number = 0,
|
||||
limit: number = 50
|
||||
limit: number = 50,
|
||||
startTime: number = 0,
|
||||
endTime: number = 0,
|
||||
ascending: boolean = false
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
@@ -411,7 +518,14 @@ class ChatService {
|
||||
// 1. 没有游标状态
|
||||
// 2. offset 为 0 (重新加载会话)
|
||||
// 3. batchSize 改变
|
||||
const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize
|
||||
// 4. startTime 改变
|
||||
// 5. ascending 改变
|
||||
const needNewCursor = !state ||
|
||||
offset === 0 ||
|
||||
state.batchSize !== batchSize ||
|
||||
state.startTime !== startTime ||
|
||||
state.endTime !== endTime ||
|
||||
state.ascending !== ascending
|
||||
|
||||
if (needNewCursor) {
|
||||
// 关闭旧游标
|
||||
@@ -424,13 +538,16 @@ class ChatService {
|
||||
}
|
||||
|
||||
// 创建新游标
|
||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0)
|
||||
// 注意:WeFlow 数据库中的 create_time 是以秒为单位的
|
||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
console.error('[ChatService] 打开消息游标失败:', cursorResult.error)
|
||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||
}
|
||||
|
||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize }
|
||||
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
|
||||
this.messageCursors.set(sessionId, state)
|
||||
|
||||
// 如果需要跳过消息(offset > 0),逐批获取但不返回
|
||||
@@ -1706,7 +1823,9 @@ class ChatService {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) return null
|
||||
const cached = this.avatarCache.get(username)
|
||||
if (cached && cached.avatarUrl && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||
// 检查缓存是否有效,且头像不是错误的 hex 格式
|
||||
const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8')
|
||||
if (cached && isValidAvatar && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||
return { avatarUrl: cached.avatarUrl, displayName: cached.displayName }
|
||||
}
|
||||
|
||||
@@ -2979,10 +3098,26 @@ class ChatService {
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const normalized = dbPath.replace(/[\\\\/]+$/, '')
|
||||
|
||||
// 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件)
|
||||
// 则向上回溯到账号目录
|
||||
if (basename(normalized).toLowerCase() === 'db_storage') {
|
||||
return dirname(normalized)
|
||||
}
|
||||
const dir = dirname(normalized)
|
||||
if (basename(normalized).toLowerCase() === 'db_storage') return dir
|
||||
if (basename(dir).toLowerCase() === 'db_storage') return dirname(dir)
|
||||
return dir // 兜底
|
||||
if (basename(dir).toLowerCase() === 'db_storage') {
|
||||
return dirname(dir)
|
||||
}
|
||||
|
||||
// 否则,dbPath 应该是数据库根目录(如 xwechat_files)
|
||||
// 账号目录应该是 {dbPath}/{wxid}
|
||||
const accountDirWithWxid = join(normalized, wxid)
|
||||
if (existsSync(accountDirWithWxid)) {
|
||||
return accountDirWithWxid
|
||||
}
|
||||
|
||||
// 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录)
|
||||
return normalized
|
||||
}
|
||||
|
||||
private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise<string | null> {
|
||||
|
||||
@@ -34,6 +34,14 @@ export class ContactCacheService {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
// 清除无效的头像数据(hex 格式而非正确的 base64)
|
||||
for (const key of Object.keys(parsed)) {
|
||||
const entry = parsed[key]
|
||||
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
|
||||
// 这是错误的 hex 格式,清除它
|
||||
entry.avatarUrl = undefined
|
||||
}
|
||||
}
|
||||
this.cache = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -84,7 +84,32 @@ export interface ExportProgress {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
|
||||
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
||||
}
|
||||
|
||||
// 并发控制:限制同时执行的 Promise 数量
|
||||
async function parallelLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let currentIndex = 0
|
||||
|
||||
async function runNext(): Promise<void> {
|
||||
while (currentIndex < items.length) {
|
||||
const index = currentIndex++
|
||||
results[index] = await fn(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动 limit 个并发任务
|
||||
const workers = Array(Math.min(limit, items.length))
|
||||
.fill(null)
|
||||
.map(() => runNext())
|
||||
|
||||
await Promise.all(workers)
|
||||
return results
|
||||
}
|
||||
|
||||
class ExportService {
|
||||
@@ -1122,7 +1147,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单个会话为 ChatLab 格式
|
||||
* 导出单个会话为 ChatLab 格式(并行优化版本)
|
||||
*/
|
||||
async exportSessionToChatLab(
|
||||
sessionId: string,
|
||||
@@ -1154,51 +1179,101 @@ class ExportService {
|
||||
|
||||
allMessages.sort((a, b) => a.createTime - b.createTime)
|
||||
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
|
||||
// ========== 阶段1:并行导出媒体文件 ==========
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? allMessages.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) || // 图片
|
||||
(t === 47 && options.exportEmojis) || // 表情
|
||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字)
|
||||
})
|
||||
: []
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
onProgress?.({
|
||||
current: 20,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-media'
|
||||
})
|
||||
|
||||
// 并行导出媒体,限制 8 个并发
|
||||
const MEDIA_CONCURRENCY = 8
|
||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 阶段2:并行语音转文字 ==========
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? allMessages.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
onProgress?.({
|
||||
current: 40,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-voice'
|
||||
})
|
||||
|
||||
// 并行转写语音,限制 4 个并发(转写比较耗资源)
|
||||
const VOICE_CONCURRENCY = 4
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 阶段3:构建消息列表 ==========
|
||||
onProgress?.({
|
||||
current: 50,
|
||||
current: 60,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
})
|
||||
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const chatLabMessages: ChatLabMessage[] = []
|
||||
for (const msg of allMessages) {
|
||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||
platformId: msg.senderUsername,
|
||||
accountName: msg.senderUsername,
|
||||
groupNickname: undefined
|
||||
}
|
||||
|
||||
let content = this.parseMessageContent(msg.content, msg.localType)
|
||||
if (exportMediaEnabled) {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
}
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
// 如果是语音消息且开启了转文字
|
||||
content = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
}
|
||||
|
||||
chatLabMessages.push({
|
||||
sender: msg.senderUsername,
|
||||
accountName: memberInfo.accountName,
|
||||
groupNickname: memberInfo.groupNickname,
|
||||
timestamp: msg.createTime,
|
||||
type: this.convertMessageType(msg.localType, msg.content),
|
||||
content: content
|
||||
})
|
||||
const chatLabMessages: ChatLabMessage[] = allMessages.map(msg => {
|
||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||
platformId: msg.senderUsername,
|
||||
accountName: msg.senderUsername,
|
||||
groupNickname: undefined
|
||||
}
|
||||
|
||||
// 确定消息内容
|
||||
let content: string | null
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
// 使用预先转写的文字
|
||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
} else {
|
||||
content = this.parseMessageContent(msg.content, msg.localType)
|
||||
}
|
||||
|
||||
return {
|
||||
sender: msg.senderUsername,
|
||||
accountName: memberInfo.accountName,
|
||||
groupNickname: memberInfo.groupNickname,
|
||||
timestamp: msg.createTime,
|
||||
type: this.convertMessageType(msg.localType, msg.content),
|
||||
content: content
|
||||
}
|
||||
})
|
||||
|
||||
const avatarMap = options.exportAvatars
|
||||
? await this.exportAvatars(
|
||||
[
|
||||
@@ -1265,7 +1340,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单个会话为详细 JSON 格式(原项目格式)
|
||||
* 导出单个会话为详细 JSON 格式(原项目格式)- 并行优化版本
|
||||
*/
|
||||
async exportSessionToDetailedJson(
|
||||
sessionId: string,
|
||||
@@ -1290,41 +1365,95 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const allMessages: any[] = []
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
|
||||
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] : ''
|
||||
// ========== 阶段1:并行导出媒体文件 ==========
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? collected.rows.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
||||
})
|
||||
: []
|
||||
|
||||
let content = this.parseMessageContent(msg.content, msg.localType)
|
||||
let mediaItem: MediaExportItem | null = null
|
||||
if (exportMediaEnabled) {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (mediaCache.has(mediaKey)) {
|
||||
mediaItem = mediaCache.get(mediaKey) || null
|
||||
} else {
|
||||
mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
}
|
||||
if (mediaItem) {
|
||||
content = mediaItem.relativePath
|
||||
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
content = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
|
||||
if (mediaMessages.length > 0) {
|
||||
onProgress?.({
|
||||
current: 15,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-media'
|
||||
})
|
||||
|
||||
const MEDIA_CONCURRENCY = 8
|
||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
allMessages.push({
|
||||
localId: allMessages.length + 1,
|
||||
createTime: msg.createTime,
|
||||
// ========== 阶段2:并行语音转文字 ==========
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? collected.rows.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
onProgress?.({
|
||||
current: 35,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-voice'
|
||||
})
|
||||
|
||||
const VOICE_CONCURRENCY = 4
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 阶段3:构建消息列表 ==========
|
||||
onProgress?.({
|
||||
current: 55,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
})
|
||||
|
||||
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] : ''
|
||||
|
||||
let content: string | null
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaItem = mediaCache.get(mediaKey)
|
||||
|
||||
if (mediaItem) {
|
||||
content = mediaItem.relativePath
|
||||
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
} else {
|
||||
content = this.parseMessageContent(msg.content, msg.localType)
|
||||
}
|
||||
|
||||
allMessages.push({
|
||||
localId: allMessages.length + 1,
|
||||
createTime: msg.createTime,
|
||||
formattedTime: this.formatTimestamp(msg.createTime),
|
||||
type: this.getMessageTypeName(msg.localType),
|
||||
localType: msg.localType,
|
||||
@@ -1550,20 +1679,31 @@ class ExportService {
|
||||
// 媒体导出设置
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
|
||||
// 媒体导出缓存
|
||||
// ========== 并行预处理:媒体文件 ==========
|
||||
const mediaMessages = exportMediaEnabled
|
||||
? sortedMessages.filter(msg => {
|
||||
const t = msg.localType
|
||||
return (t === 3 && options.exportImages) ||
|
||||
(t === 47 && options.exportEmojis) ||
|
||||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
|
||||
})
|
||||
: []
|
||||
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
|
||||
for (let i = 0; i < sortedMessages.length; i++) {
|
||||
const msg = sortedMessages[i]
|
||||
if (mediaMessages.length > 0) {
|
||||
onProgress?.({
|
||||
current: 35,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-media'
|
||||
})
|
||||
|
||||
// 导出媒体文件
|
||||
let mediaItem: MediaExportItem | null = null
|
||||
if (exportMediaEnabled) {
|
||||
const MEDIA_CONCURRENCY = 8
|
||||
await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (mediaCache.has(mediaKey)) {
|
||||
mediaItem = mediaCache.get(mediaKey) || null
|
||||
} else {
|
||||
mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
@@ -1571,7 +1711,45 @@ class ExportService {
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 并行预处理:语音转文字 ==========
|
||||
const voiceMessages = options.exportVoiceAsText
|
||||
? sortedMessages.filter(msg => msg.localType === 34)
|
||||
: []
|
||||
|
||||
const voiceTranscriptMap = new Map<number, string>()
|
||||
|
||||
if (voiceMessages.length > 0) {
|
||||
onProgress?.({
|
||||
current: 50,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting-voice'
|
||||
})
|
||||
|
||||
const VOICE_CONCURRENCY = 4
|
||||
await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => {
|
||||
const transcript = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
voiceTranscriptMap.set(msg.localId, transcript)
|
||||
})
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 65,
|
||||
total: 100,
|
||||
currentSession: sessionInfo.displayName,
|
||||
phase: 'exporting'
|
||||
})
|
||||
|
||||
// ========== 写入 Excel 行 ==========
|
||||
for (let i = 0; i < sortedMessages.length; i++) {
|
||||
const msg = sortedMessages[i]
|
||||
|
||||
// 从缓存获取媒体信息
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
const mediaItem = mediaCache.get(mediaKey) || null
|
||||
|
||||
// 确定发送者信息
|
||||
let senderRole: string
|
||||
@@ -1620,12 +1798,15 @@ class ExportService {
|
||||
const row = worksheet.getRow(currentRow)
|
||||
row.height = 24
|
||||
|
||||
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容
|
||||
let contentValue = mediaItem
|
||||
? mediaItem.relativePath
|
||||
: (this.parseMessageContent(msg.content, msg.localType) || '')
|
||||
if (!mediaItem && msg.localType === 34 && options.exportVoiceAsText) {
|
||||
contentValue = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
// 确定内容:优先使用预处理的缓存
|
||||
let contentValue: string
|
||||
if (mediaItem) {
|
||||
contentValue = mediaItem.relativePath
|
||||
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
// 使用预处理的语音转文字结果
|
||||
contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||
} else {
|
||||
contentValue = this.parseMessageContent(msg.content, msg.localType) || ''
|
||||
}
|
||||
|
||||
// 调试日志
|
||||
|
||||
@@ -629,6 +629,11 @@ export class KeyService {
|
||||
if (!ok) {
|
||||
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
|
||||
if (error) {
|
||||
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
|
||||
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
|
||||
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
|
||||
return { success: false, error: friendlyError }
|
||||
}
|
||||
return { success: false, error }
|
||||
}
|
||||
const statusBuffer = Buffer.alloc(256)
|
||||
|
||||
64
electron/services/snsService.ts
Normal file
64
electron/services/snsService.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { ContactCacheService } from './contactCacheService'
|
||||
|
||||
export interface SnsPost {
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatarUrl?: string
|
||||
createTime: number
|
||||
contentDesc: string
|
||||
type?: number
|
||||
media: { url: string; thumb: string }[]
|
||||
likes: string[]
|
||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||
}
|
||||
|
||||
class SnsService {
|
||||
private contactCache: ContactCacheService
|
||||
|
||||
constructor() {
|
||||
const config = new ConfigService()
|
||||
this.contactCache = new ContactCacheService(config.get('cachePath') as string)
|
||||
}
|
||||
|
||||
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
|
||||
console.log('[SnsService] getTimeline called with:', { limit, offset, usernames, keyword, startTime, endTime })
|
||||
|
||||
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
|
||||
console.log('[SnsService] getSnsTimeline result:', {
|
||||
success: result.success,
|
||||
timelineCount: result.timeline?.length,
|
||||
error: result.error
|
||||
})
|
||||
|
||||
if (result.success && result.timeline) {
|
||||
const enrichedTimeline = result.timeline.map((post: any) => {
|
||||
const contact = this.contactCache.get(post.username)
|
||||
|
||||
// 修复媒体 URL,如果是 http 则尝试用 https (虽然 qpic 可能不支持强制 https,但通常支持)
|
||||
const fixedMedia = post.media.map((m: any) => ({
|
||||
url: m.url.replace('http://', 'https://'),
|
||||
thumb: m.thumb.replace('http://', 'https://')
|
||||
}))
|
||||
|
||||
return {
|
||||
...post,
|
||||
avatarUrl: contact?.avatarUrl,
|
||||
nickname: post.nickname || contact?.displayName || post.username,
|
||||
media: fixedMedia
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[SnsService] Returning enriched timeline with', enrichedTimeline.length, 'posts')
|
||||
return { ...result, timeline: enrichedTimeline }
|
||||
}
|
||||
|
||||
console.log('[SnsService] Returning result:', result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const snsService = new SnsService()
|
||||
@@ -55,6 +55,7 @@ export class WcdbCore {
|
||||
private wcdbGetEmoticonCdnUrl: any = null
|
||||
private wcdbGetDbStatus: any = null
|
||||
private wcdbGetVoiceData: any = null
|
||||
private wcdbGetSnsTimeline: any = null
|
||||
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
private logTimer: NodeJS.Timeout | null = null
|
||||
@@ -116,7 +117,8 @@ export class WcdbCore {
|
||||
private writeLog(message: string, force = false): void {
|
||||
if (!force && !this.isLogEnabled()) return
|
||||
const line = `[${new Date().toISOString()}] ${message}`
|
||||
// 移除控制台日志,只写入文件
|
||||
// 同时输出到控制台和文件
|
||||
console.log('[WCDB]', message)
|
||||
try {
|
||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||
const dir = join(base, 'logs')
|
||||
@@ -385,6 +387,13 @@ export class WcdbCore {
|
||||
this.wcdbGetVoiceData = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
|
||||
try {
|
||||
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetSnsTimeline = null
|
||||
}
|
||||
|
||||
// 初始化
|
||||
const initResult = this.wcdbInit()
|
||||
if (initResult !== 0) {
|
||||
@@ -401,8 +410,8 @@ export class WcdbCore {
|
||||
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
|
||||
lastDllInitError = errorMsg
|
||||
// 检查是否是常见的 VC++ 运行时缺失错误
|
||||
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
|
||||
errorMsg.includes('The specified module could not be found')) {
|
||||
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
|
||||
errorMsg.includes('The specified module could not be found')) {
|
||||
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
|
||||
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
|
||||
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
|
||||
@@ -1388,4 +1397,32 @@ export class WcdbCore {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
|
||||
const result = this.wcdbGetSnsTimeline(
|
||||
this.handle,
|
||||
limit,
|
||||
offset,
|
||||
usernamesJson,
|
||||
keyword || '',
|
||||
startTime || 0,
|
||||
endTime || 0,
|
||||
outPtr
|
||||
)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `获取朋友圈失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' }
|
||||
const timeline = JSON.parse(jsonStr)
|
||||
return { success: true, timeline }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +362,13 @@ export class WcdbService {
|
||||
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取朋友圈
|
||||
*/
|
||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const wcdbService = new WcdbService()
|
||||
|
||||
@@ -116,6 +116,9 @@ if (parentPort) {
|
||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||
}
|
||||
break
|
||||
case 'getSnsTimeline':
|
||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user