diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index 2f6453a..82e957a 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -1113,9 +1113,16 @@ const renderExportScript = (name: string): string => ` return '' } + const playableAudioPattern = /\\.(?:mp3|wav|m4a|aac|ogg|oga|opus|flac|webm)(?:$|[?#])/i + const playableAudioFile = (name, url) => + playableAudioPattern.test(String(name || '')) || playableAudioPattern.test(String(url || '')) + const renderAudioPlayer = (source) => + '
' + const renderMessage = (message, archiveIndex) => { const data = message.contentData || {} - const mediaUrl = message.exportMediaUrl ? esc(message.exportMediaUrl) : '' + const rawMediaUrl = message.exportMediaUrl ? String(message.exportMediaUrl) : '' + const mediaUrl = rawMediaUrl ? esc(rawMediaUrl) : '' const mediaType = message.exportMediaType || data.type let media = '' if (mediaUrl && mediaType === 'image') { @@ -1125,11 +1132,13 @@ const renderExportScript = (name: string): string => ` } else if (mediaUrl && mediaType === 'sticker') { media = '表情包' } else if (mediaUrl && mediaType === 'file') { - const fileName = esc(message.exportMediaName || data.title || '下载文件') - media = '📎' + fileName + '' + const rawFileName = message.exportMediaName || data.title || '打开文件' + const fileName = esc(rawFileName) + media = '📎' + fileName + '' + + (playableAudioFile(rawFileName, rawMediaUrl) ? renderAudioPlayer(mediaUrl) : '') } const audio = message.voiceDataUrl - ? '
' + ? renderAudioPlayer(esc(message.voiceDataUrl)) : '' const voiceTranscript = message.voiceTranscript ? '
' + esc(message.voiceTranscript) + '
' diff --git a/src/main/export-service.ts b/src/main/export-service.ts index bd5e88f..15eaa8a 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -98,8 +98,10 @@ const fileHashPart = async (filePath: string, length = 16): Promise => { }) return hash.digest('hex').slice(0, length) } -const avatarFileName = (buffer: Buffer, extension: string): string => - `avatar_${bufferHashPart(buffer)}.${extension}` +const avatarFileName = (source: string, buffer: Buffer, extension: string): string => + `avatar_${/^https?:\/\//i.test(source) ? hashPart(source) : bufferHashPart(buffer)}.${extension}` +const avatarVersionFileName = (source: string, buffer: Buffer, extension: string): string => + `avatar_${hashPart(source)}_${bufferHashPart(buffer)}.${extension}` const avatarSourceHash = (source: string): string => hashPart(source, 24) const avatarIdentityKey = (conversationId: string, message: Message): string => `${conversationId}:${ @@ -690,6 +692,13 @@ export async function runExport( if (/^wxid_/i.test(name)) return false return true } + send({ + jobId: request.jobId, + phase: 'parsing', + processed: 0, + total: messages.length, + percent: 12 + }) for (const message of messages) { const target = targetById.get(message.exportConversationId || '') const peerUsername = target ? client?.getUsernameByMd5(target.userMd5) || '' : '' @@ -729,17 +738,23 @@ export async function runExport( message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件' } } - send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 }) + send({ + jobId: request.jobId, + phase: 'parsing', + processed: messages.length, + total: messages.length, + percent: 15 + }) if (!jobs.has(request.jobId)) { send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 }) return { success: false, error: '已取消' } } send({ jobId: request.jobId, - phase: 'writing', + phase: request.format === 'html' ? 'parsing' : 'writing', processed: 0, total: messages.length, - percent: 15 + percent: request.format === 'html' ? 18 : 20 }) const root = join(app.getPath('documents'), 'WechatExplorer', '导出') await fs.mkdir(root, { recursive: true }) @@ -846,8 +861,13 @@ export async function runExport( } } - const avatarName = avatarFileName(resolved.buffer, resolved.extension || 'jpg') - const avatarUrl = `avatars/${avatarName}` + const extension = resolved.extension || 'jpg' + let avatarName = avatarFileName(source, resolved.buffer, extension) + let avatarUrl = `avatars/${avatarName}` + if (avatarUrl === previousAvatarUrl) { + avatarName = avatarVersionFileName(source, resolved.buffer, extension) + avatarUrl = `avatars/${avatarName}` + } if (!(await resourceExists(avatarUrl))) { await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer) markResourceExists(avatarUrl) @@ -876,54 +896,76 @@ export async function runExport( request.includeMedia && chat.getChatDb() ? new VoiceService(chat.getChatDb()!.getWcdb4Client()) : null + const voiceMessages = messages.filter((message) => kindOf(message) === 'voice') + const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media' + const voiceProgressEnd = request.includeVoiceTranscripts ? 50 : 35 if (voiceService) { - for (const message of messages) { + send({ + jobId: request.jobId, + phase: voicePhase, + processed: 0, + total: voiceMessages.length, + percent: 20 + }) + for (const [voiceIndex, message] of voiceMessages.entries()) { if (!jobs.has(request.jobId)) throw new Error('已取消') - if (kindOf(message) !== 'voice') continue const previous = reusablePreviousMessages.get(message) + let canTranscribe = true if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) { message.voiceDataUrl = previous.voiceDataUrl message.voiceDuration = previous.voiceDuration - continue - } - if (!message.sessionId || message.localId == null || !message.createTime) { + if (request.includeVoiceTranscripts && previous.voiceTranscript) { + message.voiceTranscript = previous.voiceTranscript + } + } else if (!message.sessionId || message.localId == null || !message.createTime) { keepMediaError(request, message, '语音标识不完整,无法定位本地语音') - continue - } - try { - const voice = await voiceService.resolveVoice( - message.sessionId, - message.localId, - message.createTime, - message.serverId - ) - if (!voice.success || !voice.data) { - const detail = voice.error || '未知原因' - const reason = /未找到|不存在|获取语音数据失败/.test(detail) - ? `语音文件缺失:${detail}` - : /Silk|解码|数据为空/.test(detail) - ? `语音解析失败:${detail}` - : `语音格式不支持或读取失败:${detail}` - keepMediaError(request, message, reason) - continue - } - const audioBuffer = Buffer.from(voice.data, 'base64') - const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav` - const voiceUrl = `voices/${voiceName}` - if (!(await resourceExists(voiceUrl))) { - await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) - markResourceExists(voiceUrl) - } - message.voiceDataUrl = voiceUrl - message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) - if (request.includeVoiceTranscripts) { - if (!voiceRecognition) { - message.voiceTranscriptError = '语音转文字服务不可用' + canTranscribe = false + } else { + try { + const voice = await voiceService.resolveVoice( + message.sessionId, + message.localId, + message.createTime, + message.serverId + ) + if (!voice.success || !voice.data) { + const detail = voice.error || '未知原因' + const reason = /未找到|不存在|获取语音数据失败/.test(detail) + ? `语音文件缺失:${detail}` + : /Silk|解码|数据为空/.test(detail) + ? `语音解析失败:${detail}` + : `语音格式不支持或读取失败:${detail}` + keepMediaError(request, message, reason) + canTranscribe = false } else { + const audioBuffer = Buffer.from(voice.data, 'base64') + const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav` + const voiceUrl = `voices/${voiceName}` + if (!(await resourceExists(voiceUrl))) { + await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer) + markResourceExists(voiceUrl) + } + message.voiceDataUrl = voiceUrl + message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2))) + } + } catch (error) { + keepMediaError( + request, + message, + `语音文件写入失败:${error instanceof Error ? error.message : String(error)}` + ) + canTranscribe = false + } + } + if (request.includeVoiceTranscripts && canTranscribe && !message.voiceTranscript) { + if (!voiceRecognition) { + message.voiceTranscriptError = '语音转文字服务不可用' + } else { + try { const recognition = await voiceRecognition.recognize({ - sessionId: message.sessionId, - localId: message.localId, - createTime: message.createTime, + sessionId: message.sessionId!, + localId: message.localId!, + createTime: message.createTime!, svrId: message.serverId }) if (recognition.success) { @@ -931,15 +973,23 @@ export async function runExport( } else { message.voiceTranscriptError = recognition.error || '语音识别失败' } + } catch (error) { + message.voiceTranscriptError = + error instanceof Error ? error.message : '语音识别失败' } } - } catch (error) { - keepMediaError( - request, - message, - `语音文件写入失败:${error instanceof Error ? error.message : String(error)}` - ) } + send({ + jobId: request.jobId, + phase: voicePhase, + processed: voiceIndex + 1, + total: voiceMessages.length, + percent: + 20 + + Math.round( + ((voiceIndex + 1) / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20) + ) + }) } } else if (request.includeMedia) { for (const message of messages) { @@ -948,6 +998,17 @@ export async function runExport( } } } + const mediaStartPercent = voiceService ? voiceProgressEnd : 20 + const mediaPercent = (processed: number): number => + mediaStartPercent + + Math.round((processed / Math.max(messages.length, 1)) * (90 - mediaStartPercent)) + send({ + jobId: request.jobId, + phase: 'media', + processed: 0, + total: messages.length, + percent: mediaStartPercent + }) for (const [index, message] of messages.entries()) { if (!jobs.has(request.jobId)) { send({ @@ -955,7 +1016,7 @@ export async function runExport( phase: 'cancelled', processed: index, total: messages.length, - percent: 15 + Math.round((index / Math.max(messages.length, 1)) * 75) + percent: mediaPercent(index) }) return { success: false, error: '已取消' } } @@ -987,10 +1048,10 @@ export async function runExport( if (!request.includeMedia || !message.contentData) { send({ jobId: request.jobId, - phase: 'writing', + phase: 'media', processed: index + 1, total: messages.length, - percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) + percent: mediaPercent(index + 1) }) continue } @@ -1013,10 +1074,10 @@ export async function runExport( message.exportMediaName = previous.exportMediaName send({ jobId: request.jobId, - phase: 'writing', + phase: 'media', processed: index + 1, total: messages.length, - percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) + percent: mediaPercent(index + 1) }) continue } @@ -1158,10 +1219,10 @@ export async function runExport( } send({ jobId: request.jobId, - phase: 'writing', + phase: 'media', processed: index + 1, total: messages.length, - percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) + percent: mediaPercent(index + 1) }) } const mergedMessages = mergeHtmlArchiveMessages( @@ -1192,6 +1253,13 @@ export async function runExport( avatarVersions } if (!jobs.has(request.jobId)) throw new Error('已取消') + send({ + jobId: request.jobId, + phase: 'writing', + processed: archive.messages.length, + total: archive.messages.length, + percent: 92 + }) await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8') await writeHtmlArchive(outputDir, archive) await pruneHtmlArchiveResources(outputDir, archive) @@ -1204,7 +1272,7 @@ export async function runExport( phase: 'compressing', processed: archive.messages.length, total: archive.messages.length, - percent: 95 + percent: 96 }) await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId) completedPath = zipPath diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 4309492..fde7bcf 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -482,7 +482,7 @@ function parseShareMessage(content: string): ParsedContent { } } - const title = extractXmlValue(content, 'title') || '' + const title = decodeXmlEntities(extractXmlValue(content, 'title')) || '' const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || '' const url = extractXmlValue(content, 'url') || '' const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || '' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eda5299..90a8f12 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -387,6 +387,8 @@ function App(): React.ReactElement { targetNames, targetLabel, format: request.format, + includeVoiceTranscripts: request.includeVoiceTranscripts, + zip: request.zip, status: 'running', progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 }, createdAt: Date.now() diff --git a/src/renderer/src/components/export/ExportPreviewPanel.tsx b/src/renderer/src/components/export/ExportPreviewPanel.tsx index 396146a..a48beac 100644 --- a/src/renderer/src/components/export/ExportPreviewPanel.tsx +++ b/src/renderer/src/components/export/ExportPreviewPanel.tsx @@ -10,6 +10,8 @@ interface ExportPreviewPanelProps { previewBytes: number selfInfo: SelfInfo | null progress: ExportJobProgress | null + includeVoiceTranscripts: boolean + zip: boolean selectedCount: number jobId: string onCancel: (jobId: string) => void @@ -23,11 +25,43 @@ export function ExportPreviewPanel({ previewBytes, selfInfo, progress, + includeVoiceTranscripts, + zip, selectedCount, jobId, onCancel, onReveal }: ExportPreviewPanelProps): React.ReactElement { + const percent = Math.max(0, Math.min(100, progress?.percent ?? 0)) + const phase = progress?.phase || 'reading' + const showTranscriptStep = includeVoiceTranscripts || phase === 'transcribing' + const showZipStep = zip || phase === 'compressing' + const steps = [ + { phase: 'reading', label: '分批读取聊天记录' }, + { phase: 'parsing', label: '解析消息内容' }, + ...(showTranscriptStep ? [{ phase: 'transcribing', label: '语音转文字' }] : []), + { phase: 'media', label: '处理媒体资源' }, + { phase: 'writing', label: '生成档案' }, + ...(showZipStep ? [{ phase: 'compressing', label: '压缩 ZIP' }] : []) + ] + const currentStepIndex = Math.max( + 0, + steps.findIndex((step) => step.phase === phase) + ) + const indeterminate = phase === 'reading' && percent === 0 + const progressText = + phase === 'compressing' + ? `正在压缩资源包... ${percent}%` + : phase === 'writing' + ? `正在生成档案... ${percent}%` + : phase === 'transcribing' + ? `正在转写语音 ${progress?.processed ?? 0}/${progress?.total ?? 0}... ${percent}%` + : phase === 'media' + ? `正在处理媒体资源 ${progress?.processed ?? 0}/${progress?.total ?? 0}... ${percent}%` + : phase === 'parsing' + ? `正在解析消息内容... ${percent}%` + : `正在读取消息... ${percent}%` + return (