From 1031c4013e622845aa67e5112577f4e3b99767e0 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 8 Feb 2026 22:42:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Eweclone=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 301 ++++++++++++++++++++++++++++- src/pages/ExportPage.tsx | 5 +- src/pages/SettingsPage.tsx | 1 + 3 files changed, 304 insertions(+), 3 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 383aede..9679cce 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -68,7 +68,7 @@ const MESSAGE_TYPE_MAP: Record = { } export interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' dateRange?: { start: number; end: number } | null exportMedia?: boolean exportAvatars?: boolean @@ -811,6 +811,55 @@ class ExportService { return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '') } + private getWeCloneTypeName(localType: number, content: string): string { + if (localType === 1) return 'text' + if (localType === 3) return 'image' + if (localType === 47) return 'sticker' + if (localType === 43) return 'video' + if (localType === 34) return 'voice' + if (localType === 48) return 'location' + if (localType === 49) { + const xmlType = this.extractXmlValue(content || '', 'type') + if (xmlType === '6') return 'file' + return 'text' + } + return 'text' + } + + private getWeCloneSource(msg: any, typeName: string, mediaItem: MediaExportItem | null): string { + if (mediaItem?.relativePath) { + return mediaItem.relativePath + } + + if (typeName === 'image') { + return msg.imageDatName || '' + } + if (typeName === 'sticker') { + return msg.emojiCdnUrl || '' + } + if (typeName === 'video') { + return '' + } + if (typeName === 'file') { + const xml = msg.content || '' + return this.extractXmlValue(xml, 'filename') || this.extractXmlValue(xml, 'title') || '' + } + return '' + } + + private escapeCsvCell(value: unknown): string { + if (value === null || value === undefined) return '' + const text = String(value) + if (/[",\r\n]/.test(text)) { + return `"${text.replace(/"/g, '""')}"` + } + return text + } + + private formatIsoTimestamp(timestamp: number): string { + return new Date(timestamp * 1000).toISOString() + } + /** * 从撤回消息内容中提取撤回者的 wxid * 撤回消息 XML 格式通常包含 等字段 @@ -3577,6 +3626,253 @@ class ExportService { } } + /** + * 导出单个会话为 WeClone CSV 格式 + */ + async exportSessionToWeCloneCsv( + 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) + + const contactCache = new Map() + const getContactCached = async (username: string) => { + if (contactCache.has(username)) { + return contactCache.get(username)! + } + const result = await wcdbService.getContact(username) + contactCache.set(username, result) + return result + } + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + if (collected.rows.length === 0) { + return { success: false, error: '该会话在指定时间范围内没有消息' } + } + + const senderUsernames = new Set() + for (const msg of collected.rows) { + if (msg.senderUsername) senderUsernames.add(msg.senderUsername) + } + senderUsernames.add(sessionId) + await this.preloadContacts(senderUsernames, contactCache) + + const groupNicknameCandidates = isGroup + ? this.buildGroupNicknameIdCandidates([ + ...Array.from(senderUsernames.values()), + ...collected.rows.map(msg => msg.senderUsername), + cleanedMyWxid + ]) + : [] + const groupNicknamesMap = isGroup + ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) + : new Map() + + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + + const voiceMessages = options.exportVoiceAsText + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + + if (options.exportVoiceAsText && voiceMessages.length > 0) { + await this.ensureVoiceModel(onProgress) + } + + 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 === 43 && options.exportVideos) || + (t === 34 && options.exportVoices) + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: 0, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 0/${mediaMessages.length}` + }) + + const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + let mediaExported = 0 + await parallelLimit(mediaMessages, mediaConcurrency, 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, + exportVideos: options.exportVideos, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText + }) + mediaCache.set(mediaKey, mediaItem) + } + mediaExported++ + if (mediaExported % 5 === 0 || mediaExported === mediaMessages.length) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media', + phaseProgress: mediaExported, + phaseTotal: mediaMessages.length, + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + }) + } + }) + } + + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: 0, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 0/${voiceMessages.length}` + }) + + const VOICE_CONCURRENCY = 4 + let voiceTranscribed = 0 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) + voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscribed++ + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice', + phaseProgress: voiceTranscribed, + phaseTotal: voiceMessages.length, + phaseLabel: `语音转文字 ${voiceTranscribed}/${voiceMessages.length}` + }) + }) + } + + onProgress?.({ + current: 60, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + const lines: string[] = [] + lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') + + for (let i = 0; i < sortedMessages.length; i++) { + const msg = sortedMessages[i] + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) || null + + const typeName = this.getWeCloneTypeName(msg.localType, msg.content || '') + let senderWxid = cleanedMyWxid + if (!msg.isSend) { + senderWxid = isGroup && msg.senderUsername + ? msg.senderUsername + : sessionId + } + + let talker = myInfo.displayName || '我' + if (!msg.isSend) { + const contactDetail = await getContactCached(senderWxid) + const senderNickname = contactDetail.success && contactDetail.contact + ? (contactDetail.contact.nickName || senderWxid) + : senderWxid + const senderRemark = contactDetail.success && contactDetail.contact + ? (contactDetail.contact.remark || '') + : '' + const senderGroupNickname = isGroup + ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) + : '' + talker = this.getPreferredDisplayName( + senderWxid, + senderNickname, + senderRemark, + senderGroupNickname, + options.displayNamePreference || 'remark' + ) + } + + const msgText = msg.localType === 34 && options.exportVoiceAsText + ? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]') + : (this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) || '') + const src = this.getWeCloneSource(msg, typeName, mediaItem) + + const row = [ + i + 1, + i + 1, + typeName, + msg.isSend ? 1 : 0, + talker, + msgText, + src, + this.formatIsoTimestamp(msg.createTime) + ] + + lines.push(row.map((value) => this.escapeCsvCell(value)).join(',')) + + if ((i + 1) % 200 === 0) { + const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30) + onProgress?.({ + current: progress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + } + } + + onProgress?.({ + current: 92, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + private getVirtualScrollScript(): string { return ` class ChunkedRenderer { @@ -4228,6 +4524,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 === 'weclone') ext = '.csv' else if (options.format === 'html') ext = '.html' const outputPath = path.join(sessionDir, `${safeName}${ext}`) @@ -4240,6 +4537,8 @@ class ExportService { result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'txt') { result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress) + } else if (options.format === 'weclone') { + result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, options, sessionProgress) } else if (options.format === 'html') { result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress) } else { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7ffc1cc..92b9d81 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -13,7 +13,7 @@ interface ChatSession { } interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' dateRange: { start: Date; end: Date } | null useAllTime: boolean exportAvatars: boolean @@ -360,7 +360,7 @@ function ExportPage() { } : null } - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') { + if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html' || options.format === 'weclone') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, @@ -513,6 +513,7 @@ function ExportPage() { { value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' }, { value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' }, { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, + { value: 'weclone', label: 'WeClone CSV', icon: Table, desc: 'WeClone 兼容字段格式(CSV)' }, { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } ] const displayNameOptions = [ diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b3187c9..b6a01d6 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1565,6 +1565,7 @@ function SettingsPage() { { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } ] const exportDateRangeOptions = [