From fd6d5e4296357050367ffb6f528a7e6fc8c62f7f Mon Sep 17 00:00:00 2001 From: QingXiao <143726276+5xiao0qing5@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:34:40 +0800 Subject: [PATCH] Implement HTML chat export --- electron/services/exportService.ts | 607 ++++++++++++++++++++++++++++- src/pages/ExportPage.tsx | 21 +- 2 files changed, 617 insertions(+), 11 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 875c961..7840521 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -8,6 +8,7 @@ import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' +import { videoService } from './videoService' // ChatLab 格式类型定义 interface ChatLabHeader { @@ -89,7 +90,8 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ interface MediaExportItem { relativePath: string - kind: 'image' | 'voice' | 'emoji' + kind: 'image' | 'voice' | 'emoji' | 'video' + posterDataUrl?: string } export interface ExportProgress { @@ -451,6 +453,28 @@ class ExportService { return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim() } + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + private escapeAttribute(value: string): string { + return this.escapeHtml(value).replace(/`/g, '`') + } + + private getAvatarFallback(name: string): string { + if (!name) return '?' + return [...name][0] || '?' + } + + private renderMultilineText(value: string): string { + return this.escapeHtml(value).replace(/\r?\n/g, '
') + } + /** * 导出媒体文件到指定目录 */ @@ -459,7 +483,14 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } + options: { + exportImages?: boolean + exportVoices?: boolean + exportEmojis?: boolean + exportVoiceAsText?: boolean + includeVoiceWithTranscript?: boolean + exportVideos?: boolean + } ): Promise { const localType = msg.localType @@ -473,14 +504,13 @@ class ExportService { // 语音消息 if (localType === 34) { - // 如果开启了语音转文字,优先转文字(不导出语音文件) - if (options.exportVoiceAsText) { - return null // 转文字逻辑在消息内容处理中完成 - } - // 否则导出语音文件 - if (options.exportVoices) { + const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText + if (shouldKeepVoiceFile && options.exportVoices) { return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) } + if (options.exportVoiceAsText) { + return null + } } // 动画表情 @@ -491,6 +521,10 @@ class ExportService { return result } + if (localType === 43 && options.exportVideos) { + return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix) + } + return null } @@ -701,6 +735,47 @@ class ExportService { } } + /** + * 导出视频文件 + */ + private async exportVideo( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { + try { + const videoMd5 = msg.videoMd5 + if (!videoMd5) return null + + const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') + if (!fs.existsSync(videosDir)) { + fs.mkdirSync(videosDir, { recursive: true }) + } + + const videoInfo = await videoService.getVideoInfo(videoMd5) + if (!videoInfo.exists || !videoInfo.videoUrl) { + return null + } + + const sourcePath = videoInfo.videoUrl + const fileName = path.basename(sourcePath) + const destPath = path.join(videosDir, fileName) + + if (!fs.existsSync(destPath)) { + fs.copyFileSync(sourcePath, destPath) + } + + return { + relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), + kind: 'video', + posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl + } + } catch (e) { + return null + } + } + /** * 从消息内容提取图片 MD5 */ @@ -759,6 +834,16 @@ class ExportService { return match?.[1] } + private extractVideoMd5(content: string): string | undefined { + if (!content) return undefined + const attrMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (attrMatch) { + return attrMatch[1].toLowerCase() + } + const tagMatch = /([^<]+)<\/md5>/i.exec(content) + return tagMatch?.[1]?.toLowerCase() + } + /** * 从 data URL 获取扩展名 */ @@ -881,6 +966,7 @@ class ExportService { let imageDatName: string | undefined let emojiCdnUrl: string | undefined let emojiMd5: string | undefined + let videoMd5: string | undefined if (localType === 3 && content) { // 图片消息 @@ -890,6 +976,9 @@ class ExportService { // 动画表情 emojiCdnUrl = this.extractEmojiUrl(content) emojiMd5 = this.extractEmojiMd5(content) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = this.extractVideoMd5(content) } rows.push({ @@ -902,7 +991,8 @@ class ExportService { imageMd5, imageDatName, emojiCdnUrl, - emojiMd5 + emojiMd5, + videoMd5 }) if (firstTime === null || createTime < firstTime) firstTime = createTime @@ -2094,6 +2184,502 @@ class ExportService { } } + /** + * 导出单个会话为 HTML 格式 + */ + async exportSessionToHtml( + 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) + if (isGroup) { + await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) + } + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + + 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) || + t === 43 + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) + + const MEDIA_CONCURRENCY = 6 + 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, + includeVoiceWithTranscript: true, + exportVideos: true + }) + mediaCache.set(mediaKey, mediaItem) + } + }) + } + + const useVoiceTranscript = options.exportVoiceAsText !== false + const voiceMessages = useVoiceTranscript + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 40, + 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) + }) + } + + const avatarMap = options.exportAvatars + ? await this.exportAvatars( + [ + ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ + username, + avatarUrl: info.avatarUrl + })), + { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, + { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } + ] + ) + : new Map() + + const renderedMessages = sortedMessages.map((msg, index) => { + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) || null + + const isSenderMe = msg.isSend + const senderInfo = collected.memberSet.get(msg.senderUsername)?.member + const senderName = isSenderMe + ? (myInfo.displayName || '我') + : (isGroup + ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) + : (sessionInfo.displayName || sessionId)) + const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) + const avatarHtml = avatarData + ? `${this.escapeAttribute(senderName)}` + : `${this.escapeHtml(this.getAvatarFallback(senderName))}` + + const timeText = this.formatTimestamp(msg.createTime) + const typeName = this.getMessageTypeName(msg.localType) + + let textContent = this.parseMessageContent(msg.content, msg.localType) || '' + if (msg.localType === 34 && useVoiceTranscript) { + textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } + + let mediaHtml = '' + if (mediaItem?.kind === 'image') { + mediaHtml = `${this.escapeAttribute(typeName)}` + } else if (mediaItem?.kind === 'emoji') { + mediaHtml = `${this.escapeAttribute(typeName)}` + } else if (mediaItem?.kind === 'voice') { + mediaHtml = `` + } else if (mediaItem?.kind === 'video') { + const posterAttr = mediaItem.posterDataUrl ? ` poster="${this.escapeAttribute(mediaItem.posterDataUrl)}"` : '' + mediaHtml = `` + } + + const textHtml = textContent + ? `
${this.renderMultilineText(textContent)}
` + : '' + const senderHtml = isGroup + ? `
${this.escapeHtml(senderName)}
` + : '' + const messageBody = ` + ${senderHtml} +
+ ${mediaHtml} + ${textHtml} +
+ ` + + return ` +
+
${this.escapeHtml(timeText)}
+
+
${avatarHtml}
+
+ ${messageBody} +
+
+
+ ` + }).join('\n') + + onProgress?.({ + current: 85, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup) + const html = ` + + + + + ${this.escapeHtml(sessionInfo.displayName)} - 聊天记录 + + + +
+
+

