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 (