${this.escapeHtml(sessionInfo.displayName)} 的聊天记录

+
+ 导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))} + 消息数量:${sortedMessages.length} + 会话类型:${isGroup ? '群聊' : '私聊'} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ 共 ${sortedMessages.length} 条 +
+
+
+
+ ${renderedMessages || '
暂无消息
'} +
+
+ + +` + + fs.writeFileSync(outputPath, html, 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 批量导出多个会话 */ @@ -2145,6 +2731,7 @@ class ExportService { if (options.format === 'chatlab-jsonl') ext = '.jsonl' else if (options.format === 'excel') ext = '.xlsx' else if (options.format === 'txt') ext = '.txt' + else if (options.format === 'html') ext = '.html' const outputPath = path.join(sessionDir, `${safeName}${ext}`) let result: { success: boolean; error?: string } @@ -2156,6 +2743,8 @@ class ExportService { result = await this.exportSessionToExcel(sessionId, outputPath, options) } else if (options.format === 'txt') { result = await this.exportSessionToTxt(sessionId, outputPath, options) + } else if (options.format === 'html') { + result = await this.exportSessionToHtml(sessionId, outputPath, options) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 87f3bfa..088c0ef 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -216,6 +216,23 @@ function ExportPage() { return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) } + const handleFormatChange = (format: ExportOptions['format']) => { + setOptions((prev) => { + const next = { ...prev, format } + if (format === 'html') { + return { + ...next, + exportMedia: true, + exportImages: true, + exportVoices: true, + exportEmojis: true, + exportVoiceAsText: true + } + } + return next + }) + } + const openExportFolder = async () => { if (exportFolder) { await window.electronAPI.shell.openPath(exportFolder) @@ -249,7 +266,7 @@ function ExportPage() { } : null } - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt') { + if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, @@ -455,7 +472,7 @@ function ExportPage() {
setOptions({ ...options, format: fmt.value as any })} + onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])} > {fmt.label}